diff --git a/lib/src/js/parser.dart b/lib/src/js/parser.dart index 82bc12b0c..5e73a106a 100644 --- a/lib/src/js/parser.dart +++ b/lib/src/js/parser.dart @@ -10,9 +10,12 @@ import 'package:path/path.dart' as p; import 'package:source_span/source_span.dart'; import '../ast/sass.dart'; +import '../exception.dart'; +import '../parse/parser.dart'; import '../syntax.dart'; import '../util/nullable.dart'; import '../util/span.dart'; +import '../util/string.dart'; import '../visitor/interface/expression.dart'; import '../visitor/interface/statement.dart'; import 'reflection.dart'; @@ -24,10 +27,14 @@ import 'visitor/statement.dart'; class ParserExports { external factory ParserExports( {required Function parse, + required Function parseIdentifier, + required Function toCssIdentifier, required Function createExpressionVisitor, required Function createStatementVisitor}); external set parse(Function function); + external set parseIdentifier(Function function); + external set toCssIdentifier(Function function); external set createStatementVisitor(Function function); external set createExpressionVisitor(Function function); } @@ -45,6 +52,8 @@ ParserExports loadParserExports() { _updateAstPrototypes(); return ParserExports( parse: allowInterop(_parse), + parseIdentifier: allowInterop(_parseIdentifier), + toCssIdentifier: allowInterop(_toCssIdentifier), createExpressionVisitor: allowInterop( (JSExpressionVisitorObject inner) => JSExpressionVisitor(inner)), createStatementVisitor: allowInterop( @@ -117,3 +126,18 @@ Stylesheet _parse(String css, String syntax, String? path) => Stylesheet.parse( _ => throw UnsupportedError('Unknown syntax "$syntax"') }, url: path.andThen(p.toUri)); + +/// A JavaScript-friendly method to parse an identifier to its semantic value. +/// +/// Returns null if [identifier] isn't a valid identifier. +String? _parseIdentifier(String identifier) { + try { + return Parser.parseIdentifier(identifier); + } on SassFormatException { + return null; + } +} + +/// A JavaScript-friendly method to convert text to a valid CSS identifier with +/// the same contents. +String _toCssIdentifier(String text) => text.toCssIdentifier(); diff --git a/lib/src/util/character.dart b/lib/src/util/character.dart index 7141be67a..614fec45a 100644 --- a/lib/src/util/character.dart +++ b/lib/src/util/character.dart @@ -10,6 +10,11 @@ import 'package:charcode/charcode.dart'; /// lowercase equivalents. const _asciiCaseBit = 0x20; +/// The highest character allowed in CSS. +/// +/// See https://drafts.csswg.org/css-syntax-3/#maximum-allowed-code-point +const maxAllowedCharacter = 0x10FFFF; + // Define these checks as extension getters so they can be used in pattern // matches. extension CharacterExtension on int { @@ -35,6 +40,12 @@ extension CharacterExtension on int { // 0x36 == 0b110110. this >> 10 == 0x36; + /// Returns whether [character] is the end of a UTF-16 surrogate pair. + bool get isLowSurrogate => + // A character is a high surrogate exactly if it matches 0b110111XXXXXXXXXX. + // 0x36 == 0b110111. + this >> 10 == 0x37; + /// Returns whether [character] is a Unicode private-use code point in the Basic /// Multilingual Plane. /// diff --git a/lib/src/util/string.dart b/lib/src/util/string.dart new file mode 100644 index 000000000..949b9092c --- /dev/null +++ b/lib/src/util/string.dart @@ -0,0 +1,108 @@ +// 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 'package:charcode/charcode.dart'; +import 'package:string_scanner/string_scanner.dart'; + +import 'character.dart'; + +extension StringExtension on String { + /// Returns a minimally-escaped CSS identifiers whose contents evaluates to + /// [text]. + /// + /// Throws a [FormatException] if [text] cannot be represented as a CSS + /// identifier (such as the empty string). + String toCssIdentifier() { + var buffer = StringBuffer(); + var scanner = SpanScanner(this); + + void writeEscape(int character) { + buffer.writeCharCode($backslash); + buffer.write(character.toRadixString(16)); + if (scanner.peekChar() case int(isHex: true)) { + buffer.writeCharCode($space); + } + } + + void consumeSurrogatePair(int character) { + if (scanner.peekChar(1) case null || int(isLowSurrogate: false)) { + scanner.error( + "An individual surrogates can't be represented as a CSS " + "identifier.", + length: 1); + } else if (character.isPrivateUseHighSurrogate) { + writeEscape(combineSurrogates(scanner.readChar(), scanner.readChar())); + } else { + buffer.writeCharCode(scanner.readChar()); + buffer.writeCharCode(scanner.readChar()); + } + } + + var doubleDash = false; + if (scanner.scanChar($dash)) { + if (scanner.isDone) return '\\2d'; + + buffer.writeCharCode($dash); + + if (scanner.scanChar($dash)) { + buffer.writeCharCode($dash); + doubleDash = true; + } + } + + if (!doubleDash) { + switch (scanner.peekChar()) { + case null: + scanner.error( + "The empty string can't be represented as a CSS identifier."); + + case 0: + scanner.error("The U+0000 can't be represented as a CSS identifier."); + + case int character when character.isHighSurrogate: + consumeSurrogatePair(character); + + case int(isLowSurrogate: true): + scanner.error( + "An individual surrogate can't be represented as a CSS " + "identifier.", + length: 1); + + case int(isNameStart: true, isPrivateUseBMP: false): + buffer.writeCharCode(scanner.readChar()); + + case _: + writeEscape(scanner.readChar()); + } + } + + loop: + while (true) { + switch (scanner.peekChar()) { + case null: + break loop; + + case 0: + scanner.error("The U+0000 can't be represented as a CSS identifier."); + + case int character when character.isHighSurrogate: + consumeSurrogatePair(character); + + case int(isLowSurrogate: true): + scanner.error( + "An individual surrogate can't be represented as a CSS " + "identifier.", + length: 1); + + case int(isName: true, isPrivateUseBMP: false): + buffer.writeCharCode(scanner.readChar()); + + case _: + writeEscape(scanner.readChar()); + } + } + + return buffer.toString(); + } +} diff --git a/lib/src/utils.dart b/lib/src/utils.dart index 51e88a839..2e04afd16 100644 --- a/lib/src/utils.dart +++ b/lib/src/utils.dart @@ -409,7 +409,7 @@ int consumeEscapedCharacter(StringScanner scanner) { if (scanner.peekChar().isWhitespace) scanner.readChar(); return switch (value) { - 0 || (>= 0xD800 && <= 0xDFFF) || >= 0x10FFFF => 0xFFFD, + 0 || (>= 0xD800 && <= 0xDFFF) || >= maxAllowedCharacter => 0xFFFD, _ => value }; case _: diff --git a/pkg/sass-parser/CHANGELOG.md b/pkg/sass-parser/CHANGELOG.md index eae5707f7..3c61db629 100644 --- a/pkg/sass-parser/CHANGELOG.md +++ b/pkg/sass-parser/CHANGELOG.md @@ -2,6 +2,8 @@ * Add `BooleanExpression` and `NumberExpression`. +* Add support for parsing the `@use` rule. + ## 0.4.0 * **Breaking change:** Warnings are no longer emitted during parsing, so the diff --git a/pkg/sass-parser/lib/index.ts b/pkg/sass-parser/lib/index.ts index 11f9e3bd1..6b878c390 100644 --- a/pkg/sass-parser/lib/index.ts +++ b/pkg/sass-parser/lib/index.ts @@ -8,7 +8,20 @@ import {Root} from './src/statement/root'; import * as sassInternal from './src/sass-internal'; import {Stringifier} from './src/stringifier'; +export { + Configuration, + ConfigurationProps, + ConfigurationRaws, +} from './src/configuration'; +export { + ConfiguredVariable, + ConfiguredVariableObjectProps, + ConfiguredVariableExpressionProps, + ConfiguredVariableProps, + ConfiguredVariableRaws, +} from './src/configured-variable'; export {AnyNode, Node, NodeProps, NodeType} from './src/node'; +export {RawWithValue} from './src/raw-with-value'; export { AnyExpression, Expression, @@ -71,6 +84,7 @@ export { SassCommentProps, SassCommentRaws, } from './src/statement/sass-comment'; +export {UseRule, UseRuleProps, UseRuleRaws} from './src/statement/use-rule'; export { AnyStatement, AtRule, diff --git a/pkg/sass-parser/lib/src/__snapshots__/configured-variable.test.ts.snap b/pkg/sass-parser/lib/src/__snapshots__/configured-variable.test.ts.snap new file mode 100644 index 000000000..2b5609937 --- /dev/null +++ b/pkg/sass-parser/lib/src/__snapshots__/configured-variable.test.ts.snap @@ -0,0 +1,19 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`a configured variable toJSON 1`] = ` +{ + "expression": <"qux">, + "guarded": false, + "inputs": [ + { + "css": "@use "foo" with ($baz: "qux")", + "hasBOM": false, + "id": "", + }, + ], + "raws": {}, + "sassType": "configured-variable", + "source": <1:18-1:29 in 0>, + "variableName": "baz", +} +`; diff --git a/pkg/sass-parser/lib/src/configuration.test.ts b/pkg/sass-parser/lib/src/configuration.test.ts new file mode 100644 index 000000000..b260efef3 --- /dev/null +++ b/pkg/sass-parser/lib/src/configuration.test.ts @@ -0,0 +1,427 @@ +// 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 { + Configuration, + ConfiguredVariable, + StringExpression, + UseRule, + sass, + scss, +} from '..'; + +describe('a configuration map', () => { + let node: Configuration; + beforeEach(() => (node = new Configuration())); + + describe('empty', () => { + function describeNode( + description: string, + create: () => Configuration + ): void { + describe(description, () => { + beforeEach(() => (node = create())); + + it('has a sassType', () => + expect(node.sassType.toString()).toBe('configuration')); + + it('has no contents', () => { + expect(node.size).toBe(0); + expect([...node.variables()]).toEqual([]); + }); + }); + } + + describeNode( + 'parsed as SCSS', + () => (scss.parse('@use "foo"').nodes[0] as UseRule).configuration + ); + + describeNode( + 'parsed as Sass', + () => (sass.parse('@use "foo"').nodes[0] as UseRule).configuration + ); + + describe('constructed manually', () => { + describeNode('no args', () => new Configuration()); + + describeNode('variables array', () => new Configuration({variables: []})); + + describeNode( + 'variables record', + () => new Configuration({variables: {}}) + ); + }); + + describeNode( + 'constructed from props', + () => + new UseRule({useUrl: 'foo', configuration: {variables: []}}) + .configuration + ); + }); + + describe('with a variable', () => { + function describeNode( + description: string, + create: () => Configuration + ): void { + describe(description, () => { + beforeEach(() => (node = create())); + + it('has a sassType', () => + expect(node.sassType.toString()).toBe('configuration')); + + it('contains the variable', () => { + expect(node.size).toBe(1); + const variable = [...node.variables()][0]; + expect(variable.variableName).toEqual('bar'); + expect(variable).toHaveStringExpression('expression', 'baz'); + }); + }); + } + + describeNode( + 'parsed as SCSS', + () => + (scss.parse('@use "foo" with ($bar: "baz")').nodes[0] as UseRule) + .configuration + ); + + describeNode( + 'parsed as Sass', + () => + (sass.parse('@use "foo" with ($bar: "baz")').nodes[0] as UseRule) + .configuration + ); + + describe('constructed manually', () => { + describeNode( + 'variables array', + () => + new Configuration({ + variables: [ + {variableName: 'bar', expression: {text: 'baz', quotes: true}}, + ], + }) + ); + + describeNode( + 'variables record', + () => new Configuration({variables: {bar: {text: 'baz', quotes: true}}}) + ); + }); + + describeNode( + 'constructed from props', + () => + new UseRule({ + useUrl: 'foo', + configuration: {variables: {bar: {text: 'baz', quotes: true}}}, + }).configuration + ); + }); + + describe('add()', () => { + test('with a ConfiguredVariable', () => { + const variable = new ConfiguredVariable({ + variableName: 'foo', + expression: {text: 'bar', quotes: true}, + }); + expect(node.add(variable)).toBe(node); + expect(node.size).toBe(1); + expect([...node.variables()][0]).toBe(variable); + expect(variable.parent).toBe(node); + }); + + test('with a ConfiguredVariableProps', () => { + node.add({variableName: 'foo', expression: {text: 'bar', quotes: true}}); + expect(node.size).toBe(1); + const variable = node.get('foo'); + expect(variable?.variableName).toBe('foo'); + expect(variable).toHaveStringExpression('expression', 'bar'); + expect(variable?.parent).toBe(node); + }); + + test('overwrites on old variable', () => { + node.add({variableName: 'foo', expression: {text: 'old', quotes: true}}); + const old = node.get('foo'); + expect(old?.parent).toBe(node); + + node.add({variableName: 'foo', expression: {text: 'new', quotes: true}}); + expect(node.size).toBe(1); + expect(old?.parent).toBeUndefined(); + expect(node.get('foo')).toHaveStringExpression('expression', 'new'); + }); + }); + + test('clear() removes all variables', () => { + node.add({variableName: 'foo', expression: {text: 'bar', quotes: true}}); + node.add({variableName: 'baz', expression: {text: 'bang', quotes: true}}); + const foo = node.get('foo'); + const bar = node.get('bar'); + node.clear(); + + expect(node.size).toBe(0); + expect([...node.variables()]).toEqual([]); + expect(foo?.parent).toBeUndefined(); + expect(bar?.parent).toBeUndefined(); + }); + + describe('delete()', () => { + beforeEach(() => { + node.add({variableName: 'foo', expression: {text: 'bar', quotes: true}}); + node.add({variableName: 'baz', expression: {text: 'bang', quotes: true}}); + }); + + test('removes a matching variable', () => { + const foo = node.get('foo'); + expect(node.delete('foo')).toBe(true); + expect(foo?.parent).toBeUndefined(); + expect(node.size).toBe(1); + expect(node.get('foo')).toBeUndefined(); + }); + + test("doesn't remove a non-matching variable", () => { + expect(node.delete('bang')).toBe(false); + expect(node.size).toBe(2); + }); + }); + + describe('get()', () => { + beforeEach(() => { + node.add({variableName: 'foo', expression: {text: 'bar', quotes: true}}); + }); + + test('returns a variable in the configuration', () => { + const variable = node.get('foo'); + expect(variable?.variableName).toBe('foo'); + expect(variable).toHaveStringExpression('expression', 'bar'); + }); + + test('returns undefined for a variable not in the configuration', () => { + expect(node.get('bar')).toBeUndefined(); + }); + }); + + describe('has()', () => { + beforeEach(() => { + node.add({variableName: 'foo', expression: {text: 'bar', quotes: true}}); + }); + + test('returns true for a variable in the configuration', () => + expect(node.has('foo')).toBe(true)); + + test('returns false for a variable not in the configuration', () => + expect(node.has('bar')).toBe(false)); + }); + + describe('set()', () => { + beforeEach(() => { + node.add({variableName: 'foo', expression: {text: 'bar', quotes: true}}); + }); + + describe('adds a new variable', () => { + function describeVariable( + description: string, + create: () => Configuration + ): void { + it(description, () => { + expect(create()).toBe(node); + expect(node.size).toBe(2); + const variable = node.get('baz'); + expect(variable?.parent).toBe(node); + expect(variable?.variableName).toBe('baz'); + expect(variable).toHaveStringExpression('expression', 'bang'); + }); + } + + describeVariable('with Expression', () => + node.set('baz', new StringExpression({text: 'bang', quotes: true})) + ); + + describeVariable('with ExpressionProps', () => + node.set('baz', {text: 'bang', quotes: true}) + ); + + describeVariable('with ConfiguredVariableObjectProps', () => + node.set('baz', {expression: {text: 'bang', quotes: true}}) + ); + }); + + test('overwrites an existing variable', () => { + const foo = node.get('foo'); + node.set('foo', {text: 'bang', quotes: true}); + expect(foo?.parent).toBeUndefined(); + }); + }); + + describe('stringifies', () => { + describe('to SCSS', () => { + describe('with default raws', () => { + it('empty', () => expect(new Configuration().toString()).toBe('()')); + + it('with variables', () => + expect( + new Configuration({ + variables: { + foo: {text: 'bar', quotes: true}, + baz: {text: 'bang', quotes: true}, + }, + }).toString() + ).toBe('($foo: "bar", $baz: "bang")')); + }); + + it('with comma: true', () => + expect( + new Configuration({ + raws: {comma: true}, + variables: { + foo: {text: 'bar', quotes: true}, + baz: {text: 'bang', quotes: true}, + }, + }).toString() + ).toBe('($foo: "bar", $baz: "bang",)')); + + it('with comma: true and afterValue', () => + expect( + new Configuration({ + raws: {comma: true}, + variables: { + foo: {text: 'bar', quotes: true}, + baz: { + expression: {text: 'bang', quotes: true}, + raws: {afterValue: '/**/'}, + }, + }, + }).toString() + ).toBe('($foo: "bar", $baz: "bang"/**/,)')); + + it('with after', () => + expect( + new Configuration({ + raws: {after: '/**/'}, + variables: { + foo: {text: 'bar', quotes: true}, + baz: {text: 'bang', quotes: true}, + }, + }).toString() + ).toBe('($foo: "bar", $baz: "bang"/**/)')); + + it('with after and afterValue', () => + expect( + new Configuration({ + raws: {after: '/**/'}, + variables: { + foo: {text: 'bar', quotes: true}, + baz: { + expression: {text: 'bang', quotes: true}, + raws: {afterValue: ' '}, + }, + }, + }).toString() + ).toBe('($foo: "bar", $baz: "bang" /**/)')); + + it('with afterValue and a guard', () => + expect( + new Configuration({ + variables: { + foo: {text: 'bar', quotes: true}, + baz: { + expression: {text: 'bang', quotes: true}, + raws: {afterValue: '/**/'}, + guarded: true, + }, + }, + }).toString() + ).toBe('($foo: "bar", $baz: "bang" !default/**/)')); + }); + }); + + describe('clone', () => { + let original: Configuration; + beforeEach(() => { + original = ( + scss.parse('@use "foo" with ($foo: "bar", $baz: "bang")') + .nodes[0] as UseRule + ).configuration; + // TODO: remove this once raws are properly parsed + original.raws.after = ' '; + }); + + describe('with no overrides', () => { + let clone: Configuration; + beforeEach(() => void (clone = original.clone())); + + describe('has the same properties:', () => { + it('variables', () => { + expect(clone.size).toBe(2); + const variables = [...clone.variables()]; + expect(variables[0]?.variableName).toBe('foo'); + expect(variables[0]?.parent).toBe(clone); + expect(variables[0]).toHaveStringExpression('expression', 'bar'); + expect(variables[1]?.variableName).toBe('baz'); + expect(variables[1]?.parent).toBe(clone); + expect(variables[1]).toHaveStringExpression('expression', 'bang'); + }); + + it('raws', () => expect(clone.raws.after).toBe(' ')); + }); + + describe('creates a new', () => { + it('self', () => expect(clone).not.toBe(original)); + + for (const attr of ['raws'] as const) { + it(attr, () => expect(clone[attr]).not.toBe(original[attr])); + } + }); + }); + + describe('overrides', () => { + describe('raws', () => { + it('defined', () => + expect(original.clone({raws: {comma: true}}).raws).toEqual({ + comma: true, + })); + + it('undefined', () => + expect(original.clone({raws: undefined}).raws).toEqual({ + after: ' ', + })); + }); + + describe('variables', () => { + it('defined', () => { + const clone = original.clone({ + variables: {zip: {text: 'zap', quotes: true}}, + }); + expect(clone.size).toBe(1); + const variables = [...clone.variables()]; + expect(variables[0]?.variableName).toBe('zip'); + expect(variables[0]?.parent).toBe(clone); + expect(variables[0]).toHaveStringExpression('expression', 'zap'); + }); + + it('undefined', () => { + const clone = original.clone({variables: undefined}); + expect(clone.size).toBe(2); + const variables = [...clone.variables()]; + expect(variables[0]?.variableName).toBe('foo'); + expect(variables[0]?.parent).toBe(clone); + expect(variables[0]).toHaveStringExpression('expression', 'bar'); + expect(variables[1]?.variableName).toBe('baz'); + expect(variables[1]?.parent).toBe(clone); + expect(variables[1]).toHaveStringExpression('expression', 'bang'); + }); + }); + }); + }); + + // Can't JSON-serialize this until we implement Configuration.source.span + it.skip('toJSON', () => + expect( + (scss.parse('@use "foo" with ($baz: "qux")').nodes[0] as UseRule) + .configuration + ).toMatchSnapshot()); +}); diff --git a/pkg/sass-parser/lib/src/configuration.ts b/pkg/sass-parser/lib/src/configuration.ts new file mode 100644 index 000000000..ae788e77f --- /dev/null +++ b/pkg/sass-parser/lib/src/configuration.ts @@ -0,0 +1,200 @@ +// 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 { + ConfiguredVariable, + ConfiguredVariableExpressionProps, + ConfiguredVariableProps, +} from './configured-variable'; +import {LazySource} from './lazy-source'; +import {Node} from './node'; +import type * as sassInternal from './sass-internal'; +import * as utils from './utils'; +import {UseRule} from './statement/use-rule'; + +/** + * The set of raws supported by {@link Configuration}. + * + * @category Statement + */ +export interface ConfigurationRaws { + /** Whether the final variable has a trailing comma. */ + comma?: boolean; + + /** + * The whitespace between the final variable (or its trailing comma if it has + * one) and the closing parenthesis. + */ + after?: string; +} + +/** + * The initializer properties for {@link Configuration}. + * + * @category Statement + */ +export interface ConfigurationProps { + raws?: ConfigurationRaws; + variables: + | Record + | Array; +} + +/** + * A configuration map for a `@use` or `@forward` rule. + * + * @category Statement + */ +export class Configuration extends Node { + readonly sassType = 'configuration' as const; + declare raws: ConfigurationRaws; + declare parent: UseRule | undefined; // TODO: forward as well + + /** The underlying map from variable names to their values. */ + private _variables: Map = new Map(); + + /** The number of variables in this configuration. */ + get size(): number { + return this._variables.size; + } + + constructor(defaults?: ConfigurationProps); + /** @hidden */ + constructor(_: undefined, inner: sassInternal.ConfiguredVariable[]); + constructor( + defaults?: ConfigurationProps, + inner?: sassInternal.ConfiguredVariable[] + ) { + super({}); + this.raws = defaults?.raws ?? {}; + + if (defaults) { + for (const variable of Array.isArray(defaults.variables) + ? defaults.variables + : Object.entries(defaults.variables)) { + this.add(variable); + } + } else if (inner) { + this.source = new LazySource({ + get span(): sassInternal.FileSpan { + // TODO: expand inner[0] and inner.at(-1) out through `(` and `)` + // respectively and then combine them. + throw new Error('currently unsupported'); + }, + }); + for (const variable of inner) { + this.add(new ConfiguredVariable(undefined, variable)); + } + } + } + + /** + * Adds {@link variable} to this configuration. + * + * If there's already a variable with that name, it's removed first. + */ + add(variable: ConfiguredVariable | ConfiguredVariableProps): this { + const realVariable = + 'sassType' in variable ? variable : new ConfiguredVariable(variable); + realVariable.parent = this; + const old = this._variables.get(realVariable.variableName); + if (old) old.parent = undefined; + this._variables.set(realVariable.variableName, realVariable); + return this; + } + + /** Removes all variables from this configuration. */ + clear(): void { + for (const variable of this._variables.values()) { + variable.parent = undefined; + } + this._variables.clear(); + } + + /** + * Removes the variable named {@link name} from this configuration. + * + * Returns whether thee variable was removed. + */ + delete(key: string): boolean { + const old = this._variables.get(key); + if (old) old.parent = undefined; + return this._variables.delete(key); + } + + /** + * Returns the variable named {@link name} from this configuration if it + * contains one. + */ + get(key: string): ConfiguredVariable | undefined { + return this._variables.get(key); + } + + /** + * Returns whether this configuration has a variable named {@link name}. + */ + has(key: string): boolean { + return this._variables.has(key); + } + + /** + * Sets the variable named {@link key}. This fully overrides the previous + * value, so all previous raws and guarded state are discarded. + */ + set(key: string, expression: ConfiguredVariableExpressionProps): this { + const variable = new ConfiguredVariable([key, expression]); + variable.parent = this; + const old = this._variables.get(key); + if (old) old.parent = undefined; + this._variables.set(key, variable); + return this; + } + + /** Returns all the variables in this configuration. */ + variables(): IterableIterator { + return this._variables.values(); + } + + clone(overrides?: Partial): Configuration { + // We can't use `utils.cloneNode` here because variables isn't a public + // field. Fortunately this class doesn't have any settable derived fields to + // make cloning more complicated. + return new Configuration({ + raws: overrides?.raws ?? structuredClone(this.raws), + variables: overrides?.variables ?? [...this._variables.values()], + }); + } + + toJSON(): object; + /** @hidden */ + toJSON(_: string, inputs: Map): object; + toJSON(_?: string, inputs?: Map): object { + return utils.toJSON(this, ['variables'], inputs); + } + + /** @hidden */ + toString(): string { + let result = '('; + let first = true; + for (const variable of this._variables.values()) { + if (first) { + result += variable.raws.before ?? ''; + first = false; + } else { + result += ','; + result += variable.raws.before ?? ' '; + } + result += variable.toString(); + result += variable.raws.afterValue ?? ''; + } + return result + `${this.raws.comma ? ',' : ''}${this.raws.after ?? ''})`; + } + + /** @hidden */ + get nonStatementChildren(): ReadonlyArray { + return [...this.variables()]; + } +} diff --git a/pkg/sass-parser/lib/src/configured-variable.test.ts b/pkg/sass-parser/lib/src/configured-variable.test.ts new file mode 100644 index 000000000..33a0de857 --- /dev/null +++ b/pkg/sass-parser/lib/src/configured-variable.test.ts @@ -0,0 +1,409 @@ +// 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 {ConfiguredVariable, StringExpression, UseRule, sass, scss} from '..'; + +describe('a configured variable', () => { + let node: ConfiguredVariable; + beforeEach( + () => + void (node = new ConfiguredVariable({ + variableName: 'foo', + expression: {text: 'bar', quotes: true}, + })) + ); + + describe('unguarded', () => { + function describeNode( + description: string, + create: () => ConfiguredVariable + ): void { + describe(description, () => { + beforeEach(() => (node = create())); + + it('has a sassType', () => + expect(node.sassType.toString()).toBe('configured-variable')); + + it('has a name', () => expect(node.variableName).toBe('foo')); + + it('has a value', () => + expect(node).toHaveStringExpression('expression', 'bar')); + + it("isn't guarded", () => expect(node.guarded).toBe(false)); + }); + } + + describeNode( + 'parsed as SCSS', + () => + ( + scss.parse('@use "baz" with ($foo: "bar")').nodes[0] as UseRule + ).configuration.get('foo')! + ); + + describeNode( + 'parsed as Sass', + () => + ( + sass.parse('@use "baz" with ($foo: "bar")').nodes[0] as UseRule + ).configuration.get('foo')! + ); + + describe('constructed manually', () => { + describe('with an array', () => { + describeNode( + 'with an Expression', + () => + new ConfiguredVariable([ + 'foo', + new StringExpression({text: 'bar', quotes: true}), + ]) + ); + + describeNode( + 'with ExpressionProps', + () => new ConfiguredVariable(['foo', {text: 'bar', quotes: true}]) + ); + + describe('with an object', () => { + describeNode( + 'with an expression', + () => + new ConfiguredVariable([ + 'foo', + {expression: new StringExpression({text: 'bar', quotes: true})}, + ]) + ); + + describeNode( + 'with ExpressionProps', + () => + new ConfiguredVariable([ + 'foo', + {expression: {text: 'bar', quotes: true}}, + ]) + ); + }); + }); + + describe('with an object', () => { + describeNode( + 'with an expression', + () => + new ConfiguredVariable({ + variableName: 'foo', + expression: new StringExpression({text: 'bar', quotes: true}), + }) + ); + + describeNode( + 'with ExpressionProps', + () => + new ConfiguredVariable({ + variableName: 'foo', + expression: {text: 'bar', quotes: true}, + }) + ); + }); + }); + }); + + describe('guarded', () => { + function describeNode( + description: string, + create: () => ConfiguredVariable + ): void { + describe(description, () => { + beforeEach(() => (node = create())); + + it('has a sassType', () => + expect(node.sassType.toString()).toBe('configured-variable')); + + it('has a name', () => expect(node.variableName).toBe('foo')); + + it('has a value', () => + expect(node).toHaveStringExpression('expression', 'bar')); + + it('is guarded', () => expect(node.guarded).toBe(true)); + }); + } + + // We can re-enable these once ForwardRule exists. + // describeNode( + // 'parsed as SCSS', + // () => + // ( + // scss.parse('@forward "baz" with ($foo: "bar" !default)') + // .nodes[0] as ForwardRule + // ).configuration.get('foo')! + // ); + // + // describeNode( + // 'parsed as Sass', + // () => + // ( + // sass.parse('@forward "baz" with ($foo: "bar" !default)') + // .nodes[0] as ForwardRule + // ).configuration.get('foo')! + // ); + + describe('constructed manually', () => { + describe('with an array', () => { + describeNode( + 'with an expression', + () => + new ConfiguredVariable([ + 'foo', + { + expression: new StringExpression({text: 'bar', quotes: true}), + guarded: true, + }, + ]) + ); + + describeNode( + 'with ExpressionProps', + () => + new ConfiguredVariable([ + 'foo', + {expression: {text: 'bar', quotes: true}, guarded: true}, + ]) + ); + }); + + describe('with an object', () => { + describeNode( + 'with an expression', + () => + new ConfiguredVariable({ + variableName: 'foo', + expression: new StringExpression({text: 'bar', quotes: true}), + guarded: true, + }) + ); + + describeNode( + 'with ExpressionProps', + () => + new ConfiguredVariable({ + variableName: 'foo', + expression: {text: 'bar', quotes: true}, + guarded: true, + }) + ); + }); + }); + }); + + it('assigned a new variableName', () => { + node.variableName = 'baz'; + expect(node.variableName).toBe('baz'); + }); + + it('assigned a new expression', () => { + const old = node.expression; + node.expression = {text: 'baz', quotes: true}; + expect(old.parent).toBeUndefined(); + expect(node).toHaveStringExpression('expression', 'baz'); + }); + + it('assigned a new guarded', () => { + node.guarded = true; + expect(node.guarded).toBe(true); + }); + + describe('stringifies', () => { + describe('to SCSS', () => { + describe('with default raws', () => { + it('unguarded', () => + expect( + new ConfiguredVariable({ + variableName: 'foo', + expression: {text: 'bar', quotes: true}, + }).toString() + ).toBe('$foo: "bar"')); + + it('guarded', () => + expect( + new ConfiguredVariable({ + variableName: 'foo', + expression: {text: 'bar', quotes: true}, + guarded: true, + }).toString() + ).toBe('$foo: "bar" !default')); + + it('with a non-identifier name', () => + expect( + new ConfiguredVariable({ + variableName: 'f o', + expression: {text: 'bar', quotes: true}, + }).toString() + ).toBe('$f\\20o: "bar"')); + }); + + // raws.before is only used as part of a Configuration + it('ignores before', () => + expect( + new ConfiguredVariable({ + variableName: 'foo', + expression: {text: 'bar', quotes: true}, + raws: {before: '/**/'}, + }).toString() + ).toBe('$foo: "bar"')); + + it('with matching name', () => + expect( + new ConfiguredVariable({ + variableName: 'foo', + expression: {text: 'bar', quotes: true}, + raws: {variableName: {raw: 'f\\6fo', value: 'foo'}}, + }).toString() + ).toBe('$f\\6fo: "bar"')); + + it('with non-matching name', () => + expect( + new ConfiguredVariable({ + variableName: 'foo', + expression: {text: 'bar', quotes: true}, + raws: {variableName: {raw: 'f\\41o', value: 'fao'}}, + }).toString() + ).toBe('$foo: "bar"')); + + it('with between', () => + expect( + new ConfiguredVariable({ + variableName: 'foo', + expression: {text: 'bar', quotes: true}, + raws: {between: ' : '}, + }).toString() + ).toBe('$foo : "bar"')); + + it('with beforeGuard and a guard', () => + expect( + new ConfiguredVariable({ + variableName: 'foo', + expression: {text: 'bar', quotes: true}, + guarded: true, + raws: {beforeGuard: '/**/'}, + }).toString() + ).toBe('$foo: "bar"/**/!default')); + + it('with beforeGuard and no guard', () => + expect( + new ConfiguredVariable({ + variableName: 'foo', + expression: {text: 'bar', quotes: true}, + raws: {beforeGuard: '/**/'}, + }).toString() + ).toBe('$foo: "bar"')); + + // raws.before is only used as part of a Configuration + describe('ignores afterValue', () => { + it('with no guard', () => + expect( + new ConfiguredVariable({ + variableName: 'foo', + expression: {text: 'bar', quotes: true}, + raws: {afterValue: '/**/'}, + }).toString() + ).toBe('$foo: "bar"')); + + it('with a guard', () => + expect( + new ConfiguredVariable({ + variableName: 'foo', + expression: {text: 'bar', quotes: true}, + guarded: true, + raws: {afterValue: '/**/'}, + }).toString() + ).toBe('$foo: "bar" !default')); + }); + }); + }); + + describe('clone()', () => { + let original: ConfiguredVariable; + beforeEach(() => { + original = ( + scss.parse('@use "foo" with ($foo: "bar")').nodes[0] as UseRule + ).configuration.get('foo')!; + original.raws.between = ' : '; + }); + + describe('with no overrides', () => { + let clone: ConfiguredVariable; + beforeEach(() => void (clone = original.clone())); + + describe('has the same properties:', () => { + it('variableName', () => expect(clone.variableName).toBe('foo')); + + it('expression', () => + expect(clone).toHaveStringExpression('expression', 'bar')); + + it('guarded', () => expect(clone.guarded).toBe(false)); + }); + + 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: {before: ' '}}).raws).toEqual({ + before: ' ', + })); + + it('undefined', () => + expect(original.clone({raws: undefined}).raws).toEqual({ + between: ' : ', + })); + }); + + describe('variableName', () => { + it('defined', () => + expect(original.clone({variableName: 'baz'}).variableName).toBe( + 'baz' + )); + + it('undefined', () => + expect(original.clone({variableName: undefined}).variableName).toBe( + 'foo' + )); + }); + + describe('expression', () => { + it('defined', () => + expect( + original.clone({expression: {text: 'baz', quotes: true}}) + ).toHaveStringExpression('expression', 'baz')); + + it('undefined', () => + expect( + original.clone({expression: undefined}) + ).toHaveStringExpression('expression', 'bar')); + }); + + describe('guarded', () => { + it('defined', () => + expect(original.clone({guarded: true}).guarded).toBe(true)); + + it('undefined', () => + expect(original.clone({guarded: undefined}).guarded).toBe(false)); + }); + }); + }); + + it('toJSON', () => + expect( + ( + scss.parse('@use "foo" with ($baz: "qux")').nodes[0] as UseRule + ).configuration.get('baz') + ).toMatchSnapshot()); +}); diff --git a/pkg/sass-parser/lib/src/configured-variable.ts b/pkg/sass-parser/lib/src/configured-variable.ts new file mode 100644 index 000000000..82f5d12aa --- /dev/null +++ b/pkg/sass-parser/lib/src/configured-variable.ts @@ -0,0 +1,188 @@ +// 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 {Configuration} from './configuration'; +import {convertExpression} from './expression/convert'; +import {Expression, ExpressionProps} from './expression'; +import {fromProps} from './expression/from-props'; +import {LazySource} from './lazy-source'; +import {Node} from './node'; +import * as sassInternal from './sass-internal'; +import {RawWithValue} from './raw-with-value'; +import * as utils from './utils'; + +/** + * The set of raws supported by {@link ConfiguredVariable}. + * + * @category Statement + */ +export interface ConfiguredVariableRaws { + /** The whitespace before the variable name. */ + before?: string; + + /** + * The variable's name, not including the `$`. + * + * This may be different than {@link ConfiguredVariable.variable} if the name + * contains escape codes or underscores. + */ + variableName?: RawWithValue; + + /** The whitespace and colon between the variable name and value. */ + between?: string; + + /** + * The whitespace between the variable's value and the `!default` flag. If the + * variable doesn't have a `!default` flag, this is ignored. + */ + beforeGuard?: string; + + /** + * The space symbols between the end of the variable declaration and the comma + * afterwards. Always empty for a variable that doesn't have a trailing comma. + */ + afterValue?: string; +} + +/** + * The initializer properties for {@link ConfiguredVariable} passed as an + * options object. + * + * @category Statement + */ +export interface ConfiguredVariableObjectProps { + raws?: ConfiguredVariableRaws; + variableName: string; + expression: Expression | ExpressionProps; + guarded?: boolean; +} + +/** + * Properties used to initialize a {@link ConfiguredVariable} without an + * explicit name. This is used when the name is given elsewhere, either in the + * array form of {@link ConfiguredVariableProps} or the record form of [@link + * ConfigurationProps}. + * + * Passing in an {@link Expression} or {@link ExpressionProps} directly always + * creates an unguarded {@link ConfiguredVariable}. + */ +export type ConfiguredVariableExpressionProps = + | Expression + | ExpressionProps + | Omit; + +/** + * The initializer properties for {@link ConfiguredVariable}. + * + * @category Statement + */ +export type ConfiguredVariableProps = + | ConfiguredVariableObjectProps + | [string, ConfiguredVariableExpressionProps]; + +/** + * A single variable configured for the `with` clause of a `@use` or `@forward` + * rule. This is always included in a {@link Configuration}. + * + * @category Statement + */ +export class ConfiguredVariable extends Node { + readonly sassType = 'configured-variable' as const; + declare raws: ConfiguredVariableRaws; + declare parent: Configuration | 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. + */ + variableName!: string; + + /** The expresison whose value the variable is assigned. */ + 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 this has a `!default` guard. */ + guarded!: boolean; + + constructor(defaults: ConfiguredVariableProps); + /** @hidden */ + constructor(_: undefined, inner: sassInternal.ConfiguredVariable); + constructor( + defaults?: ConfiguredVariableProps, + inner?: sassInternal.ConfiguredVariable + ) { + if (Array.isArray(defaults!)) { + const [variableName, rest] = defaults; + if ('sassType' in rest || !('expression' in rest)) { + defaults = { + variableName, + expression: rest as Expression | ExpressionProps, + }; + } else { + defaults = {variableName, ...rest}; + } + } + super(defaults); + this.raws ??= {}; + + if (inner) { + this.source = new LazySource(inner); + this.variableName = inner.name; + this.expression = convertExpression(inner.expression); + this.guarded = inner.isGuarded; + } else { + this.guarded ??= false; + } + } + + clone(overrides?: Partial): this { + return utils.cloneNode(this, overrides, [ + 'raws', + 'variableName', + 'expression', + 'guarded', + ]); + } + + toJSON(): object; + /** @hidden */ + toJSON(_: string, inputs: Map): object; + toJSON(_?: string, inputs?: Map): object { + return utils.toJSON( + this, + ['variableName', 'expression', 'guarded'], + inputs + ); + } + + /** @hidden */ + toString(): string { + return ( + '$' + + (this.raws.variableName?.value === this.variableName + ? this.raws.variableName.raw + : sassInternal.toCssIdentifier(this.variableName)) + + (this.raws.between ?? ': ') + + this.expression + + (this.guarded ? `${this.raws.beforeGuard ?? ' '}!default` : '') + ); + } + + /** @hidden */ + get nonStatementChildren(): ReadonlyArray { + return [this.expression]; + } +} diff --git a/pkg/sass-parser/lib/src/interpolation.ts b/pkg/sass-parser/lib/src/interpolation.ts index 8ffbb94a2..032c564dd 100644 --- a/pkg/sass-parser/lib/src/interpolation.ts +++ b/pkg/sass-parser/lib/src/interpolation.ts @@ -9,6 +9,7 @@ import {fromProps} from './expression/from-props'; import {Expression, ExpressionProps} from './expression'; import {LazySource} from './lazy-source'; import {Node} from './node'; +import {RawWithValue} from './raw-with-value'; import type * as sassInternal from './sass-internal'; import * as utils from './utils'; @@ -48,13 +49,10 @@ export interface InterpolationRaws { * The text written in the stylesheet for the plain-text portions of the * interpolation, without any interpretation of escape sequences. * - * `raw` is the value of the raw itself, and `value` is the parsed value - * that's required to be in the interpolation in order for this raw to be used. - * * Any indices for which {@link Interpolation.nodes} doesn't contain a string * are ignored. */ - text?: Array<{raw: string; value: string} | undefined>; + text?: Array | undefined>; /** * The whitespace before and after each interpolated expression. diff --git a/pkg/sass-parser/lib/src/node.d.ts b/pkg/sass-parser/lib/src/node.d.ts index 8841a46a0..432983699 100644 --- a/pkg/sass-parser/lib/src/node.d.ts +++ b/pkg/sass-parser/lib/src/node.d.ts @@ -18,7 +18,12 @@ export type AnyNode = AnyStatement | AnyExpression | Interpolation; * alongside `Node.type` to disambiguate between the wide range of nodes that * Sass parses as distinct types. */ -export type NodeType = StatementType | ExpressionType | 'interpolation'; +export type NodeType = + | StatementType + | ExpressionType + | 'interpolation' + | 'configuration' + | 'configured-variable'; /** The constructor properties shared by all Sass AST nodes. */ export type NodeProps = postcss.NodeProps; diff --git a/pkg/sass-parser/lib/src/raw-with-value.ts b/pkg/sass-parser/lib/src/raw-with-value.ts new file mode 100644 index 000000000..3e58021a0 --- /dev/null +++ b/pkg/sass-parser/lib/src/raw-with-value.ts @@ -0,0 +1,26 @@ +// 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. + +/** + * An object describing how a value is represented in a stylesheet's source. + * + * This is used for values that can have multiple different representations that + * all produce the same value. The {@link raw} field indicates the textual + * representation in the stylesheet, while the {@link value} indicates the value + * it represents. + * + * When serializing, if {@link value} doesn't match the value in the AST node, + * this is ignored. This ensures that if a plugin overwrites the AST value + * and ignores the raws, its change is preserved in the serialized output. + */ +export interface RawWithValue { + /** The textual representation of {@link value} in the stylesheet. */ + raw: string; + + /** + * The parsed value that {@link raw} represents. This is used to verify that + * this raw is still valid for the AST node that contains it. + */ + value: T; +} diff --git a/pkg/sass-parser/lib/src/sass-internal.ts b/pkg/sass-parser/lib/src/sass-internal.ts index b0b42c1a5..8c7093daa 100644 --- a/pkg/sass-parser/lib/src/sass-internal.ts +++ b/pkg/sass-parser/lib/src/sass-internal.ts @@ -30,6 +30,13 @@ export interface SourceFile { declare namespace SassInternal { function parse(css: string, syntax: Syntax, path?: string): Stylesheet; + function parseIdentifier( + identifier: string, + logger?: sass.Logger + ): string | null; + + function toCssIdentifier(text: string): string; + class StatementVisitor { private _fakePropertyToMakeThisAUniqueType1: T; } @@ -167,6 +174,18 @@ declare namespace SassInternal { toInterpolation(): Interpolation; } + class UseRule extends Statement { + readonly url: Object; + readonly namespace: string | null; + readonly configuration: ConfiguredVariable[]; + } + + class ConfiguredVariable extends SassNode { + readonly name: string; + readonly expression: Expression; + readonly isGuarded: boolean; + } + class Expression extends SassNode { accept(visitor: ExpressionVisitor): T; } @@ -218,6 +237,8 @@ export type SilentComment = SassInternal.SilentComment; export type Stylesheet = SassInternal.Stylesheet; export type StyleRule = SassInternal.StyleRule; export type SupportsRule = SassInternal.SupportsRule; +export type UseRule = SassInternal.UseRule; +export type ConfiguredVariable = SassInternal.ConfiguredVariable; export type Interpolation = SassInternal.Interpolation; export type Expression = SassInternal.Expression; export type BinaryOperationExpression = SassInternal.BinaryOperationExpression; @@ -238,6 +259,7 @@ export interface StatementVisitorObject { visitSilentComment(node: SilentComment): T; visitStyleRule(node: StyleRule): T; visitSupportsRule(node: SupportsRule): T; + visitUseRule(node: UseRule): T; } export interface ExpressionVisitorObject { @@ -248,5 +270,7 @@ export interface ExpressionVisitorObject { } export const parse = sassInternal.parse; +export const parseIdentifier = sassInternal.parseIdentifier; +export const toCssIdentifier = sassInternal.toCssIdentifier; export const createStatementVisitor = sassInternal.createStatementVisitor; export const createExpressionVisitor = sassInternal.createExpressionVisitor; diff --git a/pkg/sass-parser/lib/src/statement/index.ts b/pkg/sass-parser/lib/src/statement/index.ts index b742aff8d..10a0aae11 100644 --- a/pkg/sass-parser/lib/src/statement/index.ts +++ b/pkg/sass-parser/lib/src/statement/index.ts @@ -17,6 +17,7 @@ import {ErrorRule, ErrorRuleProps} from './error-rule'; import {ForRule, ForRuleProps} from './for-rule'; import {Root} from './root'; import {Rule, RuleProps} from './rule'; +import {UseRule, UseRuleProps} from './use-rule'; // TODO: Replace this with the corresponding Sass types once they're // implemented. @@ -47,6 +48,7 @@ export type StatementType = | 'each-rule' | 'for-rule' | 'error-rule' + | 'use-rule' | 'sass-comment'; /** @@ -54,7 +56,13 @@ export type StatementType = * * @category Statement */ -export type AtRule = DebugRule | EachRule | ErrorRule | ForRule | GenericAtRule; +export type AtRule = + | DebugRule + | EachRule + | ErrorRule + | ForRule + | GenericAtRule + | UseRule; /** * All Sass statements that are comments. @@ -88,7 +96,8 @@ export type ChildProps = | ForRuleProps | GenericAtRuleProps | RuleProps - | SassCommentChildProps; + | SassCommentChildProps + | UseRuleProps; /** * The Sass eqivalent of PostCSS's `ContainerProps`. @@ -175,6 +184,7 @@ const visitor = sassInternal.createStatementVisitor({ appendInternalChildren(rule, inner.children); return rule; }, + visitUseRule: inner => new UseRule(undefined, inner), }); /** Appends parsed versions of `internal`'s children to `container`. */ @@ -289,6 +299,8 @@ export function normalize( result.push(new CssComment(node as CssCommentProps)); } else if ('silentText' in node) { result.push(new SassComment(node)); + } else if ('useUrl' in node) { + result.push(new UseRule(node)); } else { result.push(...postcssNormalizeAndConvertToSass(self, node, sample)); } diff --git a/pkg/sass-parser/lib/src/statement/use-rule.test.ts b/pkg/sass-parser/lib/src/statement/use-rule.test.ts new file mode 100644 index 000000000..27a76c5ee --- /dev/null +++ b/pkg/sass-parser/lib/src/statement/use-rule.test.ts @@ -0,0 +1,553 @@ +// 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 {Configuration, UseRule, sass, scss} from '../..'; +import * as utils from '../../../test/utils'; + +describe('a @use rule', () => { + let node: UseRule; + describe('with just a URL', () => { + function describeNode(description: string, create: () => UseRule): void { + describe(description, () => { + beforeEach(() => (node = create())); + + it('has a type', () => expect(node.type.toString()).toBe('atrule')); + + it('has a sassType', () => + expect(node.sassType.toString()).toBe('use-rule')); + + it('has a name', () => expect(node.name.toString()).toBe('use')); + + it('has a url', () => expect(node.useUrl).toBe('foo')); + + it('has a default namespace', () => expect(node.namespace).toBe('foo')); + + it('has an empty configuration', () => { + expect(node.configuration.size).toBe(0); + expect(node.configuration.parent).toBe(node); + }); + + it('has matching params', () => expect(node.params).toBe('"foo"')); + + it('has undefined nodes', () => expect(node.nodes).toBeUndefined()); + }); + } + + describeNode( + 'parsed as SCSS', + () => scss.parse('@use "foo"').nodes[0] as UseRule + ); + + describeNode( + 'parsed as Sass', + () => sass.parse('@use "foo"').nodes[0] as UseRule + ); + + describeNode( + 'constructed manually', + () => + new UseRule({ + useUrl: 'foo', + }) + ); + + describeNode('constructed from ChildProps', () => + utils.fromChildProps({ + useUrl: 'foo', + }) + ); + }); + + describe('with no namespace', () => { + function describeNode(description: string, create: () => UseRule): void { + describe(description, () => { + beforeEach(() => void (node = create())); + + it('has a type', () => expect(node.type.toString()).toBe('atrule')); + + it('has a sassType', () => + expect(node.sassType.toString()).toBe('use-rule')); + + it('has a name', () => expect(node.name.toString()).toBe('use')); + + it('has a url', () => expect(node.useUrl).toBe('foo')); + + it('has a null namespace', () => expect(node.namespace).toBeNull()); + + it('has an empty configuration', () => { + expect(node.configuration.size).toBe(0); + expect(node.configuration.parent).toBe(node); + }); + + it('has matching params', () => expect(node.params).toBe('"foo" as *')); + + it('has undefined nodes', () => expect(node.nodes).toBeUndefined()); + }); + } + + describeNode( + 'parsed as SCSS', + () => scss.parse('@use "foo" as *').nodes[0] as UseRule + ); + + describeNode( + 'parsed as Sass', + () => sass.parse('@use "foo" as *').nodes[0] as UseRule + ); + + describeNode( + 'constructed manually', + () => + new UseRule({ + useUrl: 'foo', + namespace: null, + }) + ); + + describeNode('constructed from ChildProps', () => + utils.fromChildProps({ + useUrl: 'foo', + namespace: null, + }) + ); + }); + + describe('with explicit namespace and configuration', () => { + function describeNode(description: string, create: () => UseRule): void { + describe(description, () => { + beforeEach(() => void (node = create())); + + it('has a type', () => expect(node.type.toString()).toBe('atrule')); + + it('has a sassType', () => + expect(node.sassType.toString()).toBe('use-rule')); + + it('has a name', () => expect(node.name.toString()).toBe('use')); + + it('has a url', () => expect(node.useUrl).toBe('foo')); + + it('has an explicit', () => expect(node.namespace).toBe('bar')); + + it('has an empty configuration', () => { + expect(node.configuration.size).toBe(1); + expect(node.configuration.parent).toBe(node); + const variables = [...node.configuration.variables()]; + expect(variables[0].variableName).toBe('baz'); + expect(variables[0]).toHaveStringExpression('expression', 'qux'); + }); + + it('has matching params', () => + expect(node.params).toBe('"foo" as bar with ($baz: "qux")')); + + it('has undefined nodes', () => expect(node.nodes).toBeUndefined()); + }); + } + + describeNode( + 'parsed as SCSS', + () => + scss.parse('@use "foo" as bar with ($baz: "qux")').nodes[0] as UseRule + ); + + describeNode( + 'parsed as Sass', + () => + sass.parse('@use "foo" as bar with ($baz: "qux")').nodes[0] as UseRule + ); + + describeNode( + 'constructed manually', + () => + new UseRule({ + useUrl: 'foo', + namespace: 'bar', + configuration: { + variables: {baz: {text: 'qux', quotes: true}}, + }, + }) + ); + + describeNode('constructed from ChildProps', () => + utils.fromChildProps({ + useUrl: 'foo', + namespace: 'bar', + configuration: { + variables: {baz: {text: 'qux', quotes: true}}, + }, + }) + ); + }); + + describe('throws an error when assigned a new', () => { + beforeEach(() => void (node = new UseRule({useUrl: 'foo'}))); + + it('name', () => expect(() => (node.name = 'bar')).toThrow()); + + it('params', () => expect(() => (node.params = 'bar')).toThrow()); + }); + + it('assigned a new url', () => { + node = new UseRule({useUrl: 'foo'}); + node.useUrl = 'bar'; + expect(node.useUrl).toBe('bar'); + expect(node.params).toBe('"bar" as foo'); + expect(node.defaultNamespace).toBe('bar'); + }); + + it('assigned a new namespace', () => { + node = new UseRule({useUrl: 'foo'}); + node.namespace = 'bar'; + expect(node.namespace).toBe('bar'); + expect(node.params).toBe('"foo" as bar'); + expect(node.defaultNamespace).toBe('foo'); + }); + + it('assigned a new configuration', () => { + node = new UseRule({useUrl: 'foo'}); + node.configuration = new Configuration({ + variables: {bar: {text: 'baz', quotes: true}}, + }); + expect(node.configuration.size).toBe(1); + expect(node.params).toBe('"foo" with ($bar: "baz")'); + }); + + describe('defaultNamespace', () => { + describe('is null for', () => { + it('a URL without a pathname', () => + expect( + new UseRule({useUrl: 'https://example.org'}).defaultNamespace + ).toBeNull()); + + it('a URL with a slash pathname', () => + expect( + new UseRule({useUrl: 'https://example.org/'}).defaultNamespace + ).toBeNull()); + + it('a basename that starts with .', () => + expect(new UseRule({useUrl: '.foo'}).defaultNamespace).toBeNull()); + + it('a fragment', () => + expect(new UseRule({useUrl: '#foo'}).defaultNamespace).toBeNull()); + + it('a path that ends in /', () => + expect(new UseRule({useUrl: 'foo/'}).defaultNamespace).toBeNull()); + + it('an invalid identifier', () => + expect(new UseRule({useUrl: '123'}).defaultNamespace).toBeNull()); + }); + + it('the basename', () => + expect(new UseRule({useUrl: 'foo/bar/baz'}).defaultNamespace).toBe( + 'baz' + )); + + it('without an extension', () => + expect(new UseRule({useUrl: 'foo.scss'}).defaultNamespace).toBe('foo')); + + it('the basename of an HTTP URL', () => + expect( + new UseRule({useUrl: 'http://example.org/foo/bar/baz'}).defaultNamespace + ).toBe('baz')); + + it('the basename of a file: URL', () => + expect( + new UseRule({useUrl: 'file:///foo/bar/baz'}).defaultNamespace + ).toBe('baz')); + + it('the basename of an unknown scheme URL', () => + expect(new UseRule({useUrl: 'foo:bar/bar/qux'}).defaultNamespace).toBe( + 'qux' + )); + + it('a sass: URL', () => + expect(new UseRule({useUrl: 'sass:foo'}).defaultNamespace).toBe('foo')); + }); + + describe('stringifies', () => { + describe('to SCSS', () => { + describe('with default raws', () => { + it('with a non-default namespace', () => + expect( + new UseRule({ + useUrl: 'foo', + namespace: 'bar', + }).toString() + ).toBe('@use "foo" as bar;')); + + it('with a non-identifier namespace', () => + expect( + new UseRule({ + useUrl: 'foo', + namespace: ' ', + }).toString() + ).toBe('@use "foo" as \\20;')); + + it('with no namespace', () => + expect( + new UseRule({ + useUrl: 'foo', + namespace: null, + }).toString() + ).toBe('@use "foo" as *;')); + + it('with configuration', () => + expect( + new UseRule({ + useUrl: 'foo', + configuration: { + variables: {bar: {text: 'baz', quotes: true}}, + }, + }).toString() + ).toBe('@use "foo" with ($bar: "baz");')); + }); + + describe('with a URL raw', () => { + it('that matches', () => + expect( + new UseRule({ + useUrl: 'foo', + raws: {url: {raw: "'foo'", value: 'foo'}}, + }).toString() + ).toBe("@use 'foo';")); + + it("that doesn't match", () => + expect( + new UseRule({ + useUrl: 'foo', + raws: {url: {raw: "'bar'", value: 'bar'}}, + }).toString() + ).toBe('@use "foo";')); + }); + + describe('with a namespace raw', () => { + it('that matches a string', () => + expect( + new UseRule({ + useUrl: 'foo', + raws: {namespace: {raw: ' as foo', value: 'foo'}}, + }).toString() + ).toBe('@use "foo" as foo;')); + + it('that matches null', () => + expect( + new UseRule({ + useUrl: 'foo', + namespace: null, + raws: {namespace: {raw: ' as *', value: null}}, + }).toString() + ).toBe('@use "foo" as *;')); + + it("that doesn't match", () => + expect( + new UseRule({ + useUrl: 'foo', + raws: {url: {raw: ' as bar', value: 'bar'}}, + }).toString() + ).toBe('@use "foo";')); + }); + + describe('with beforeWith', () => { + it('and a configuration', () => + expect( + new UseRule({ + useUrl: 'foo', + configuration: { + variables: {bar: {text: 'baz', quotes: true}}, + }, + raws: {beforeWith: '/**/'}, + }).toString() + ).toBe('@use "foo"/**/with ($bar: "baz");')); + + it('and no configuration', () => + expect( + new UseRule({ + useUrl: 'foo', + raws: {beforeWith: '/**/'}, + }).toString() + ).toBe('@use "foo";')); + }); + + describe('with afterWith', () => { + it('and a configuration', () => + expect( + new UseRule({ + useUrl: 'foo', + configuration: { + variables: {bar: {text: 'baz', quotes: true}}, + }, + raws: {afterWith: '/**/'}, + }).toString() + ).toBe('@use "foo" with/**/($bar: "baz");')); + + it('and no configuration', () => + expect( + new UseRule({ + useUrl: 'foo', + raws: {afterWith: '/**/'}, + }).toString() + ).toBe('@use "foo";')); + }); + }); + }); + + describe('clone', () => { + let original: UseRule; + beforeEach(() => { + original = scss.parse('@use "foo" as bar with ($baz: "qux")') + .nodes[0] as UseRule; + // TODO: remove this once raws are properly parsed + original.raws.beforeWith = ' '; + }); + + describe('with no overrides', () => { + let clone: UseRule; + beforeEach(() => void (clone = original.clone())); + + describe('has the same properties:', () => { + it('params', () => + expect(clone.params).toBe('"foo" as bar with ($baz: "qux")')); + + it('url', () => expect(clone.useUrl).toBe('foo')); + + it('namespace', () => expect(clone.namespace).toBe('bar')); + + it('configuration', () => { + expect(clone.configuration.size).toBe(1); + expect(clone.configuration.parent).toBe(clone); + const variables = [...clone.configuration.variables()]; + expect(variables[0].variableName).toBe('baz'); + expect(variables[0]).toHaveStringExpression('expression', 'qux'); + }); + + it('raws', () => expect(clone.raws).toEqual({beforeWith: ' '})); + + it('source', () => expect(clone.source).toBe(original.source)); + }); + + describe('creates a new', () => { + it('self', () => expect(clone).not.toBe(original)); + + for (const attr of ['configuration', 'raws'] as const) { + it(attr, () => expect(clone[attr]).not.toBe(original[attr])); + } + }); + }); + + describe('overrides', () => { + describe('raws', () => { + it('defined', () => + expect(original.clone({raws: {afterWith: ' '}}).raws).toEqual({ + afterWith: ' ', + })); + + it('undefined', () => + expect(original.clone({raws: undefined}).raws).toEqual({ + beforeWith: ' ', + })); + }); + + describe('useUrl', () => { + describe('defined', () => { + let clone: UseRule; + beforeEach(() => { + clone = original.clone({useUrl: 'flip'}); + }); + + it('changes useUrl', () => expect(clone.useUrl).toBe('flip')); + + it('changes params', () => + expect(clone.params).toBe('"flip" as bar with ($baz: "qux")')); + }); + + describe('undefined', () => { + let clone: UseRule; + beforeEach(() => { + clone = original.clone({useUrl: undefined}); + }); + + it('preserves useUrl', () => expect(clone.useUrl).toBe('foo')); + + it('preserves params', () => + expect(clone.params).toBe('"foo" as bar with ($baz: "qux")')); + }); + }); + + describe('namespace', () => { + describe('defined', () => { + let clone: UseRule; + beforeEach(() => { + clone = original.clone({namespace: 'flip'}); + }); + + it('changes namespace', () => expect(clone.namespace).toBe('flip')); + + it('changes params', () => + expect(clone.params).toBe('"foo" as flip with ($baz: "qux")')); + }); + + describe('null', () => { + let clone: UseRule; + beforeEach(() => { + clone = original.clone({namespace: null}); + }); + + it('changes namespace', () => expect(clone.namespace).toBeNull()); + + it('changes params', () => + expect(clone.params).toBe('"foo" as * with ($baz: "qux")')); + }); + + describe('undefined', () => { + let clone: UseRule; + beforeEach(() => { + clone = original.clone({namespace: undefined}); + }); + + it('preserves namespace', () => expect(clone.namespace).toBe('bar')); + + it('preserves params', () => + expect(clone.params).toBe('"foo" as bar with ($baz: "qux")')); + }); + }); + + describe('configuration', () => { + describe('defined', () => { + let clone: UseRule; + beforeEach(() => { + clone = original.clone({configuration: new Configuration()}); + }); + + it('changes configuration', () => + expect(clone.configuration.size).toBe(0)); + + it('changes params', () => expect(clone.params).toBe('"foo" as bar')); + }); + + describe('undefined', () => { + let clone: UseRule; + beforeEach(() => { + clone = original.clone({configuration: undefined}); + }); + + it('preserves configuration', () => { + expect(clone.configuration.size).toBe(1); + expect(clone.configuration.parent).toBe(clone); + const variables = [...clone.configuration.variables()]; + expect(variables[0].variableName).toBe('baz'); + expect(variables[0]).toHaveStringExpression('expression', 'qux'); + }); + + it('preserves params', () => + expect(clone.params).toBe('"foo" as bar with ($baz: "qux")')); + }); + }); + }); + }); + + // Can't JSON-serialize this until we implement Configuration.source.span + it.skip('toJSON', () => + expect( + scss.parse('@use "foo" as bar with ($baz: "qux")').nodes[0] + ).toMatchSnapshot()); +}); diff --git a/pkg/sass-parser/lib/src/statement/use-rule.ts b/pkg/sass-parser/lib/src/statement/use-rule.ts new file mode 100644 index 000000000..f64b3eee3 --- /dev/null +++ b/pkg/sass-parser/lib/src/statement/use-rule.ts @@ -0,0 +1,209 @@ +// 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} from 'postcss/lib/at-rule'; + +import {Configuration, ConfigurationProps} from '../configuration'; +import {Expression} from '../expression'; +import {StringExpression} from '../expression/string'; +import {LazySource} from '../lazy-source'; +import {RawWithValue} from '../raw-with-value'; +import * as sassInternal from '../sass-internal'; +import * as utils from '../utils'; +import {ContainerProps, 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 UseRule}. + * + * @category Statement + */ +export interface UseRuleRaws extends Omit { + /** The representation of {@link UseRule.url}. */ + url?: RawWithValue; + + /** + * The text of the explicit namespace value, including `as` and any whitespace + * before it. + * + * Only used if {@link namespace.value} matches {@link UseRule.namespace}. + */ + namespace?: RawWithValue; + + /** + * The whitespace between the URL or namespace and the `with` keyword. + * + * Unused if the rule doesn't have a `with` clause. + */ + beforeWith?: string; + + /** + * The whitespace between the `with` keyword and the configuration map. + * + * Unused unless the rule has a non-empty configuration. + */ + afterWith?: string; +} + +/** + * The initializer properties for {@link UseRule}. + * + * @category Statement + */ +export type UseRuleProps = ContainerProps & { + raws?: UseRuleRaws; + useUrl: string; + namespace?: string | null; + configuration?: Configuration | ConfigurationProps; +}; + +/** + * A `@use` rule. Extends [`postcss.AtRule`]. + * + * [`postcss.AtRule`]: https://postcss.org/api/#atrule + * + * @category Statement + */ +export class UseRule + extends _AtRule> + implements Statement +{ + readonly sassType = 'use-rule' as const; + declare parent: StatementWithChildren | undefined; + declare raws: UseRuleRaws; + declare readonly nodes: undefined; + + /** The URL loaded by the `@use` rule. */ + declare useUrl: string; + + /** + * This rule's namespace, or `null` if the members can be accessed without a + * namespace. + * + * Note that this is the _semantic_ namespace for the rule, so it's set even + * if the namespace is inferred from the URL. When constructing a new + * `UseRule`, this is set to {@link defaultNamespace} by default unless an + * explicit `null` or string value is passed. + */ + declare namespace: string | null; + + /** + * The default namespace for {@link useUrl} if no explicit namespace is + * specified, or null if there's not a valid default. + */ + get defaultNamespace(): string | null { + // Use a bogus base URL so we can parse relative URLs. + const url = new URL(this.useUrl, 'https://example.org/'); + const basename = url.pathname.split('/').at(-1)!; + const dot = basename.indexOf('.'); + return sassInternal.parseIdentifier( + dot === -1 ? basename : basename.substring(0, dot) + ); + } + + get name(): string { + return 'use'; + } + set name(value: string) { + throw new Error("UseRule.name can't be overwritten."); + } + + get params(): string { + let result = + this.raws.url?.value === this.useUrl + ? this.raws.url!.raw + : new StringExpression({text: this.useUrl, quotes: true}).toString(); + const hasConfiguration = this.configuration.size > 0; + if (this.raws.namespace?.value === this.namespace) { + result += this.raws.namespace?.raw; + } else if (!this.namespace) { + result += ' as *'; + } else if (this.defaultNamespace !== this.namespace) { + result += ' as ' + sassInternal.toCssIdentifier(this.namespace); + } + + if (hasConfiguration) { + result += + `${this.raws.beforeWith ?? ' '}with` + + `${this.raws.afterWith ?? ' '}${this.configuration}`; + } + return result; + } + set params(value: string | number | undefined) { + throw new Error("UseRule.params can't be overwritten."); + } + + /** The variables whose defaults are set when loading this module. */ + get configuration(): Configuration { + return this._configuration!; + } + set configuration(configuration: Configuration | ConfigurationProps) { + if (this._configuration) { + this._configuration.clear(); + this._configuration.parent = undefined; + } + this._configuration = + 'sassType' in configuration + ? configuration + : new Configuration(configuration); + this._configuration.parent = this; + } + private _configuration!: Configuration; + + constructor(defaults: UseRuleProps); + /** @hidden */ + constructor(_: undefined, inner: sassInternal.UseRule); + constructor(defaults?: UseRuleProps, inner?: sassInternal.UseRule) { + super(defaults as unknown as postcss.AtRuleProps); + this.raws ??= {}; + + if (inner) { + this.source = new LazySource(inner); + this.useUrl = inner.url.toString(); + this.namespace = inner.namespace ?? null; + this.configuration = new Configuration(undefined, inner.configuration); + } else { + this.configuration ??= new Configuration(); + if (this.namespace === undefined) this.namespace = this.defaultNamespace; + } + } + + clone(overrides?: Partial): this { + return utils.cloneNode(this, overrides, [ + 'raws', + 'useUrl', + 'namespace', + 'configuration', + ]); + } + + toJSON(): object; + /** @hidden */ + toJSON(_: string, inputs: Map): object; + toJSON(_?: string, inputs?: Map): object { + return utils.toJSON( + this, + ['useUrl', 'namespace', 'configuration', 'params'], + inputs + ); + } + + /** @hidden */ + toString( + stringifier: postcss.Stringifier | postcss.Syntax = sassParser.scss + .stringify + ): string { + return super.toString(stringifier); + } + + /** @hidden */ + get nonStatementChildren(): ReadonlyArray { + return [...Object.values(this.configuration)]; + } +} + +interceptIsClean(UseRule); diff --git a/pkg/sass-parser/lib/src/stringifier.ts b/pkg/sass-parser/lib/src/stringifier.ts index 46374e19d..8f5a9aa5c 100644 --- a/pkg/sass-parser/lib/src/stringifier.ts +++ b/pkg/sass-parser/lib/src/stringifier.ts @@ -35,6 +35,7 @@ import {ErrorRule} from './statement/error-rule'; import {GenericAtRule} from './statement/generic-at-rule'; import {Rule} from './statement/rule'; import {SassComment} from './statement/sass-comment'; +import {UseRule} from './statement/use-rule'; const PostCssStringifier = require('postcss/lib/stringifier'); @@ -72,45 +73,19 @@ export class Stringifier extends PostCssStringifier { } private ['debug-rule'](node: DebugRule, semicolon: boolean): void { - this.builder( - '@debug' + - (node.raws.afterName ?? ' ') + - node.debugExpression + - (node.raws.between ?? '') + - (semicolon ? ';' : ''), - node - ); + this.sassAtRule(node, semicolon); } private ['each-rule'](node: EachRule): void { - this.block( - node, - '@each' + - (node.raws.afterName ?? ' ') + - node.params + - (node.raws.between ?? '') - ); + this.sassAtRule(node); } private ['error-rule'](node: ErrorRule, semicolon: boolean): void { - this.builder( - '@error' + - (node.raws.afterName ?? ' ') + - node.errorExpression + - (node.raws.between ?? '') + - (semicolon ? ';' : ''), - node - ); + this.sassAtRule(node, semicolon); } private ['for-rule'](node: EachRule): void { - this.block( - node, - '@for' + - (node.raws.afterName ?? ' ') + - node.params + - (node.raws.between ?? '') - ); + this.sassAtRule(node); } private atrule(node: GenericAtRule, semicolon: boolean): void { @@ -180,4 +155,23 @@ export class Stringifier extends PostCssStringifier { this.builder(text, node); } + + private ['use-rule'](node: UseRule, 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 = + '@' + + node.name + + (node.raws.afterName ?? ' ') + + node.params + + (node.raws.between ?? ''); + if (node.nodes) { + this.block(node, start); + } else { + this.builder(start + (semicolon ? ';' : ''), node); + } + } } diff --git a/pkg/sass-parser/lib/src/utils.ts b/pkg/sass-parser/lib/src/utils.ts index f022aca5d..f18f9816c 100644 --- a/pkg/sass-parser/lib/src/utils.ts +++ b/pkg/sass-parser/lib/src/utils.ts @@ -173,10 +173,10 @@ function toJsonField( ): unknown { if (typeof value !== 'object' || value === null) { return value; - } else if (Array.isArray(value)) { - return value.map((element, i) => - toJsonField(i.toString(), element, inputs) - ); + } else if (Symbol.iterator in value) { + return ( + Array.isArray(value) ? value : [...(value as IterableIterator)] + ).map((element, i) => toJsonField(i.toString(), element, inputs)); } else if ('toJSON' in value) { if ('sassType' in value) { return ( diff --git a/test/util/string_test.dart b/test/util/string_test.dart new file mode 100644 index 000000000..db8607098 --- /dev/null +++ b/test/util/string_test.dart @@ -0,0 +1,188 @@ +// 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 'package:test/test.dart'; + +import 'package:sass/src/util/string.dart'; +import 'package:sass/src/util/map.dart'; + +void main() { + group("toCssIdentifier()", () { + group("doesn't escape", () { + test('a double hyphen', + () => expect('--'.toCssIdentifier(), equals('--'))); + + group("a starting character", () { + const chars = { + 'lower-case alphabetic': 'q', + 'upper-case alphabetic': 'E', + 'an underscore': '_', + 'non-ASCII': 'ä', + 'double-width': '👭' + }; + + group("at the very beginning that's", () { + for (var (name, char) in chars.pairs) { + test(name, () => expect(char.toCssIdentifier(), equals(char))); + } + }); + + group("after a single hyphen that's", () { + for (var (name, char) in chars.pairs) { + test(name, + () => expect('-$char'.toCssIdentifier(), equals('-$char'))); + } + }); + }); + + group("a middle character", () { + const chars = { + 'lower-case alphabetic': 'q', + 'upper-case alphabetic': 'E', + 'numeric': '4', + 'an underscore': '_', + 'a hyphen': '-', + 'non-ASCII': 'ä', + 'double-width': '👭' + }; + + group("after a name start that's", () { + for (var (name, char) in chars.pairs) { + test(name, + () => expect('a$char'.toCssIdentifier(), equals('a$char'))); + } + }); + + group("after a double hyphen that's", () { + for (var (name, char) in chars.pairs) { + test(name, + () => expect('--$char'.toCssIdentifier(), equals('--$char'))); + } + }); + }); + }); + + group('escapes', () { + test('a single hyphen', + () => expect('-'.toCssIdentifier(), equals('\\2d'))); + + group('a starting character', () { + const chars = { + 'numeric': ('4', '\\34'), + 'non-alphanumeric ASCII': ('%', '\\25'), + 'a BMP private-use character': ('\ueabc', '\\eabc'), + 'a supplementary private-use character': ('\u{fabcd}', '\\fabcd'), + }; + + group("at the very beginning that's", () { + for (var (name, (char, escape)) in chars.pairs) { + test(name, () => expect(char.toCssIdentifier(), equals('$escape'))); + } + }); + + group("after a single hyphen that's", () { + for (var (name, (char, escape)) in chars.pairs) { + test(name, + () => expect('-$char'.toCssIdentifier(), equals('-$escape'))); + } + }); + }); + + group('a middle character', () { + const chars = { + 'non-alphanumeric ASCII': ('%', '\\25'), + 'a BMP private-use character': ('\ueabc', '\\eabc'), + 'a supplementary private-use character': ('\u{fabcd}', '\\fabcd'), + }; + + group("after a name start that's", () { + for (var (name, (char, escape)) in chars.pairs) { + test(name, + () => expect('a$char'.toCssIdentifier(), equals('a$escape'))); + } + }); + + group("after a double hyphen that's", () { + for (var (name, (char, escape)) in chars.pairs) { + test(name, + () => expect('--$char'.toCssIdentifier(), equals('--$escape'))); + } + }); + }); + }); + + group('throws an error for', () { + test('the empty string', + () => expect(''.toCssIdentifier, throwsFormatException)); + + const chars = { + 'zero': '\u0000', + 'single high surrogate': '\udabc', + 'single low surrogate': '\udcde', + }; + + group("a starting character that's", () { + for (var (name, char) in chars.pairs) { + test(name, () => expect(char.toCssIdentifier, throwsFormatException)); + } + }); + + group("after a hyphen that's", () { + for (var (name, char) in chars.pairs) { + test(name, + () => expect('-$char'.toCssIdentifier, throwsFormatException)); + } + }); + + group("after a name start that's", () { + for (var (name, char) in chars.pairs) { + test(name, + () => expect('a$char'.toCssIdentifier, throwsFormatException)); + } + }); + + group("after a double hyphen that's", () { + for (var (name, char) in chars.pairs) { + test(name, + () => expect('--$char'.toCssIdentifier, throwsFormatException)); + } + }); + + group("before a body char that's", () { + for (var (name, char) in chars.pairs) { + test(name, + () => expect('a${char}b'.toCssIdentifier, throwsFormatException)); + } + }); + }); + + group('adds a space between an escape and', () { + test('a digit', () => expect(' 1'.toCssIdentifier(), '\\20 1')); + + test('a lowercase hex letter', + () => expect(' b'.toCssIdentifier(), '\\20 b')); + + test('an uppercase hex letter', + () => expect(' B'.toCssIdentifier(), '\\20 B')); + }); + + group('doesn\'t add a space between an escape and', () { + test( + 'the end of the string', () => expect(' '.toCssIdentifier(), '\\20')); + + test('a lowercase non-hex letter', + () => expect(' g'.toCssIdentifier(), '\\20g')); + + test('an uppercase non-hex letter', + () => expect(' G'.toCssIdentifier(), '\\20G')); + + test('a hyphen', () => expect(' -'.toCssIdentifier(), '\\20-')); + + test('a non-ascii character', + () => expect(' ä'.toCssIdentifier(), '\\20ä')); + + test('another escape', () => expect(' '.toCssIdentifier(), '\\20\\20')); + }); + }); +}