diff --git a/lib/src/ast/sass/statement/stylesheet.dart b/lib/src/ast/sass/statement/stylesheet.dart index 55aad5419..a030a71aa 100644 --- a/lib/src/ast/sass/statement/stylesheet.dart +++ b/lib/src/ast/sass/statement/stylesheet.dart @@ -53,6 +53,11 @@ final class Stylesheet extends ParentStatement> { @internal final List parseTimeWarnings; + /// The set of (normalized) global variable names defined by this stylesheet + /// to the spans where they're defined. + @internal + final Map globalVariables; + Stylesheet(Iterable children, FileSpan span) : this.internal(children, span, []); @@ -62,8 +67,11 @@ final class Stylesheet extends ParentStatement> { @internal Stylesheet.internal(Iterable children, this.span, List parseTimeWarnings, - {this.plainCss = false}) + {this.plainCss = false, Map? globalVariables}) : parseTimeWarnings = UnmodifiableListView(parseTimeWarnings), + globalVariables = globalVariables == null + ? const {} + : Map.unmodifiable(globalVariables), super(List.unmodifiable(children)) { loop: for (var child in this.children) { diff --git a/lib/src/deprecation.dart b/lib/src/deprecation.dart index e7c4e6a4e..3281e720d 100644 --- a/lib/src/deprecation.dart +++ b/lib/src/deprecation.dart @@ -181,6 +181,7 @@ enum Deprecation { isFuture = false; /// Constructs a future deprecation. + // ignore: unused_element const Deprecation.future(this.id, {this.description}) : _deprecatedIn = null, _obsoleteIn = null, diff --git a/lib/src/embedded/compilation_dispatcher.dart b/lib/src/embedded/compilation_dispatcher.dart index a8c84c8eb..9862a97c1 100644 --- a/lib/src/embedded/compilation_dispatcher.dart +++ b/lib/src/embedded/compilation_dispatcher.dart @@ -145,7 +145,7 @@ final class CompilationDispatcher { try { var importers = request.importers.map((importer) => - _decodeImporter(request, importer) ?? + _decodeImporter(importer) ?? (throw mandatoryError("Importer.importer"))); var globalFunctions = request.globalFunctions @@ -159,7 +159,7 @@ final class CompilationDispatcher { color: request.alertColor, logger: logger, importers: importers, - importer: _decodeImporter(request, input.importer) ?? + importer: _decodeImporter(input.importer) ?? (input.url.startsWith("file:") ? null : sass.Importer.noOp), functions: globalFunctions, syntax: syntaxToSyntax(input.syntax), @@ -234,7 +234,7 @@ final class CompilationDispatcher { } /// Converts [importer] into a [sass.Importer]. - sass.Importer? _decodeImporter(InboundMessage_CompileRequest request, + sass.Importer? _decodeImporter( InboundMessage_CompileRequest_Importer importer) { switch (importer.whichImporter()) { case InboundMessage_CompileRequest_Importer_Importer.path: @@ -249,13 +249,13 @@ final class CompilationDispatcher { _checkNoNonCanonicalScheme(importer); return FileImporter(this, importer.fileImporterId); - case InboundMessage_CompileRequest_Importer_Importer.notSet: - _checkNoNonCanonicalScheme(importer); - return null; - case InboundMessage_CompileRequest_Importer_Importer.nodePackageImporter: return npi.NodePackageImporter( importer.nodePackageImporter.entryPointDirectory); + + case InboundMessage_CompileRequest_Importer_Importer.notSet: + _checkNoNonCanonicalScheme(importer); + return null; } } diff --git a/lib/src/functions/color.dart b/lib/src/functions/color.dart index 7f0acd1fb..abbf9d00a 100644 --- a/lib/src/functions/color.dart +++ b/lib/src/functions/color.dart @@ -1610,7 +1610,7 @@ BuiltInCallable _channelFunction( warnForDeprecation( "${global ? '' : 'color.'}$name() is deprecated. Suggestion:\n" "\n" - 'color.channel(\$color, \"$name\", \$space: $space)\n' + 'color.channel(\$color, "$name", \$space: $space)\n' "\n" "More info: https://sass-lang.com/d/color-functions", Deprecation.colorFunctions); diff --git a/lib/src/parse/stylesheet.dart b/lib/src/parse/stylesheet.dart index cab1bf972..94f551d2d 100644 --- a/lib/src/parse/stylesheet.dart +++ b/lib/src/parse/stylesheet.dart @@ -61,13 +61,13 @@ abstract class StylesheetParser extends Parser { var _inExpression = false; /// A map from all variable names that are assigned with `!global` in the - /// current stylesheet to the nodes where they're defined. + /// current stylesheet to the spans where they're defined. /// /// These are collected at parse time because they affect the variables /// exposed by the module generated for this stylesheet, *even if they aren't /// evaluated*. This allows us to ensure that the stylesheet always exposes /// the same set of variable names no matter how it's evaluated. - final _globalVariables = {}; + final _globalVariables = {}; /// Warnings discovered while parsing that should be emitted during /// evaluation once a proper logger is available. @@ -100,15 +100,8 @@ abstract class StylesheetParser extends Parser { }); scanner.expectDone(); - /// Ensure that all global variable assignments produce a variable in this - /// stylesheet, even if they aren't evaluated. See sass/language#50. - statements.addAll(_globalVariables.values.map((declaration) => - VariableDeclaration(declaration.name, - NullExpression(declaration.expression.span), declaration.span, - guarded: true))); - return Stylesheet.internal(statements, scanner.spanFrom(start), warnings, - plainCss: plainCss); + plainCss: plainCss, globalVariables: _globalVariables); }); } @@ -288,7 +281,7 @@ abstract class StylesheetParser extends Parser { guarded: guarded, global: global, comment: precedingComment); - if (global) _globalVariables.putIfAbsent(name, () => declaration); + if (global) _globalVariables.putIfAbsent(name, () => declaration.span); return declaration; } diff --git a/lib/src/visitor/async_evaluate.dart b/lib/src/visitor/async_evaluate.dart index 69d9b0d88..4dc806e67 100644 --- a/lib/src/visitor/async_evaluate.dart +++ b/lib/src/visitor/async_evaluate.dart @@ -1005,6 +1005,14 @@ final class _EvaluateVisitor for (var child in node.children) { await child.accept(this); } + + // Make sure all global variables declared in a module always appear in the + // module's definition, even if their assignments aren't reached. + for (var (name, span) in node.globalVariables.pairs) { + visitVariableDeclaration( + VariableDeclaration(name, NullExpression(span), span, guarded: true)); + } + return null; } diff --git a/lib/src/visitor/evaluate.dart b/lib/src/visitor/evaluate.dart index 5e29377df..e06601361 100644 --- a/lib/src/visitor/evaluate.dart +++ b/lib/src/visitor/evaluate.dart @@ -5,7 +5,7 @@ // DO NOT EDIT. This file was generated from async_evaluate.dart. // See tool/grind/synchronize.dart for details. // -// Checksum: e7260fedcd4f374ba517a93d038c3c53586c9622 +// Checksum: 396c8f169d95c601598b8c3be1f4b948ca22effa // // ignore_for_file: unused_import @@ -1005,6 +1005,14 @@ final class _EvaluateVisitor for (var child in node.children) { child.accept(this); } + + // Make sure all global variables declared in a module always appear in the + // module's definition, even if their assignments aren't reached. + for (var (name, span) in node.globalVariables.pairs) { + visitVariableDeclaration( + VariableDeclaration(name, NullExpression(span), span, guarded: true)); + } + return null; } diff --git a/pkg/sass-parser/CHANGELOG.md b/pkg/sass-parser/CHANGELOG.md index 699914194..b55725a13 100644 --- a/pkg/sass-parser/CHANGELOG.md +++ b/pkg/sass-parser/CHANGELOG.md @@ -1,6 +1,8 @@ ## 0.4.2 -* No user-visible changes. +* Add support for parsing variable declarations. + +* Add support for parsing the `@warn` rule. ## 0.4.1 diff --git a/pkg/sass-parser/lib/index.ts b/pkg/sass-parser/lib/index.ts index 6b878c390..3a890ffa9 100644 --- a/pkg/sass-parser/lib/index.ts +++ b/pkg/sass-parser/lib/index.ts @@ -96,6 +96,12 @@ export { StatementType, StatementWithChildren, } from './src/statement'; +export { + VariableDeclaration, + VariableDeclarationProps, + VariableDeclarationRaws, +} from './src/statement/variable-declaration'; +export {WarnRule, WarnRuleProps, WarnRuleRaws} from './src/statement/warn-rule'; /** Options that can be passed to the Sass parsers to control their behavior. */ export type SassParserOptions = Pick; diff --git a/pkg/sass-parser/lib/src/sass-internal.ts b/pkg/sass-parser/lib/src/sass-internal.ts index 8c7093daa..055e9ca0a 100644 --- a/pkg/sass-parser/lib/src/sass-internal.ts +++ b/pkg/sass-parser/lib/src/sass-internal.ts @@ -180,6 +180,18 @@ declare namespace SassInternal { readonly configuration: ConfiguredVariable[]; } + class VariableDeclaration extends Statement { + readonly namespace: string | null; + readonly name: string; + readonly expression: Expression; + readonly isGuarded: boolean; + readonly isGlobal: boolean; + } + + class WarnRule extends Statement { + readonly expression: Expression; + } + class ConfiguredVariable extends SassNode { readonly name: string; readonly expression: Expression; @@ -238,6 +250,8 @@ export type Stylesheet = SassInternal.Stylesheet; export type StyleRule = SassInternal.StyleRule; export type SupportsRule = SassInternal.SupportsRule; export type UseRule = SassInternal.UseRule; +export type VariableDeclaration = SassInternal.VariableDeclaration; +export type WarnRule = SassInternal.WarnRule; export type ConfiguredVariable = SassInternal.ConfiguredVariable; export type Interpolation = SassInternal.Interpolation; export type Expression = SassInternal.Expression; @@ -260,6 +274,8 @@ export interface StatementVisitorObject { visitStyleRule(node: StyleRule): T; visitSupportsRule(node: SupportsRule): T; visitUseRule(node: UseRule): T; + visitVariableDeclaration(node: VariableDeclaration): T; + visitWarnRule(node: WarnRule): T; } export interface ExpressionVisitorObject { diff --git a/pkg/sass-parser/lib/src/statement/__snapshots__/variable-declaration.test.ts.snap b/pkg/sass-parser/lib/src/statement/__snapshots__/variable-declaration.test.ts.snap new file mode 100644 index 000000000..b2b5e0501 --- /dev/null +++ b/pkg/sass-parser/lib/src/statement/__snapshots__/variable-declaration.test.ts.snap @@ -0,0 +1,22 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`a variable declaration toJSON 1`] = ` +{ + "expression": <"bar">, + "global": false, + "guarded": false, + "inputs": [ + { + "css": "baz.$foo: "bar"", + "hasBOM": false, + "id": "", + }, + ], + "namespace": "baz", + "raws": {}, + "sassType": "variable-declaration", + "source": <1:1-1:16 in 0>, + "type": "decl", + "variableName": "foo", +} +`; diff --git a/pkg/sass-parser/lib/src/statement/__snapshots__/warn-rule.test.ts.snap b/pkg/sass-parser/lib/src/statement/__snapshots__/warn-rule.test.ts.snap new file mode 100644 index 000000000..b072acb85 --- /dev/null +++ b/pkg/sass-parser/lib/src/statement/__snapshots__/warn-rule.test.ts.snap @@ -0,0 +1,20 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`a @warn rule toJSON 1`] = ` +{ + "inputs": [ + { + "css": "@warn foo", + "hasBOM": false, + "id": "", + }, + ], + "name": "warn", + "params": "foo", + "raws": {}, + "sassType": "warn-rule", + "source": <1:1-1:10 in 0>, + "type": "atrule", + "warnExpression": , +} +`; diff --git a/pkg/sass-parser/lib/src/statement/declaration-internal.d.ts b/pkg/sass-parser/lib/src/statement/declaration-internal.d.ts new file mode 100644 index 000000000..c10d2fa6e --- /dev/null +++ b/pkg/sass-parser/lib/src/statement/declaration-internal.d.ts @@ -0,0 +1,77 @@ +// Copyright 2024 Google Inc. Use of this source code is governed by an +// MIT-style license that can be found in the LICENSE file or at +// https://opensource.org/licenses/MIT. + +import * as postcss from 'postcss'; + +import {Rule} from './rule'; +import {Root} from './root'; +import {AtRule, ChildNode, Comment, Declaration, NewNode} from '.'; + +/** + * A fake intermediate class to convince TypeScript to use Sass types for + * various upstream methods. + * + * @hidden + */ +export class _Declaration extends postcss.Declaration { + // Override the PostCSS container types to constrain them to Sass types only. + // Unfortunately, there's no way to abstract this out, because anything + // mixin-like returns an intersection type which doesn't actually override + // parent methods. See microsoft/TypeScript#59394. + + after(newNode: NewNode): this; + append(...nodes: NewNode[]): this; + assign(overrides: Partial): this; + before(newNode: NewNode): this; + cloneAfter(overrides?: Partial): this; + cloneBefore(overrides?: Partial): this; + each( + callback: (node: ChildNode, index: number) => false | void + ): false | undefined; + every( + condition: (node: ChildNode, index: number, nodes: ChildNode[]) => boolean + ): boolean; + insertAfter(oldNode: postcss.ChildNode | number, newNode: NewNode): this; + insertBefore(oldNode: postcss.ChildNode | number, newNode: NewNode): this; + next(): ChildNode | undefined; + prepend(...nodes: NewNode[]): this; + prev(): ChildNode | undefined; + replaceWith(...nodes: NewNode[]): this; + root(): Root; + some( + condition: (node: ChildNode, index: number, nodes: ChildNode[]) => boolean + ): boolean; + walk( + callback: (node: ChildNode, index: number) => false | void + ): false | undefined; + walkAtRules( + nameFilter: RegExp | string, + callback: (atRule: AtRule, index: number) => false | void + ): false | undefined; + walkAtRules( + callback: (atRule: AtRule, index: number) => false | void + ): false | undefined; + walkComments( + callback: (comment: Comment, indexed: number) => false | void + ): false | undefined; + walkComments( + callback: (comment: Comment, indexed: number) => false | void + ): false | undefined; + walkDecls( + propFilter: RegExp | string, + callback: (decl: Declaration, index: number) => false | void + ): false | undefined; + walkDecls( + callback: (decl: Declaration, index: number) => false | void + ): false | undefined; + walkRules( + selectorFilter: RegExp | string, + callback: (rule: Rule, index: number) => false | void + ): false | undefined; + walkRules( + callback: (rule: Rule, index: number) => false | void + ): false | undefined; + get first(): ChildNode | undefined; + get last(): ChildNode | undefined; +} diff --git a/pkg/sass-parser/lib/src/statement/declaration-internal.js b/pkg/sass-parser/lib/src/statement/declaration-internal.js new file mode 100644 index 000000000..8472c1ec9 --- /dev/null +++ b/pkg/sass-parser/lib/src/statement/declaration-internal.js @@ -0,0 +1,5 @@ +// Copyright 2024 Google Inc. Use of this source code is governed by an +// MIT-style license that can be found in the LICENSE file or at +// https://opensource.org/licenses/MIT. + +exports._Declaration = require('postcss').Declaration; diff --git a/pkg/sass-parser/lib/src/statement/index.ts b/pkg/sass-parser/lib/src/statement/index.ts index 10a0aae11..da018916a 100644 --- a/pkg/sass-parser/lib/src/statement/index.ts +++ b/pkg/sass-parser/lib/src/statement/index.ts @@ -18,6 +18,11 @@ import {ForRule, ForRuleProps} from './for-rule'; import {Root} from './root'; import {Rule, RuleProps} from './rule'; import {UseRule, UseRuleProps} from './use-rule'; +import { + VariableDeclaration, + VariableDeclarationProps, +} from './variable-declaration'; +import {WarnRule, WarnRuleProps} from './warn-rule'; // TODO: Replace this with the corresponding Sass types once they're // implemented. @@ -28,7 +33,7 @@ export {Declaration} from 'postcss'; * * @category Statement */ -export type AnyStatement = Comment | Root | Rule | GenericAtRule; +export type AnyStatement = Comment | Root | Rule | AtRule | VariableDeclaration; /** * Sass statement types. @@ -49,7 +54,9 @@ export type StatementType = | 'for-rule' | 'error-rule' | 'use-rule' - | 'sass-comment'; + | 'sass-comment' + | 'variable-declaration' + | 'warn-rule'; /** * All Sass statements that are also at-rules. @@ -62,7 +69,8 @@ export type AtRule = | ErrorRule | ForRule | GenericAtRule - | UseRule; + | UseRule + | WarnRule; /** * All Sass statements that are comments. @@ -78,7 +86,7 @@ export type Comment = CssComment | SassComment; * * @category Statement */ -export type ChildNode = Rule | AtRule | Comment; +export type ChildNode = Rule | AtRule | Comment | VariableDeclaration; /** * The properties that can be used to construct {@link ChildNode}s. @@ -97,7 +105,9 @@ export type ChildProps = | GenericAtRuleProps | RuleProps | SassCommentChildProps - | UseRuleProps; + | UseRuleProps + | VariableDeclarationProps + | WarnRuleProps; /** * The Sass eqivalent of PostCSS's `ContainerProps`. @@ -185,6 +195,8 @@ const visitor = sassInternal.createStatementVisitor({ return rule; }, visitUseRule: inner => new UseRule(undefined, inner), + visitVariableDeclaration: inner => new VariableDeclaration(undefined, inner), + visitWarnRule: inner => new WarnRule(undefined, inner), }); /** Appends parsed versions of `internal`'s children to `container`. */ @@ -301,6 +313,10 @@ export function normalize( result.push(new SassComment(node)); } else if ('useUrl' in node) { result.push(new UseRule(node)); + } else if ('variableName' in node) { + result.push(new VariableDeclaration(node)); + } else if ('warnExpression' in node) { + result.push(new WarnRule(node)); } else { result.push(...postcssNormalizeAndConvertToSass(self, node, sample)); } diff --git a/pkg/sass-parser/lib/src/statement/variable-declaration.test.ts b/pkg/sass-parser/lib/src/statement/variable-declaration.test.ts new file mode 100644 index 000000000..cfb943296 --- /dev/null +++ b/pkg/sass-parser/lib/src/statement/variable-declaration.test.ts @@ -0,0 +1,619 @@ +// Copyright 2024 Google Inc. Use of this source code is governed by an +// MIT-style license that can be found in the LICENSE file or at +// https://opensource.org/licenses/MIT. + +import {StringExpression, VariableDeclaration, sass, scss} from '../..'; +import * as utils from '../../../test/utils'; + +describe('a variable declaration', () => { + let node: VariableDeclaration; + beforeEach( + () => + void (node = new VariableDeclaration({ + variableName: 'foo', + expression: {text: 'bar'}, + })) + ); + + describe('with no namespace and no flags', () => { + function describeNode( + description: string, + create: () => VariableDeclaration + ): void { + describe(description, () => { + beforeEach(() => (node = create())); + + it('has a type', () => expect(node.type.toString()).toBe('decl')); + + it('has a sassType', () => + expect(node.sassType.toString()).toBe('variable-declaration')); + + it('has no namespace', () => expect(node.namespace).toBeUndefined()); + + it('has a name', () => expect(node.variableName).toBe('foo')); + + it('has an expression', () => + expect(node).toHaveStringExpression('expression', 'bar')); + + it('has a value', () => expect(node.value).toBe('bar')); + + it('is not guarded', () => expect(node.guarded).toBe(false)); + + it('is not global', () => expect(node.global).toBe(false)); + }); + } + + describeNode( + 'parsed as SCSS', + () => scss.parse('$foo: bar').nodes[0] as VariableDeclaration + ); + + describeNode( + 'parsed as Sass', + () => sass.parse('$foo: bar').nodes[0] as VariableDeclaration + ); + + describe('constructed manually', () => { + describeNode( + 'with an Expression', + () => + new VariableDeclaration({ + variableName: 'foo', + expression: new StringExpression({text: 'bar'}), + }) + ); + + describeNode( + 'with child props', + () => + new VariableDeclaration({ + variableName: 'foo', + expression: {text: 'bar'}, + }) + ); + + describeNode( + 'with a value', + () => + new VariableDeclaration({ + variableName: 'foo', + value: 'bar', + }) + ); + }); + + describeNode('constructed from ChildProps', () => + utils.fromChildProps({variableName: 'foo', expression: {text: 'bar'}}) + ); + }); + + describe('with a namespace', () => { + function describeNode( + description: string, + create: () => VariableDeclaration + ): void { + describe(description, () => { + beforeEach(() => (node = create())); + + it('has a type', () => expect(node.type.toString()).toBe('decl')); + + it('has a sassType', () => + expect(node.sassType.toString()).toBe('variable-declaration')); + + it('has a namespace', () => expect(node.namespace).toBe('baz')); + + it('has a name', () => expect(node.variableName).toBe('foo')); + + it('has an expression', () => + expect(node).toHaveStringExpression('expression', 'bar')); + + it('has a value', () => expect(node.value).toBe('"bar"')); + + it('is not guarded', () => expect(node.guarded).toBe(false)); + + it('is not global', () => expect(node.global).toBe(false)); + }); + } + + describeNode( + 'parsed as SCSS', + () => scss.parse('baz.$foo: "bar"').nodes[0] as VariableDeclaration + ); + + describeNode( + 'parsed as Sass', + () => sass.parse('baz.$foo: "bar"').nodes[0] as VariableDeclaration + ); + + describeNode( + 'constructed manually', + () => + new VariableDeclaration({ + namespace: 'baz', + variableName: 'foo', + expression: new StringExpression({text: 'bar', quotes: true}), + }) + ); + + describeNode('constructed from ChildProps', () => + utils.fromChildProps({ + namespace: 'baz', + variableName: 'foo', + expression: {text: 'bar', quotes: true}, + }) + ); + }); + + describe('guarded', () => { + function describeNode( + description: string, + create: () => VariableDeclaration + ): void { + describe(description, () => { + beforeEach(() => (node = create())); + + it('has a type', () => expect(node.type.toString()).toBe('decl')); + + it('has a sassType', () => + expect(node.sassType.toString()).toBe('variable-declaration')); + + it('has no namespace', () => expect(node.namespace).toBeUndefined()); + + it('has a name', () => expect(node.variableName).toBe('foo')); + + it('has an expression', () => + expect(node).toHaveStringExpression('expression', 'bar')); + + it('has a value', () => expect(node.value).toBe('"bar"')); + + it('is guarded', () => expect(node.guarded).toBe(true)); + + it('is not global', () => expect(node.global).toBe(false)); + }); + } + + describeNode( + 'parsed as SCSS', + () => scss.parse('$foo: "bar" !default').nodes[0] as VariableDeclaration + ); + + describeNode( + 'parsed as Sass', + () => sass.parse('$foo: "bar" !default').nodes[0] as VariableDeclaration + ); + + describeNode( + 'constructed manually', + () => + new VariableDeclaration({ + variableName: 'foo', + expression: new StringExpression({text: 'bar', quotes: true}), + guarded: true, + }) + ); + + describeNode('constructed from ChildProps', () => + utils.fromChildProps({ + variableName: 'foo', + expression: {text: 'bar', quotes: true}, + guarded: true, + }) + ); + }); + + describe('global', () => { + function describeNode( + description: string, + create: () => VariableDeclaration + ): void { + describe(description, () => { + beforeEach(() => (node = create())); + + it('has a type', () => expect(node.type.toString()).toBe('decl')); + + it('has a sassType', () => + expect(node.sassType.toString()).toBe('variable-declaration')); + + it('has no namespace', () => expect(node.namespace).toBeUndefined()); + + it('has a name', () => expect(node.variableName).toBe('foo')); + + it('has an expression', () => + expect(node).toHaveStringExpression('expression', 'bar')); + + it('has a value', () => expect(node.value).toBe('"bar"')); + + it('is not guarded', () => expect(node.guarded).toBe(false)); + + it('is global', () => expect(node.global).toBe(true)); + }); + } + + describeNode( + 'parsed as SCSS', + () => scss.parse('$foo: "bar" !global').nodes[0] as VariableDeclaration + ); + + describeNode( + 'parsed as Sass', + () => sass.parse('$foo: "bar" !global').nodes[0] as VariableDeclaration + ); + + describeNode( + 'constructed manually', + () => + new VariableDeclaration({ + variableName: 'foo', + expression: new StringExpression({text: 'bar', quotes: true}), + global: true, + }) + ); + + describeNode('constructed from ChildProps', () => + utils.fromChildProps({ + variableName: 'foo', + expression: {text: 'bar', quotes: true}, + global: true, + }) + ); + }); + + it('throws an error when assigned a new prop', () => + expect(() => (node.prop = 'bar')).toThrow()); + + it('assigned a new namespace', () => { + node.namespace = 'baz'; + expect(node.namespace).toBe('baz'); + expect(node.prop).toBe('baz.$foo'); + }); + + it('assigned a new variableName', () => { + node.variableName = 'baz'; + expect(node.variableName).toBe('baz'); + expect(node.prop).toBe('$baz'); + }); + + it('assigned a new expression', () => { + const old = node.expression; + node.expression = {text: 'baz'}; + expect(old.parent).toBeUndefined(); + expect(node).toHaveStringExpression('expression', 'baz'); + }); + + it('assigned a new expression', () => { + const old = node.expression; + node.expression = {text: 'baz'}; + expect(old.parent).toBeUndefined(); + expect(node).toHaveStringExpression('expression', 'baz'); + }); + + it('assigned a value', () => { + node.value = 'Helvetica, sans-serif'; + expect(node).toHaveStringExpression('expression', 'Helvetica, sans-serif'); + }); + + it('is a variable', () => expect(node.variable).toBe(true)); + + describe('stringifies', () => { + describe('to SCSS', () => { + describe('with default raws', () => { + it('with no flags', () => + expect( + new VariableDeclaration({ + variableName: 'foo', + expression: {text: 'bar'}, + }).toString() + ).toBe('$foo: bar')); + + describe('with a namespace', () => { + it("that's an identifier", () => + expect( + new VariableDeclaration({ + namespace: 'baz', + variableName: 'foo', + expression: {text: 'bar'}, + }).toString() + ).toBe('baz.$foo: bar')); + + it("that's not an identifier", () => + expect( + new VariableDeclaration({ + namespace: 'b z', + variableName: 'foo', + expression: {text: 'bar'}, + }).toString() + ).toBe('b\\20z.$foo: bar')); + }); + + it("with a name that's not an identifier", () => + expect( + new VariableDeclaration({ + variableName: 'f o', + expression: {text: 'bar'}, + }).toString() + ).toBe('$f\\20o: bar')); + + it('global', () => + expect( + new VariableDeclaration({ + variableName: 'foo', + expression: {text: 'bar'}, + global: true, + }).toString() + ).toBe('$foo: bar !global')); + + it('guarded', () => + expect( + new VariableDeclaration({ + variableName: 'foo', + expression: {text: 'bar'}, + guarded: true, + }).toString() + ).toBe('$foo: bar !default')); + + it('with both flags', () => + expect( + new VariableDeclaration({ + variableName: 'foo', + expression: {text: 'bar'}, + global: true, + guarded: true, + }).toString() + ).toBe('$foo: bar !default !global')); + }); + + describe('with a namespace raw', () => { + it('that matches', () => + expect( + new VariableDeclaration({ + namespace: 'baz', + variableName: 'foo', + expression: {text: 'bar'}, + raws: {namespace: {raw: 'b\\41z', value: 'baz'}}, + }).toString() + ).toBe('b\\41z.$foo: bar')); + + it("that doesn't match", () => + expect( + new VariableDeclaration({ + namespace: 'baz', + variableName: 'foo', + expression: {text: 'bar'}, + raws: {namespace: {raw: 'z\\41p', value: 'zap'}}, + }).toString() + ).toBe('baz.$foo: bar')); + }); + + describe('with a variableName raw', () => { + it('that matches', () => + expect( + new VariableDeclaration({ + variableName: 'foo', + expression: {text: 'bar'}, + raws: {variableName: {raw: 'f\\f3o', value: 'foo'}}, + }).toString() + ).toBe('$f\\f3o: bar')); + + it("that doesn't match", () => + expect( + new VariableDeclaration({ + variableName: 'foo', + expression: {text: 'bar'}, + raws: {namespace: {raw: 'z\\41p', value: 'zap'}}, + }).toString() + ).toBe('$foo: bar')); + }); + + it('with between', () => + expect( + new VariableDeclaration({ + variableName: 'foo', + expression: {text: 'bar'}, + raws: {between: '/**/:'}, + }).toString() + ).toBe('$foo/**/:bar')); + + describe('with a flags raw', () => { + it('that matches both', () => + expect( + new VariableDeclaration({ + variableName: 'foo', + expression: {text: 'bar'}, + guarded: true, + raws: { + flags: { + raw: '/**/!default', + value: {guarded: true, global: false}, + }, + }, + }).toString() + ).toBe('$foo: bar/**/!default')); + + it('that matches only one', () => + expect( + new VariableDeclaration({ + variableName: 'foo', + expression: {text: 'bar'}, + guarded: true, + raws: { + flags: { + raw: '/**/!default !global', + value: {guarded: true, global: true}, + }, + }, + }).toString() + ).toBe('$foo: bar !default')); + + it('that matches neither', () => + expect( + new VariableDeclaration({ + variableName: 'foo', + expression: {text: 'bar'}, + guarded: true, + raws: { + flags: { + raw: '/**/!global', + value: {guarded: false, global: true}, + }, + }, + }).toString() + ).toBe('$foo: bar !default')); + }); + + describe('with an afterValue raw', () => { + it('without flags', () => + expect( + new VariableDeclaration({ + variableName: 'foo', + expression: {text: 'bar'}, + raws: {afterValue: '/**/'}, + }).toString() + ).toBe('$foo: bar/**/')); + + it('with flags', () => + expect( + new VariableDeclaration({ + variableName: 'foo', + expression: {text: 'bar'}, + global: true, + raws: {afterValue: '/**/'}, + }).toString() + ).toBe('$foo: bar !global/**/')); + }); + }); + }); + + describe('clone', () => { + let original: VariableDeclaration; + beforeEach(() => { + original = scss.parse('baz.$foo: bar !default') + .nodes[0] as VariableDeclaration; + // TODO: remove this once raws are properly parsed + original.raws.between = ' :'; + }); + + describe('with no overrides', () => { + let clone: VariableDeclaration; + beforeEach(() => void (clone = original.clone())); + + describe('has the same properties:', () => { + it('prop', () => expect(clone.prop).toBe('baz.$foo')); + + it('namespace', () => expect(clone.namespace).toBe('baz')); + + it('variableName', () => expect(clone.variableName).toBe('foo')); + + it('expression', () => + expect(clone).toHaveStringExpression('expression', 'bar')); + + it('global', () => expect(clone.global).toBe(false)); + + it('guarded', () => expect(clone.guarded).toBe(true)); + }); + + describe('creates a new', () => { + it('self', () => expect(clone).not.toBe(original)); + + for (const attr of ['expression', 'raws'] as const) { + it(attr, () => expect(clone[attr]).not.toBe(original[attr])); + } + }); + }); + + describe('overrides', () => { + describe('raws', () => { + it('defined', () => + expect(original.clone({raws: {afterValue: ' '}}).raws).toEqual({ + afterValue: ' ', + })); + + it('undefined', () => + expect(original.clone({raws: undefined}).raws).toEqual({ + between: ' :', + })); + }); + + describe('namespace', () => { + describe('defined', () => { + let clone: VariableDeclaration; + beforeEach(() => { + clone = original.clone({namespace: 'zap'}); + }); + + it('changes namespace', () => expect(clone.namespace).toBe('zap')); + + it('changes prop', () => expect(clone.prop).toBe('zap.$foo')); + }); + + describe('undefined', () => { + let clone: VariableDeclaration; + beforeEach(() => { + clone = original.clone({namespace: undefined}); + }); + + it('removes namespace', () => + expect(clone.namespace).toBeUndefined()); + + it('changes prop', () => expect(clone.prop).toBe('$foo')); + }); + }); + + describe('variableName', () => { + describe('defined', () => { + let clone: VariableDeclaration; + beforeEach(() => { + clone = original.clone({variableName: 'zap'}); + }); + + it('changes variableName', () => + expect(clone.variableName).toBe('zap')); + + it('changes prop', () => expect(clone.prop).toBe('baz.$zap')); + }); + + describe('undefined', () => { + let clone: VariableDeclaration; + beforeEach(() => { + clone = original.clone({variableName: undefined}); + }); + + it('preserves variableName', () => + expect(clone.variableName).toBe('foo')); + + it('preserves prop', () => expect(clone.prop).toBe('baz.$foo')); + }); + }); + + describe('expression', () => { + it('defined changes expression', () => + expect( + original.clone({expression: {text: 'zap'}}) + ).toHaveStringExpression('expression', 'zap')); + + it('undefined preserves expression', () => + expect( + original.clone({expression: undefined}) + ).toHaveStringExpression('expression', 'bar')); + }); + + describe('guarded', () => { + it('defined changes guarded', () => + expect(original.clone({guarded: false}).guarded).toBe(false)); + + it('undefined preserves guarded', () => + expect(original.clone({guarded: undefined}).guarded).toBe(true)); + }); + + describe('global', () => { + it('defined changes global', () => + expect(original.clone({global: true}).global).toBe(true)); + + it('undefined preserves global', () => + expect(original.clone({global: undefined}).global).toBe(false)); + }); + }); + }); + + it('toJSON', () => + expect(scss.parse('baz.$foo: "bar"').nodes[0]).toMatchSnapshot()); +}); diff --git a/pkg/sass-parser/lib/src/statement/variable-declaration.ts b/pkg/sass-parser/lib/src/statement/variable-declaration.ts new file mode 100644 index 000000000..780e2a4cf --- /dev/null +++ b/pkg/sass-parser/lib/src/statement/variable-declaration.ts @@ -0,0 +1,228 @@ +// Copyright 2024 Google Inc. Use of this source code is governed by an +// MIT-style license that can be found in the LICENSE file or at +// https://opensource.org/licenses/MIT. + +import * as postcss from 'postcss'; +import type {DeclarationRaws} from 'postcss/lib/declaration'; + +import {Expression, ExpressionProps} from '../expression'; +import {convertExpression} from '../expression/convert'; +import {fromProps} from '../expression/from-props'; +import {LazySource} from '../lazy-source'; +import {RawWithValue} from '../raw-with-value'; +import * as sassInternal from '../sass-internal'; +import * as utils from '../utils'; +import {Statement, StatementWithChildren} from '.'; +import {_Declaration} from './declaration-internal'; + +/** + * The set of raws supported by {@link VariableDeclaration}. + * + * @category Statement + */ +export interface VariableDeclarationRaws + extends Omit { + /** + * The variable's namespace. + * + * This may be different than {@link VariableDeclarationRaws.namespace} if the + * name contains escape codes or underscores. + */ + namespace?: RawWithValue; + + /** + * The variable's name, not including the `$`. + * + * This may be different than {@link VariableDeclarationRaws.variableName} if + * the name contains escape codes or underscores. + */ + variableName?: RawWithValue; + + /** The whitespace and colon between the variable name and value. */ + between?: string; + + /** The `!default` and/or `!global` flags, including preceding whitespace. */ + flags?: RawWithValue<{guarded: boolean; global: boolean}>; + + /** + * The space symbols between the end of the variable declaration and the + * semicolon afterwards. Always empty for a variable that isn't followed by a + * semicolon. + */ + afterValue?: string; +} + +/** + * The initializer properties for {@link VariableDeclaration}. + * + * @category Statement + */ +export type VariableDeclarationProps = { + raws?: VariableDeclarationRaws; + namespace?: string; + variableName: string; + guarded?: boolean; + global?: boolean; +} & ({expression: Expression | ExpressionProps} | {value: string}); + +/** + * A Sass variable declaration. Extends [`postcss.Declaration`]. + * + * [`postcss.AtRule`]: https://postcss.org/api/#declaration + * + * @category Statement + */ +export class VariableDeclaration + extends _Declaration> + implements Statement +{ + readonly sassType = 'variable-declaration' as const; + declare parent: StatementWithChildren | undefined; + declare raws: VariableDeclarationRaws; + + /** + * The variable name, not including `$`. + * + * This is the parsed value, with escapes resolved to the characters they + * represent. + */ + declare namespace: string | undefined; + + /** + * The variable name, not including `$`. + * + * This is the parsed and normalized value, with underscores converted to + * hyphens and escapes resolved to the characters they represent. + */ + declare variableName: string; + + /** The variable's value. */ + get expression(): Expression { + return this._expression; + } + set expression(value: Expression | ExpressionProps) { + if (this._expression) this._expression.parent = undefined; + if (!('sassType' in value)) value = fromProps(value); + if (value) value.parent = this; + this._expression = value; + } + private _expression!: Expression; + + /** Whether the variable has a `!default` flag. */ + declare guarded: boolean; + + /** Whether the variable has a `!global` flag. */ + declare global: boolean; + + get prop(): string { + return ( + (this.namespace + ? (this.raws.namespace?.value === this.namespace + ? this.raws.namespace.raw + : sassInternal.toCssIdentifier(this.namespace)) + '.' + : '') + + '$' + + (this.raws.variableName?.value === this.variableName + ? this.raws.variableName.raw + : sassInternal.toCssIdentifier(this.variableName)) + ); + } + set prop(value: string) { + throw new Error("VariableDeclaration.prop can't be overwritten."); + } + + get value(): string { + return this.expression.toString(); + } + set value(value: string) { + this.expression = {text: value}; + } + + get important(): boolean { + // TODO: Return whether `this.expression` is a nested series of unbracketed + // list expressions that ends in the unquoted string `!important` (or an + // unquoted string ending in " !important", which can occur if `value` is + // set // manually). + throw new Error('Not yet implemented'); + } + set important(value: boolean) { + // TODO: If value !== this.important, either set this to a space-separated + // list whose second value is `!important` or remove the existing + // `!important` from wherever it's defined. Or if that's too complex, just + // bake this to a string expression and edit that. + throw new Error('Not yet implemented'); + } + + get variable(): boolean { + return true; + } + + constructor(defaults: VariableDeclarationProps); + /** @hidden */ + constructor(_: undefined, inner: sassInternal.VariableDeclaration); + constructor( + defaults?: VariableDeclarationProps, + inner?: sassInternal.VariableDeclaration + ) { + super(defaults as unknown as postcss.DeclarationProps); + this.raws ??= {}; + + if (inner) { + this.source = new LazySource(inner); + this.namespace = inner.namespace ? inner.namespace : undefined; + this.variableName = inner.name; + this.expression = convertExpression(inner.expression); + this.guarded = inner.isGuarded; + this.global = inner.isGlobal; + } else { + this.guarded ??= false; + this.global ??= false; + } + } + + clone(overrides?: Partial): this { + return utils.cloneNode( + this, + overrides, + [ + 'raws', + {name: 'namespace', explicitUndefined: true}, + 'variableName', + 'expression', + 'guarded', + 'global', + ], + ['value'] + ); + } + + toJSON(): object; + /** @hidden */ + toJSON(_: string, inputs: Map): object; + toJSON(_?: string, inputs?: Map): object { + return utils.toJSON( + this, + ['namespace', 'variableName', 'expression', 'guarded', 'global'], + inputs + ); + } + + /** @hidden */ + toString(): string { + return ( + this.prop + + (this.raws.between ?? ': ') + + this.expression + + (this.raws.flags?.value?.guarded === this.guarded && + this.raws.flags?.value?.global === this.global + ? this.raws.flags.raw + : (this.guarded ? ' !default' : '') + (this.global ? ' !global' : '')) + + (this.raws.afterValue ?? '') + ); + } + + /** @hidden */ + get nonStatementChildren(): ReadonlyArray { + return [this.expression]; + } +} diff --git a/pkg/sass-parser/lib/src/statement/warn-rule.test.ts b/pkg/sass-parser/lib/src/statement/warn-rule.test.ts new file mode 100644 index 000000000..0e51af5e7 --- /dev/null +++ b/pkg/sass-parser/lib/src/statement/warn-rule.test.ts @@ -0,0 +1,205 @@ +// Copyright 2024 Google Inc. Use of this source code is governed by an +// MIT-style license that can be found in the LICENSE file or at +// https://opensource.org/licenses/MIT. + +import {StringExpression, WarnRule, sass, scss} from '../..'; +import * as utils from '../../../test/utils'; + +describe('a @warn rule', () => { + let node: WarnRule; + function describeNode(description: string, create: () => WarnRule): void { + describe(description, () => { + beforeEach(() => void (node = create())); + + it('has a name', () => expect(node.name.toString()).toBe('warn')); + + it('has an expression', () => + expect(node).toHaveStringExpression('warnExpression', 'foo')); + + it('has matching params', () => expect(node.params).toBe('foo')); + + it('has undefined nodes', () => expect(node.nodes).toBeUndefined()); + }); + } + + describeNode( + 'parsed as SCSS', + () => scss.parse('@warn foo').nodes[0] as WarnRule + ); + + describeNode( + 'parsed as Sass', + () => sass.parse('@warn foo').nodes[0] as WarnRule + ); + + describeNode( + 'constructed manually', + () => + new WarnRule({ + warnExpression: {text: 'foo'}, + }) + ); + + describeNode('constructed from ChildProps', () => + utils.fromChildProps({ + warnExpression: {text: 'foo'}, + }) + ); + + it('throws an error when assigned a new name', () => + expect( + () => + (new WarnRule({ + warnExpression: {text: 'foo'}, + }).name = 'bar') + ).toThrow()); + + describe('assigned a new expression', () => { + beforeEach(() => { + node = scss.parse('@warn foo').nodes[0] as WarnRule; + }); + + it('sets an empty string expression as undefined params', () => { + node.params = undefined; + expect(node.params).toBe(''); + expect(node).toHaveStringExpression('warnExpression', ''); + }); + + it('sets an empty string expression as empty string params', () => { + node.params = ''; + expect(node.params).toBe(''); + expect(node).toHaveStringExpression('warnExpression', ''); + }); + + it("removes the old expression's parent", () => { + const oldExpression = node.warnExpression; + node.warnExpression = {text: 'bar'}; + expect(oldExpression.parent).toBeUndefined(); + }); + + it("assigns the new expression's parent", () => { + const expression = new StringExpression({text: 'bar'}); + node.warnExpression = expression; + expect(expression.parent).toBe(node); + }); + + it('assigns the expression explicitly', () => { + const expression = new StringExpression({text: 'bar'}); + node.warnExpression = expression; + expect(node.warnExpression).toBe(expression); + }); + + it('assigns the expression as ExpressionProps', () => { + node.warnExpression = {text: 'bar'}; + expect(node).toHaveStringExpression('warnExpression', 'bar'); + }); + + it('assigns the expression as params', () => { + node.params = 'bar'; + expect(node).toHaveStringExpression('warnExpression', 'bar'); + }); + }); + + describe('stringifies', () => { + describe('to SCSS', () => { + it('with default raws', () => + expect( + new WarnRule({ + warnExpression: {text: 'foo'}, + }).toString() + ).toBe('@warn foo;')); + + it('with afterName', () => + expect( + new WarnRule({ + warnExpression: {text: 'foo'}, + raws: {afterName: '/**/'}, + }).toString() + ).toBe('@warn/**/foo;')); + + it('with between', () => + expect( + new WarnRule({ + warnExpression: {text: 'foo'}, + raws: {between: '/**/'}, + }).toString() + ).toBe('@warn foo/**/;')); + }); + }); + + describe('clone', () => { + let original: WarnRule; + beforeEach(() => { + original = scss.parse('@warn foo').nodes[0] as WarnRule; + // TODO: remove this once raws are properly parsed + original.raws.between = ' '; + }); + + describe('with no overrides', () => { + let clone: WarnRule; + beforeEach(() => void (clone = original.clone())); + + describe('has the same properties:', () => { + it('params', () => expect(clone.params).toBe('foo')); + + it('warnExpression', () => + expect(clone).toHaveStringExpression('warnExpression', 'foo')); + + it('raws', () => expect(clone.raws).toEqual({between: ' '})); + + it('source', () => expect(clone.source).toBe(original.source)); + }); + + describe('creates a new', () => { + it('self', () => expect(clone).not.toBe(original)); + + for (const attr of ['warnExpression', 'raws'] as const) { + it(attr, () => expect(clone[attr]).not.toBe(original[attr])); + } + }); + }); + + describe('overrides', () => { + describe('raws', () => { + it('defined', () => + expect(original.clone({raws: {afterName: ' '}}).raws).toEqual({ + afterName: ' ', + })); + + it('undefined', () => + expect(original.clone({raws: undefined}).raws).toEqual({ + between: ' ', + })); + }); + + describe('warnExpression', () => { + describe('defined', () => { + let clone: WarnRule; + beforeEach(() => { + clone = original.clone({warnExpression: {text: 'bar'}}); + }); + + it('changes params', () => expect(clone.params).toBe('bar')); + + it('changes warnExpression', () => + expect(clone).toHaveStringExpression('warnExpression', 'bar')); + }); + + describe('undefined', () => { + let clone: WarnRule; + beforeEach(() => { + clone = original.clone({warnExpression: undefined}); + }); + + it('preserves params', () => expect(clone.params).toBe('foo')); + + it('preserves warnExpression', () => + expect(clone).toHaveStringExpression('warnExpression', 'foo')); + }); + }); + }); + }); + + it('toJSON', () => + expect(scss.parse('@warn foo').nodes[0]).toMatchSnapshot()); +}); diff --git a/pkg/sass-parser/lib/src/statement/warn-rule.ts b/pkg/sass-parser/lib/src/statement/warn-rule.ts new file mode 100644 index 000000000..d45eca18d --- /dev/null +++ b/pkg/sass-parser/lib/src/statement/warn-rule.ts @@ -0,0 +1,129 @@ +// Copyright 2024 Google Inc. Use of this source code is governed by an +// MIT-style license that can be found in the LICENSE file or at +// https://opensource.org/licenses/MIT. + +import * as postcss from 'postcss'; +import type {AtRuleRaws as PostcssAtRuleRaws} from 'postcss/lib/at-rule'; + +import {convertExpression} from '../expression/convert'; +import {Expression, ExpressionProps} from '../expression'; +import {fromProps} from '../expression/from-props'; +import {LazySource} from '../lazy-source'; +import type * as sassInternal from '../sass-internal'; +import * as utils from '../utils'; +import {Statement, StatementWithChildren} from '.'; +import {_AtRule} from './at-rule-internal'; +import {interceptIsClean} from './intercept-is-clean'; +import * as sassParser from '../..'; + +/** + * The set of raws supported by {@link WarnRule}. + * + * @category Statement + */ +export type WarnRuleRaws = Pick< + PostcssAtRuleRaws, + 'afterName' | 'before' | 'between' +>; + +/** + * The initializer properties for {@link WarnRule}. + * + * @category Statement + */ +export type WarnRuleProps = postcss.NodeProps & { + raws?: WarnRuleRaws; + warnExpression: Expression | ExpressionProps; +}; + +/** + * A `@warn` rule. Extends [`postcss.AtRule`]. + * + * [`postcss.AtRule`]: https://postcss.org/api/#atrule + * + * @category Statement + */ +export class WarnRule + extends _AtRule> + implements Statement +{ + readonly sassType = 'warn-rule' as const; + declare parent: StatementWithChildren | undefined; + declare raws: WarnRuleRaws; + declare readonly nodes: undefined; + + get name(): string { + return 'warn'; + } + set name(value: string) { + throw new Error("WarnRule.name can't be overwritten."); + } + + get params(): string { + return this.warnExpression.toString(); + } + set params(value: string | number | undefined) { + this.warnExpression = {text: value?.toString() ?? ''}; + } + + /** The expresison whose value is emitted when the warn rule is executed. */ + get warnExpression(): Expression { + return this._warnExpression!; + } + set warnExpression(warnExpression: Expression | ExpressionProps) { + if (this._warnExpression) this._warnExpression.parent = undefined; + if (!('sassType' in warnExpression)) { + warnExpression = fromProps(warnExpression); + } + if (warnExpression) warnExpression.parent = this; + this._warnExpression = warnExpression; + } + private _warnExpression?: Expression; + + constructor(defaults: WarnRuleProps); + /** @hidden */ + constructor(_: undefined, inner: sassInternal.WarnRule); + constructor(defaults?: WarnRuleProps, inner?: sassInternal.WarnRule) { + super(defaults as unknown as postcss.AtRuleProps); + + if (inner) { + this.source = new LazySource(inner); + this.warnExpression = convertExpression(inner.expression); + } + } + + clone(overrides?: Partial): this { + return utils.cloneNode( + this, + overrides, + ['raws', 'warnExpression'], + [{name: 'params', explicitUndefined: true}] + ); + } + + toJSON(): object; + /** @hidden */ + toJSON(_: string, inputs: Map): object; + toJSON(_?: string, inputs?: Map): object { + return utils.toJSON( + this, + ['name', 'warnExpression', 'params', 'nodes'], + inputs + ); + } + + /** @hidden */ + toString( + stringifier: postcss.Stringifier | postcss.Syntax = sassParser.scss + .stringify + ): string { + return super.toString(stringifier); + } + + /** @hidden */ + get nonStatementChildren(): ReadonlyArray { + return [this.warnExpression]; + } +} + +interceptIsClean(WarnRule); diff --git a/pkg/sass-parser/lib/src/stringifier.ts b/pkg/sass-parser/lib/src/stringifier.ts index 8f5a9aa5c..9ce52a86e 100644 --- a/pkg/sass-parser/lib/src/stringifier.ts +++ b/pkg/sass-parser/lib/src/stringifier.ts @@ -36,6 +36,7 @@ import {GenericAtRule} from './statement/generic-at-rule'; import {Rule} from './statement/rule'; import {SassComment} from './statement/sass-comment'; import {UseRule} from './statement/use-rule'; +import {WarnRule} from './statement/warn-rule'; const PostCssStringifier = require('postcss/lib/stringifier'); @@ -160,6 +161,10 @@ export class Stringifier extends PostCssStringifier { this.sassAtRule(node, semicolon); } + private ['warn-rule'](node: WarnRule, semicolon: boolean): void { + this.sassAtRule(node, semicolon); + } + /** Helper method for non-generic Sass at-rules. */ private sassAtRule(node: postcss.AtRule, semicolon?: boolean): void { const start = diff --git a/test/util/string_test.dart b/test/util/string_test.dart index db8607098..9aa6563de 100644 --- a/test/util/string_test.dart +++ b/test/util/string_test.dart @@ -77,7 +77,7 @@ void main() { group("at the very beginning that's", () { for (var (name, (char, escape)) in chars.pairs) { - test(name, () => expect(char.toCssIdentifier(), equals('$escape'))); + test(name, () => expect(char.toCssIdentifier(), equals(escape))); } }); diff --git a/tool/grind.dart b/tool/grind.dart index b5e735d3e..cf64cc52e 100644 --- a/tool/grind.dart +++ b/tool/grind.dart @@ -11,6 +11,7 @@ import 'package:grinder/grinder.dart'; import 'package:path/path.dart' as p; import 'package:source_span/source_span.dart'; +import 'grind/bump_version.dart'; import 'grind/generate_deprecations.dart'; import 'grind/synchronize.dart'; import 'grind/utils.dart'; @@ -125,6 +126,8 @@ void main(List args) { pkg.addAllTasks(); + addBumpVersionTasks(); + afterTask("pkg-npm-dev", _addDefaultExport); afterTask("pkg-npm-release", _addDefaultExport); diff --git a/tool/grind/bump_version.dart b/tool/grind/bump_version.dart new file mode 100644 index 000000000..d5fcd6491 --- /dev/null +++ b/tool/grind/bump_version.dart @@ -0,0 +1,117 @@ +// Copyright 2024 Google Inc. Use of this source code is governed by an +// MIT-style license that can be found in the LICENSE file or at +// https://opensource.org/licenses/MIT. + +import 'dart:io'; +import 'dart:convert'; + +import 'package:grinder/grinder.dart'; +import 'package:path/path.dart' as p; +import 'package:pub_semver/pub_semver.dart'; +import 'package:source_span/source_span.dart'; +import 'package:yaml/yaml.dart'; + +/// A regular expression that matches a version in a pubspec. +final _pubspecVersionRegExp = RegExp(r'^version: (.*)$', multiLine: true); + +/// A regular expression that matches a Sass dependency version in a pubspec. +final _sassVersionRegExp = RegExp(r'^( +)sass: (\d.*)$', multiLine: true); + +/// Adds grinder tasks for bumping package versions. +void addBumpVersionTasks() { + for (var patch in [false, true]) { + for (var dev in [true, false]) { + addTask(GrinderTask( + 'bump-version-${patch ? 'patch' : 'minor'}' + (dev ? '-dev' : ''), + taskFunction: () => _bumpVersion(patch, dev), + description: 'Bump the version of all packages to the next ' + '${patch ? 'patch' : 'minor'}${dev ? ' dev' : ''} version')); + } + } +} + +/// Bumps the current package versions to the next [patch] version, with `-dev` +/// if [dev] is true. +void _bumpVersion(bool patch, bool dev) { + // Returns the version to which to bump [version]. + Version chooseNextVersion(Version version, SourceSpan span) { + if (dev) { + if (patch + ? version.preRelease.isNotEmpty + : version.patch == 0 || + version.preRelease.length != 1 || + version.preRelease.first != "dev") { + fail(span.message("Version is already pre-release", color: true)); + } + } else if (version.preRelease.length == 1 && + version.preRelease.first == "dev" && + (patch || version.patch == 0)) { + // If it's already a dev version, just mark it stable instead of + // increasing it. + return Version(version.major, version.minor, version.patch); + } + + var nextVersion = + patch || version.major == 0 ? version.nextPatch : version.nextMinor; + return Version(nextVersion.major, nextVersion.minor, nextVersion.patch, + pre: dev ? "dev" : null); + } + + /// Adds a "No user-visible changes" entry for [version] to the changelog in + /// [dir]. + void addChangelogEntry(String dir, Version version) { + var path = p.join(dir, "CHANGELOG.md"); + var text = File(path).readAsStringSync(); + if (!dev && text.startsWith("## $version-dev\n")) { + File(path).writeAsStringSync( + text.replaceFirst("## $version-dev\n", "## $version\n")); + } else if (text.startsWith("## $version\n")) { + return; + } else { + File(path).writeAsStringSync( + "## $version\n\n* No user-visible changes.\n\n$text"); + } + } + + // Bumps the current version of [pubspec] to the next [patch] version, with + // `-dev` if [dev] is true. + // + // If [sassVersion] is passed, this bumps the `sass` dependency to that version. + // + // Returns the new version of this package. + Version bumpDartVersion(String path, [Version? sassVersion]) { + var text = File(path).readAsStringSync(); + var pubspec = loadYaml(text, sourceUrl: p.toUri(path)) as YamlMap; + var version = chooseNextVersion(Version.parse(pubspec["version"] as String), + pubspec.nodes["version"]!.span); + + text = text.replaceFirst(_pubspecVersionRegExp, 'version: $version'); + if (sassVersion != null) { + // Don't depend on a prerelease version, depend on its released + // equivalent. + var sassDependencyVersion = + Version(sassVersion.major, sassVersion.minor, sassVersion.patch); + text = text.replaceFirstMapped(_sassVersionRegExp, + (match) => '${match[1]}sass: $sassDependencyVersion'); + } + + File(path).writeAsStringSync(text); + addChangelogEntry(p.dirname(path), version); + return version; + } + + var sassVersion = bumpDartVersion('pubspec.yaml'); + bumpDartVersion('pkg/sass_api/pubspec.yaml', sassVersion); + + var packageJsonPath = 'pkg/sass-parser/package.json'; + var packageJsonText = File(packageJsonPath).readAsStringSync(); + var packageJson = + loadYaml(packageJsonText, sourceUrl: p.toUri(packageJsonPath)) as YamlMap; + var version = chooseNextVersion( + Version.parse(packageJson["version"] as String), + packageJson.nodes["version"]!.span); + File(packageJsonPath).writeAsStringSync(JsonEncoder.withIndent(" ") + .convert({...packageJson, "version": version.toString()}) + + "\n"); + addChangelogEntry("pkg/sass-parser", version); +}