diff --git a/lib/src/js/parser.dart b/lib/src/js/parser.dart index 79d9a5cc9..582e733d7 100644 --- a/lib/src/js/parser.dart +++ b/lib/src/js/parser.dart @@ -88,6 +88,12 @@ void _updateAstPrototypes() { 'accept', (Expression self, ExpressionVisitor visitor) => self.accept(visitor)); + var arguments = ArgumentDeclaration([], bogusSpan); + getJSClass(arguments) + .defineGetter('arguments', (ArgumentDeclaration self) => self.arguments); + var function = FunctionRule('a', arguments, [], bogusSpan); + getJSClass(function) + .defineGetter('arguments', (FunctionRule self) => self.arguments); _addSupportsConditionToInterpolation(); diff --git a/pkg/sass-parser/lib/index.ts b/pkg/sass-parser/lib/index.ts index 99dfa5283..a3db171fd 100644 --- a/pkg/sass-parser/lib/index.ts +++ b/pkg/sass-parser/lib/index.ts @@ -20,6 +20,7 @@ export { ConfiguredVariableProps, ConfiguredVariableRaws, } from './src/configured-variable'; +export {Container} from './src/container'; export {AnyNode, Node, NodeProps, NodeType} from './src/node'; export {RawWithValue} from './src/raw-with-value'; export { @@ -55,6 +56,20 @@ export { InterpolationRaws, NewNodeForInterpolation, } from './src/interpolation'; +export { + NewParameters, + ParameterListObjectProps, + ParameterListProps, + ParameterListRaws, + ParameterList, +} from './src/parameter-list'; +export { + ParameterObjectProps, + ParameterRaws, + ParameterExpressionProps, + ParameterProps, + Parameter, +} from './src/parameter'; export { CssComment, CssCommentProps, @@ -79,6 +94,11 @@ export { ForwardRuleProps, ForwardRuleRaws, } from './src/statement/forward-rule'; +export { + FunctionRuleRaws, + FunctionRuleProps, + FunctionRule, +} from './src/statement/function-rule'; export { GenericAtRule, GenericAtRuleProps, 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 index 2b5609937..ec5132c40 100644 --- a/pkg/sass-parser/lib/src/__snapshots__/configured-variable.test.ts.snap +++ b/pkg/sass-parser/lib/src/__snapshots__/configured-variable.test.ts.snap @@ -11,9 +11,9 @@ exports[`a configured variable toJSON 1`] = ` "id": "", }, ], + "name": "baz", "raws": {}, "sassType": "configured-variable", "source": <1:18-1:29 in 0>, - "variableName": "baz", } `; diff --git a/pkg/sass-parser/lib/src/__snapshots__/parameter-list.test.ts.snap b/pkg/sass-parser/lib/src/__snapshots__/parameter-list.test.ts.snap new file mode 100644 index 000000000..2c93129bc --- /dev/null +++ b/pkg/sass-parser/lib/src/__snapshots__/parameter-list.test.ts.snap @@ -0,0 +1,20 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`a parameter list toJSON 1`] = ` +{ + "inputs": [ + { + "css": "@function x($foo, $bar...) {}", + "hasBOM": false, + "id": "", + }, + ], + "nodes": [ + <$foo>, + ], + "raws": {}, + "restParameter": "bar", + "sassType": "parameter-list", + "source": <1:12-1:27 in 0>, +} +`; diff --git a/pkg/sass-parser/lib/src/__snapshots__/parameter.test.ts.snap b/pkg/sass-parser/lib/src/__snapshots__/parameter.test.ts.snap new file mode 100644 index 000000000..5889a1b3e --- /dev/null +++ b/pkg/sass-parser/lib/src/__snapshots__/parameter.test.ts.snap @@ -0,0 +1,34 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`a parameter toJSON with a default 1`] = ` +{ + "defaultValue": <"qux">, + "inputs": [ + { + "css": "@function x($baz: "qux") {}", + "hasBOM": false, + "id": "", + }, + ], + "name": "baz", + "raws": {}, + "sassType": "parameter", + "source": <1:13-1:24 in 0>, +} +`; + +exports[`a parameter toJSON with no default 1`] = ` +{ + "inputs": [ + { + "css": "@function x($baz) {}", + "hasBOM": false, + "id": "", + }, + ], + "name": "baz", + "raws": {}, + "sassType": "parameter", + "source": <1:13-1:17 in 0>, +} +`; diff --git a/pkg/sass-parser/lib/src/configuration.test.ts b/pkg/sass-parser/lib/src/configuration.test.ts index e0f88aa6d..5882d64ee 100644 --- a/pkg/sass-parser/lib/src/configuration.test.ts +++ b/pkg/sass-parser/lib/src/configuration.test.ts @@ -76,7 +76,7 @@ describe('a configuration map', () => { it('contains the variable', () => { expect(node.size).toBe(1); const variable = [...node.variables()][0]; - expect(variable.variableName).toEqual('bar'); + expect(variable.name).toEqual('bar'); expect(variable).toHaveStringExpression('expression', 'baz'); }); }); @@ -101,9 +101,7 @@ describe('a configuration map', () => { 'variables array', () => new Configuration({ - variables: [ - {variableName: 'bar', expression: {text: 'baz', quotes: true}}, - ], + variables: [{name: 'bar', expression: {text: 'baz', quotes: true}}], }), ); @@ -127,7 +125,7 @@ describe('a configuration map', () => { describe('add()', () => { test('with a ConfiguredVariable', () => { const variable = new ConfiguredVariable({ - variableName: 'foo', + name: 'foo', expression: {text: 'bar', quotes: true}, }); expect(node.add(variable)).toBe(node); @@ -137,20 +135,20 @@ describe('a configuration map', () => { }); test('with a ConfiguredVariableProps', () => { - node.add({variableName: 'foo', expression: {text: 'bar', quotes: true}}); + node.add({name: 'foo', expression: {text: 'bar', quotes: true}}); expect(node.size).toBe(1); const variable = node.get('foo'); - expect(variable?.variableName).toBe('foo'); + expect(variable?.name).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}}); + node.add({name: '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}}); + node.add({name: 'foo', expression: {text: 'new', quotes: true}}); expect(node.size).toBe(1); expect(old?.parent).toBeUndefined(); expect(node.get('foo')).toHaveStringExpression('expression', 'new'); @@ -158,8 +156,8 @@ describe('a configuration map', () => { }); test('clear() removes all variables', () => { - node.add({variableName: 'foo', expression: {text: 'bar', quotes: true}}); - node.add({variableName: 'baz', expression: {text: 'bang', quotes: true}}); + node.add({name: 'foo', expression: {text: 'bar', quotes: true}}); + node.add({name: 'baz', expression: {text: 'bang', quotes: true}}); const foo = node.get('foo'); const bar = node.get('bar'); node.clear(); @@ -172,8 +170,8 @@ describe('a configuration map', () => { describe('delete()', () => { beforeEach(() => { - node.add({variableName: 'foo', expression: {text: 'bar', quotes: true}}); - node.add({variableName: 'baz', expression: {text: 'bang', quotes: true}}); + node.add({name: 'foo', expression: {text: 'bar', quotes: true}}); + node.add({name: 'baz', expression: {text: 'bang', quotes: true}}); }); test('removes a matching variable', () => { @@ -192,12 +190,12 @@ describe('a configuration map', () => { describe('get()', () => { beforeEach(() => { - node.add({variableName: 'foo', expression: {text: 'bar', quotes: true}}); + node.add({name: '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?.name).toBe('foo'); expect(variable).toHaveStringExpression('expression', 'bar'); }); @@ -208,7 +206,7 @@ describe('a configuration map', () => { describe('has()', () => { beforeEach(() => { - node.add({variableName: 'foo', expression: {text: 'bar', quotes: true}}); + node.add({name: 'foo', expression: {text: 'bar', quotes: true}}); }); test('returns true for a variable in the configuration', () => @@ -220,7 +218,7 @@ describe('a configuration map', () => { describe('set()', () => { beforeEach(() => { - node.add({variableName: 'foo', expression: {text: 'bar', quotes: true}}); + node.add({name: 'foo', expression: {text: 'bar', quotes: true}}); }); describe('adds a new variable', () => { @@ -233,7 +231,7 @@ describe('a configuration map', () => { expect(node.size).toBe(2); const variable = node.get('baz'); expect(variable?.parent).toBe(node); - expect(variable?.variableName).toBe('baz'); + expect(variable?.name).toBe('baz'); expect(variable).toHaveStringExpression('expression', 'bang'); }); } @@ -285,7 +283,7 @@ describe('a configuration map', () => { }).toString(), ).toBe('($foo: "bar", $baz: "bang",)')); - it('with comma: true and afterValue', () => + it('with comma: true and after', () => expect( new Configuration({ raws: {comma: true}, @@ -293,7 +291,7 @@ describe('a configuration map', () => { foo: {text: 'bar', quotes: true}, baz: { expression: {text: 'bang', quotes: true}, - raws: {afterValue: '/**/'}, + raws: {after: '/**/'}, }, }, }).toString(), @@ -310,7 +308,7 @@ describe('a configuration map', () => { }).toString(), ).toBe('($foo: "bar", $baz: "bang"/**/)')); - it('with after and afterValue', () => + it('with after and after', () => expect( new Configuration({ raws: {after: '/**/'}, @@ -318,20 +316,20 @@ describe('a configuration map', () => { foo: {text: 'bar', quotes: true}, baz: { expression: {text: 'bang', quotes: true}, - raws: {afterValue: ' '}, + raws: {after: ' '}, }, }, }).toString(), ).toBe('($foo: "bar", $baz: "bang" /**/)')); - it('with afterValue and a guard', () => + it('with after and a guard', () => expect( new Configuration({ variables: { foo: {text: 'bar', quotes: true}, baz: { expression: {text: 'bang', quotes: true}, - raws: {afterValue: '/**/'}, + raws: {after: '/**/'}, guarded: true, }, }, @@ -359,10 +357,10 @@ describe('a configuration map', () => { it('variables', () => { expect(clone.size).toBe(2); const variables = [...clone.variables()]; - expect(variables[0]?.variableName).toBe('foo'); + expect(variables[0]?.name).toBe('foo'); expect(variables[0]?.parent).toBe(clone); expect(variables[0]).toHaveStringExpression('expression', 'bar'); - expect(variables[1]?.variableName).toBe('baz'); + expect(variables[1]?.name).toBe('baz'); expect(variables[1]?.parent).toBe(clone); expect(variables[1]).toHaveStringExpression('expression', 'bang'); }); @@ -399,7 +397,7 @@ describe('a configuration map', () => { }); expect(clone.size).toBe(1); const variables = [...clone.variables()]; - expect(variables[0]?.variableName).toBe('zip'); + expect(variables[0]?.name).toBe('zip'); expect(variables[0]?.parent).toBe(clone); expect(variables[0]).toHaveStringExpression('expression', 'zap'); }); @@ -408,10 +406,10 @@ describe('a configuration map', () => { const clone = original.clone({variables: undefined}); expect(clone.size).toBe(2); const variables = [...clone.variables()]; - expect(variables[0]?.variableName).toBe('foo'); + expect(variables[0]?.name).toBe('foo'); expect(variables[0]?.parent).toBe(clone); expect(variables[0]).toHaveStringExpression('expression', 'bar'); - expect(variables[1]?.variableName).toBe('baz'); + expect(variables[1]?.name).toBe('baz'); expect(variables[1]?.parent).toBe(clone); expect(variables[1]).toHaveStringExpression('expression', 'bang'); }); diff --git a/pkg/sass-parser/lib/src/configuration.ts b/pkg/sass-parser/lib/src/configuration.ts index e9c025563..82a76d2a3 100644 --- a/pkg/sass-parser/lib/src/configuration.ts +++ b/pkg/sass-parser/lib/src/configuration.ts @@ -44,6 +44,9 @@ export interface ConfigurationProps { | Array; } +// TODO: This should probably implement a similar interface to `ParameterList` +// as well as or instead of its current map-like interface. + /** * A configuration map for a `@use` or `@forward` rule. * @@ -101,9 +104,9 @@ export class Configuration extends Node { const realVariable = 'sassType' in variable ? variable : new ConfiguredVariable(variable); realVariable.parent = this; - const old = this._variables.get(realVariable.variableName); + const old = this._variables.get(realVariable.name); if (old) old.parent = undefined; - this._variables.set(realVariable.variableName, realVariable); + this._variables.set(realVariable.name, realVariable); return this; } @@ -189,7 +192,7 @@ export class Configuration extends Node { result += variable.raws.before ?? ' '; } result += variable.toString(); - result += variable.raws.afterValue ?? ''; + result += variable.raws.after ?? ''; } return result + `${this.raws.comma ? ',' : ''}${this.raws.after ?? ''})`; } diff --git a/pkg/sass-parser/lib/src/configured-variable.test.ts b/pkg/sass-parser/lib/src/configured-variable.test.ts index c673670a8..1ea3e6cc4 100644 --- a/pkg/sass-parser/lib/src/configured-variable.test.ts +++ b/pkg/sass-parser/lib/src/configured-variable.test.ts @@ -9,7 +9,7 @@ describe('a configured variable', () => { beforeEach( () => void (node = new ConfiguredVariable({ - variableName: 'foo', + name: 'foo', expression: {text: 'bar', quotes: true}, })), ); @@ -25,7 +25,7 @@ describe('a configured variable', () => { it('has a sassType', () => expect(node.sassType.toString()).toBe('configured-variable')); - it('has a name', () => expect(node.variableName).toBe('foo')); + it('has a name', () => expect(node.name).toBe('foo')); it('has a value', () => expect(node).toHaveStringExpression('expression', 'bar')); @@ -92,7 +92,7 @@ describe('a configured variable', () => { 'with an expression', () => new ConfiguredVariable({ - variableName: 'foo', + name: 'foo', expression: new StringExpression({text: 'bar', quotes: true}), }), ); @@ -101,7 +101,7 @@ describe('a configured variable', () => { 'with ExpressionProps', () => new ConfiguredVariable({ - variableName: 'foo', + name: 'foo', expression: {text: 'bar', quotes: true}, }), ); @@ -120,7 +120,7 @@ describe('a configured variable', () => { it('has a sassType', () => expect(node.sassType.toString()).toBe('configured-variable')); - it('has a name', () => expect(node.variableName).toBe('foo')); + it('has a name', () => expect(node.name).toBe('foo')); it('has a value', () => expect(node).toHaveStringExpression('expression', 'bar')); @@ -177,7 +177,7 @@ describe('a configured variable', () => { 'with an expression', () => new ConfiguredVariable({ - variableName: 'foo', + name: 'foo', expression: new StringExpression({text: 'bar', quotes: true}), guarded: true, }), @@ -187,7 +187,7 @@ describe('a configured variable', () => { 'with ExpressionProps', () => new ConfiguredVariable({ - variableName: 'foo', + name: 'foo', expression: {text: 'bar', quotes: true}, guarded: true, }), @@ -196,9 +196,9 @@ describe('a configured variable', () => { }); }); - it('assigned a new variableName', () => { - node.variableName = 'baz'; - expect(node.variableName).toBe('baz'); + it('assigned a new name', () => { + node.name = 'baz'; + expect(node.name).toBe('baz'); }); it('assigned a new expression', () => { @@ -219,7 +219,7 @@ describe('a configured variable', () => { it('unguarded', () => expect( new ConfiguredVariable({ - variableName: 'foo', + name: 'foo', expression: {text: 'bar', quotes: true}, }).toString(), ).toBe('$foo: "bar"')); @@ -227,7 +227,7 @@ describe('a configured variable', () => { it('guarded', () => expect( new ConfiguredVariable({ - variableName: 'foo', + name: 'foo', expression: {text: 'bar', quotes: true}, guarded: true, }).toString(), @@ -236,7 +236,7 @@ describe('a configured variable', () => { it('with a non-identifier name', () => expect( new ConfiguredVariable({ - variableName: 'f o', + name: 'f o', expression: {text: 'bar', quotes: true}, }).toString(), ).toBe('$f\\20o: "bar"')); @@ -246,7 +246,7 @@ describe('a configured variable', () => { it('ignores before', () => expect( new ConfiguredVariable({ - variableName: 'foo', + name: 'foo', expression: {text: 'bar', quotes: true}, raws: {before: '/**/'}, }).toString(), @@ -255,25 +255,25 @@ describe('a configured variable', () => { it('with matching name', () => expect( new ConfiguredVariable({ - variableName: 'foo', + name: 'foo', expression: {text: 'bar', quotes: true}, - raws: {variableName: {raw: 'f\\6fo', value: 'foo'}}, + raws: {name: {raw: 'f\\6fo', value: 'foo'}}, }).toString(), ).toBe('$f\\6fo: "bar"')); it('with non-matching name', () => expect( new ConfiguredVariable({ - variableName: 'foo', + name: 'foo', expression: {text: 'bar', quotes: true}, - raws: {variableName: {raw: 'f\\41o', value: 'fao'}}, + raws: {name: {raw: 'f\\41o', value: 'fao'}}, }).toString(), ).toBe('$foo: "bar"')); it('with between', () => expect( new ConfiguredVariable({ - variableName: 'foo', + name: 'foo', expression: {text: 'bar', quotes: true}, raws: {between: ' : '}, }).toString(), @@ -282,7 +282,7 @@ describe('a configured variable', () => { it('with beforeGuard and a guard', () => expect( new ConfiguredVariable({ - variableName: 'foo', + name: 'foo', expression: {text: 'bar', quotes: true}, guarded: true, raws: {beforeGuard: '/**/'}, @@ -292,30 +292,30 @@ describe('a configured variable', () => { it('with beforeGuard and no guard', () => expect( new ConfiguredVariable({ - variableName: 'foo', + name: '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', () => { + // raws.after is only used as part of a Configuration + describe('ignores after', () => { it('with no guard', () => expect( new ConfiguredVariable({ - variableName: 'foo', + name: 'foo', expression: {text: 'bar', quotes: true}, - raws: {afterValue: '/**/'}, + raws: {after: '/**/'}, }).toString(), ).toBe('$foo: "bar"')); it('with a guard', () => expect( new ConfiguredVariable({ - variableName: 'foo', + name: 'foo', expression: {text: 'bar', quotes: true}, guarded: true, - raws: {afterValue: '/**/'}, + raws: {after: '/**/'}, }).toString(), ).toBe('$foo: "bar" !default')); }); @@ -336,7 +336,7 @@ describe('a configured variable', () => { beforeEach(() => void (clone = original.clone())); describe('has the same properties:', () => { - it('variableName', () => expect(clone.variableName).toBe('foo')); + it('name', () => expect(clone.name).toBe('foo')); it('expression', () => expect(clone).toHaveStringExpression('expression', 'bar')); @@ -366,16 +366,12 @@ describe('a configured variable', () => { })); }); - describe('variableName', () => { + describe('name', () => { it('defined', () => - expect(original.clone({variableName: 'baz'}).variableName).toBe( - 'baz', - )); + expect(original.clone({name: 'baz'}).name).toBe('baz')); it('undefined', () => - expect(original.clone({variableName: undefined}).variableName).toBe( - 'foo', - )); + expect(original.clone({name: undefined}).name).toBe('foo')); }); describe('expression', () => { diff --git a/pkg/sass-parser/lib/src/configured-variable.ts b/pkg/sass-parser/lib/src/configured-variable.ts index 17dcea6f7..7565ad056 100644 --- a/pkg/sass-parser/lib/src/configured-variable.ts +++ b/pkg/sass-parser/lib/src/configured-variable.ts @@ -29,7 +29,7 @@ export interface ConfiguredVariableRaws { * This may be different than {@link ConfiguredVariable.variable} if the name * contains escape codes or underscores. */ - variableName?: RawWithValue; + name?: RawWithValue; /** The whitespace and colon between the variable name and value. */ between?: string; @@ -44,7 +44,7 @@ export interface ConfiguredVariableRaws { * 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; + after?: string; } /** @@ -55,7 +55,7 @@ export interface ConfiguredVariableRaws { */ export interface ConfiguredVariableObjectProps { raws?: ConfiguredVariableRaws; - variableName: string; + name: string; expression: Expression | ExpressionProps; guarded?: boolean; } @@ -72,7 +72,7 @@ export interface ConfiguredVariableObjectProps { export type ConfiguredVariableExpressionProps = | Expression | ExpressionProps - | Omit; + | Omit; /** * The initializer properties for {@link ConfiguredVariable}. @@ -100,7 +100,7 @@ export class ConfiguredVariable extends Node { * This is the parsed and normalized value, with underscores converted to * hyphens and escapes resolved to the characters they represent. */ - variableName!: string; + name!: string; /** The expresison whose value the variable is assigned. */ get expression(): Expression { @@ -125,14 +125,14 @@ export class ConfiguredVariable extends Node { inner?: sassInternal.ConfiguredVariable, ) { if (Array.isArray(defaults!)) { - const [variableName, rest] = defaults; + const [name, rest] = defaults; if ('sassType' in rest || !('expression' in rest)) { defaults = { - variableName, + name, expression: rest as Expression | ExpressionProps, }; } else { - defaults = {variableName, ...rest}; + defaults = {name, ...rest}; } } super(defaults); @@ -140,7 +140,7 @@ export class ConfiguredVariable extends Node { if (inner) { this.source = new LazySource(inner); - this.variableName = inner.name; + this.name = inner.name; this.expression = convertExpression(inner.expression); this.guarded = inner.isGuarded; } else { @@ -151,7 +151,7 @@ export class ConfiguredVariable extends Node { clone(overrides?: Partial): this { return utils.cloneNode(this, overrides, [ 'raws', - 'variableName', + 'name', 'expression', 'guarded', ]); @@ -161,20 +161,16 @@ export class ConfiguredVariable extends Node { /** @hidden */ toJSON(_: string, inputs: Map): object; toJSON(_?: string, inputs?: Map): object { - return utils.toJSON( - this, - ['variableName', 'expression', 'guarded'], - inputs, - ); + return utils.toJSON(this, ['name', 'expression', 'guarded'], inputs); } /** @hidden */ toString(): string { return ( '$' + - (this.raws.variableName?.value === this.variableName - ? this.raws.variableName.raw - : sassInternal.toCssIdentifier(this.variableName)) + + (this.raws.name?.value === this.name + ? this.raws.name.raw + : sassInternal.toCssIdentifier(this.name)) + (this.raws.between ?? ': ') + this.expression + (this.guarded ? `${this.raws.beforeGuard ?? ' '}!default` : '') diff --git a/pkg/sass-parser/lib/src/container.ts b/pkg/sass-parser/lib/src/container.ts new file mode 100644 index 000000000..035aeaeec --- /dev/null +++ b/pkg/sass-parser/lib/src/container.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. + +// Used in TypeDoc +// eslint-disable-next-line @typescript-eslint/no-unused-vars +import type * as postcss from 'postcss'; + +// Used in TypeDoc +// eslint-disable-next-line @typescript-eslint/no-unused-vars +import type {Interpolation} from './interpolation'; + +/** + * A Sass AST container. While this tries to maintain the general shape of the + * {@link postcss.Container} interface, it's more broadly used to contain + * other node types (and even strings in the case of {@link Interpolation}. + * + * @typeParam Child - The type of child nodes that this container can contain. + * @typeParam NewChild - The type of values that can be passed in to create one + * or more new child nodes for this container. + */ +export interface Container { + /** + * The nodes in this container. + * + * This shouldn't be modified directly; instead, the various methods defined + * in {@link Container} should be used to modify it. + */ + get nodes(): ReadonlyArray; + + /** Inserts new nodes at the end of this interpolation. */ + append(...nodes: NewChild[]): this; + + /** + * Iterates through {@link nodes}, calling `callback` for each child. + * + * Returning `false` in the callback will break iteration. + * + * Unlike a `for` loop or `Array#forEach`, this iterator is safe to use while + * modifying the interpolation's children. + * + * @param callback The iterator callback, which is passed each child + * @return Returns `false` if any call to `callback` returned false + */ + each( + callback: (node: Child, index: number) => false | void, + ): false | undefined; + + /** + * Returns `true` if {@link condition} returns `true` for all of the + * container’s children. + */ + every( + condition: ( + node: Child, + index: number, + nodes: ReadonlyArray, + ) => boolean, + ): boolean; + + /** + * Returns the first index of {@link child} in {@link nodes}. + * + * If {@link child} is a number, returns it as-is. + */ + index(child: Child | number): number; + + /** + * Inserts {@link newNode} immediately after the first occurance of + * {@link oldNode} in {@link nodes}. + * + * If {@link oldNode} is a number, inserts {@link newNode} immediately after + * that index instead. + */ + insertAfter(oldNode: Child | number, newNode: NewChild): this; + + /** + * Inserts {@link newNode} immediately before the first occurance of + * {@link oldNode} in {@link nodes}. + * + * If {@link oldNode} is a number, inserts {@link newNode} at that index + * instead. + */ + insertBefore(oldNode: Child | number, newNode: NewChild): this; + + /** Inserts {@link nodes} at the beginning of the container. */ + prepend(...nodes: NewChild[]): this; + + /** Adds {@link child} to the end of this interpolation. */ + push(child: Child): this; + + /** + * Removes all {@link nodes} from this container and cleans their {@link + * Node.parent} properties. + */ + removeAll(): this; + + /** + * Removes the first occurance of {@link child} from the container and cleans + * the parent properties from the node and its children. + * + * If {@link child} is a number, removes the child at that index. + */ + removeChild(child: Child | number): this; + + /** + * Returns `true` if {@link condition} returns `true` for (at least) one of + * the container’s children. + */ + some( + condition: ( + node: Child, + index: number, + nodes: ReadonlyArray, + ) => boolean, + ): boolean; + + /** The first node in {@link nodes}. */ + get first(): Child | undefined; + + /** + * The container’s last child. + * + * ```js + * rule.last === rule.nodes[rule.nodes.length - 1] + * ``` + */ + get last(): Child | undefined; +} diff --git a/pkg/sass-parser/lib/src/interpolation.test.ts b/pkg/sass-parser/lib/src/interpolation.test.ts index fd0b81e29..9675e8dc5 100644 --- a/pkg/sass-parser/lib/src/interpolation.test.ts +++ b/pkg/sass-parser/lib/src/interpolation.test.ts @@ -430,7 +430,7 @@ describe('an interpolation', () => { it("removes a node's parents", () => { const string = node.nodes[1]; - node.removeAll(); + node.removeChild(1); expect(string).toHaveProperty('parent', undefined); }); diff --git a/pkg/sass-parser/lib/src/interpolation.ts b/pkg/sass-parser/lib/src/interpolation.ts index c051decc8..5d5775fcf 100644 --- a/pkg/sass-parser/lib/src/interpolation.ts +++ b/pkg/sass-parser/lib/src/interpolation.ts @@ -4,6 +4,7 @@ import * as postcss from 'postcss'; +import {Container} from './container'; import {convertExpression} from './expression/convert'; import {fromProps} from './expression/from-props'; import {Expression, ExpressionProps} from './expression'; @@ -16,6 +17,9 @@ import * as utils from './utils'; /** * The type of new nodes that can be passed into an interpolation. * + * Note that unlike in PostCSS, a `string` here is treated as a raw string for + * interpolation rather than parsed as an expression. + * * @category Expression */ export type NewNodeForInterpolation = @@ -76,7 +80,10 @@ export interface InterpolationRaws { * * @category Expression */ -export class Interpolation extends Node { +export class Interpolation + extends Node + implements Container +{ readonly sassType = 'interpolation' as const; declare raws: InterpolationRaws; @@ -150,31 +157,12 @@ export class Interpolation extends Node { return utils.toJSON(this, ['nodes'], inputs); } - /** - * Inserts new nodes at the end of this interpolation. - * - * Note: unlike PostCSS's [`Container.append()`], this treats strings as raw - * text rather than parsing them into new nodes. - * - * [`Container.append()`]: https://postcss.org/api/#container-append - */ append(...nodes: NewNodeForInterpolation[]): this { // TODO - postcss/postcss#1957: Mark this as dirty this._nodes!.push(...this._normalizeList(nodes)); return this; } - /** - * Iterates through {@link nodes}, calling `callback` for each child. - * - * Returning `false` in the callback will break iteration. - * - * Unlike a `for` loop or `Array#forEach`, this iterator is safe to use while - * modifying the interpolation's children. - * - * @param callback The iterator callback, which is passed each child - * @return Returns `false` if any call to `callback` returned false - */ each( callback: (node: string | Expression, index: number) => false | void, ): false | undefined { @@ -193,10 +181,6 @@ export class Interpolation extends Node { } } - /** - * Returns `true` if {@link condition} returns `true` for all of the - * container’s children. - */ every( condition: ( node: string | Expression, @@ -207,22 +191,10 @@ export class Interpolation extends Node { return this.nodes.every(condition); } - /** - * Returns the first index of {@link child} in {@link nodes}. - * - * If {@link child} is a number, returns it as-is. - */ index(child: string | Expression | number): number { return typeof child === 'number' ? child : this.nodes.indexOf(child); } - /** - * Inserts {@link newNode} immediately after the first occurance of - * {@link oldNode} in {@link nodes}. - * - * If {@link oldNode} is a number, inserts {@link newNode} immediately after - * that index instead. - */ insertAfter( oldNode: string | Expression | number, newNode: NewNodeForInterpolation, @@ -239,13 +211,6 @@ export class Interpolation extends Node { return this; } - /** - * Inserts {@link newNode} immediately before the first occurance of - * {@link oldNode} in {@link nodes}. - * - * If {@link oldNode} is a number, inserts {@link newNode} at that index - * instead. - */ insertBefore( oldNode: string | Expression | number, newNode: NewNodeForInterpolation, @@ -262,7 +227,6 @@ export class Interpolation extends Node { return this; } - /** Inserts {@link nodes} at the beginning of the interpolation. */ prepend(...nodes: NewNodeForInterpolation[]): this { // TODO - postcss/postcss#1957: Mark this as dirty const normalized = this._normalizeList(nodes); @@ -275,15 +239,10 @@ export class Interpolation extends Node { return this; } - /** Adds {@link child} to the end of this interpolation. */ push(child: string | Expression): this { return this.append(child); } - /** - * Removes all {@link nodes} from this interpolation and cleans their {@link - * Node.parent} properties. - */ removeAll(): this { // TODO - postcss/postcss#1957: Mark this as dirty for (const node of this.nodes) { @@ -293,15 +252,10 @@ export class Interpolation extends Node { return this; } - /** - * Removes the first occurance of {@link child} from the container and cleans - * the parent properties from the node and its children. - * - * If {@link child} is a number, removes the child at that index. - */ removeChild(child: string | Expression | number): this { // TODO - postcss/postcss#1957: Mark this as dirty const index = this.index(child); + child = this._nodes![index]; if (typeof child === 'object') child.parent = undefined; this._nodes!.splice(index, 1); @@ -312,10 +266,6 @@ export class Interpolation extends Node { return this; } - /** - * Returns `true` if {@link condition} returns `true` for (at least) one of - * the container’s children. - */ some( condition: ( node: string | Expression, @@ -326,18 +276,10 @@ export class Interpolation extends Node { return this.nodes.some(condition); } - /** The first node in {@link nodes}. */ get first(): string | Expression | undefined { return this.nodes[0]; } - /** - * The container’s last child. - * - * ```js - * rule.last === rule.nodes[rule.nodes.length - 1] - * ``` - */ get last(): string | Expression | undefined { return this.nodes[this.nodes.length - 1]; } diff --git a/pkg/sass-parser/lib/src/node.d.ts b/pkg/sass-parser/lib/src/node.d.ts index 11f8d9ae2..c28b36bec 100644 --- a/pkg/sass-parser/lib/src/node.d.ts +++ b/pkg/sass-parser/lib/src/node.d.ts @@ -23,7 +23,9 @@ export type NodeType = | ExpressionType | 'interpolation' | 'configuration' - | 'configured-variable'; + | 'configured-variable' + | 'parameter' + | 'parameter-list'; /** The constructor properties shared by all Sass AST nodes. */ export type NodeProps = postcss.NodeProps; diff --git a/pkg/sass-parser/lib/src/parameter-list.test.ts b/pkg/sass-parser/lib/src/parameter-list.test.ts new file mode 100644 index 000000000..2c2b5aa11 --- /dev/null +++ b/pkg/sass-parser/lib/src/parameter-list.test.ts @@ -0,0 +1,985 @@ +// 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 {FunctionRule, Parameter, ParameterList, sass, scss} from '..'; + +type EachFn = Parameters[0]; + +let node: ParameterList; +describe('a parameter list', () => { + describe('empty', () => { + function describeNode( + description: string, + create: () => ParameterList, + ): void { + describe(description, () => { + beforeEach(() => void (node = create())); + + it('has a sassType', () => + expect(node.sassType).toBe('parameter-list')); + + it('has no nodes', () => expect(node.nodes).toHaveLength(0)); + + it('has no rest parameter', () => + expect(node.restParameter).toBeUndefined()); + }); + } + + describeNode( + 'parsed as SCSS', + () => + (scss.parse('@function x() {}').nodes[0] as FunctionRule).parameters, + ); + + describeNode( + 'parsed as Sass', + () => (sass.parse('@function x()').nodes[0] as FunctionRule).parameters, + ); + + describe('constructed manually', () => { + describeNode('with no arguments', () => new ParameterList()); + + describeNode('with an array', () => new ParameterList([])); + + describeNode('with an object', () => new ParameterList({})); + + describeNode( + 'with an object with an array', + () => new ParameterList({nodes: []}), + ); + }); + + describe('constructed from properties', () => { + describeNode( + 'an object', + () => new FunctionRule({functionName: 'x', parameters: {}}).parameters, + ); + + describeNode( + 'an array', + () => new FunctionRule({functionName: 'x', parameters: []}).parameters, + ); + }); + }); + + describe('with an argument with no default', () => { + function describeNode( + description: string, + create: () => ParameterList, + ): void { + describe(description, () => { + beforeEach(() => void (node = create())); + + it('has a sassType', () => + expect(node.sassType).toBe('parameter-list')); + + it('has a node', () => { + expect(node.nodes.length).toBe(1); + expect(node.nodes[0].name).toBe('foo'); + expect(node.nodes[0].defaultValue).toBeUndefined(); + expect(node.nodes[0].parent).toBe(node); + }); + + it('has no rest parameter', () => + expect(node.restParameter).toBeUndefined()); + }); + } + + describeNode( + 'parsed as SCSS', + () => + (scss.parse('@function x($foo) {}').nodes[0] as FunctionRule) + .parameters, + ); + + describeNode( + 'parsed as Sass', + () => + (sass.parse('@function x($foo)').nodes[0] as FunctionRule).parameters, + ); + + describe('constructed manually', () => { + describe('with an array', () => { + describeNode('with a string', () => new ParameterList(['foo'])); + + describeNode( + 'with an object', + () => new ParameterList([{name: 'foo'}]), + ); + + describeNode( + 'with a Parameter', + () => new ParameterList([new Parameter('foo')]), + ); + }); + + describe('with an object', () => { + describeNode( + 'with a string', + () => new ParameterList({nodes: ['foo']}), + ); + + describeNode( + 'with an object', + () => new ParameterList({nodes: [{name: 'foo'}]}), + ); + + describeNode( + 'with a Parameter', + () => new ParameterList({nodes: [new Parameter('foo')]}), + ); + }); + }); + + describe('constructed from properties', () => { + describeNode( + 'an object', + () => + new FunctionRule({functionName: 'x', parameters: {nodes: ['foo']}}) + .parameters, + ); + + describeNode( + 'an array', + () => + new FunctionRule({functionName: 'x', parameters: ['foo']}).parameters, + ); + }); + }); + + describe('with an argument with a default', () => { + function describeNode( + description: string, + create: () => ParameterList, + ): void { + describe(description, () => { + beforeEach(() => void (node = create())); + + it('has a sassType', () => + expect(node.sassType).toBe('parameter-list')); + + it('has a node', () => { + expect(node.nodes.length).toBe(1); + expect(node.nodes[0].name).toBe('foo'); + expect(node.nodes[0]).toHaveStringExpression('defaultValue', 'bar'); + expect(node.nodes[0]).toHaveProperty('parent', node); + }); + + it('has no rest parameter', () => + expect(node.restParameter).toBeUndefined()); + }); + } + + describeNode( + 'parsed as SCSS', + () => + (scss.parse('@function x($foo: "bar") {}').nodes[0] as FunctionRule) + .parameters, + ); + + describeNode( + 'parsed as Sass', + () => + (sass.parse('@function x($foo: "bar")').nodes[0] as FunctionRule) + .parameters, + ); + + describe('constructed manually', () => { + describe('with an array', () => { + describeNode( + 'with a sub-array', + () => new ParameterList([['foo', {text: 'bar'}]]), + ); + + describeNode( + 'with an object', + () => new ParameterList([{name: 'foo', defaultValue: {text: 'bar'}}]), + ); + + describeNode( + 'with a Parameter', + () => + new ParameterList([ + new Parameter({name: 'foo', defaultValue: {text: 'bar'}}), + ]), + ); + }); + + describe('with an object', () => { + describeNode( + 'with a sub-array', + () => new ParameterList({nodes: [['foo', {text: 'bar'}]]}), + ); + + describeNode( + 'with an object', + () => + new ParameterList({ + nodes: [{name: 'foo', defaultValue: {text: 'bar'}}], + }), + ); + + describeNode( + 'with a Parameter', + () => + new ParameterList({ + nodes: [ + new Parameter({name: 'foo', defaultValue: {text: 'bar'}}), + ], + }), + ); + }); + }); + + describe('constructed from properties', () => { + describeNode( + 'an object', + () => + new FunctionRule({ + functionName: 'x', + parameters: {nodes: [['foo', {text: 'bar'}]]}, + }).parameters, + ); + + describeNode( + 'an array', + () => + new FunctionRule({ + functionName: 'x', + parameters: [['foo', {text: 'bar'}]], + }).parameters, + ); + }); + }); + + describe('with a rest parameter', () => { + function describeNode( + description: string, + create: () => ParameterList, + ): void { + describe(description, () => { + beforeEach(() => void (node = create())); + + it('has a sassType', () => + expect(node.sassType).toBe('parameter-list')); + + it('has no nodes', () => expect(node.nodes).toHaveLength(0)); + + it('has a rest parameter', () => + expect(node.restParameter).toBe('foo')); + }); + } + + describeNode( + 'parsed as SCSS', + () => + (scss.parse('@function x($foo...) {}').nodes[0] as FunctionRule) + .parameters, + ); + + describeNode( + 'parsed as Sass', + () => + (sass.parse('@function x($foo...)').nodes[0] as FunctionRule) + .parameters, + ); + + describeNode( + 'constructed manually', + () => new ParameterList({restParameter: 'foo'}), + ); + + describeNode( + 'constructed from properties', + () => + new FunctionRule({ + functionName: 'x', + parameters: {restParameter: 'foo'}, + }).parameters, + ); + }); + + it('assigned a new rest parameter', () => { + node.restParameter = 'qux'; + expect(node.restParameter).toBe('qux'); + }); + + describe('can add', () => { + beforeEach(() => void (node = new ParameterList())); + + it('a single parameter', () => { + const parameter = new Parameter('foo'); + node.append(parameter); + expect(node.nodes).toEqual([parameter]); + expect(parameter).toHaveProperty('parent', node); + }); + + it('a list of parameters', () => { + const foo = new Parameter('foo'); + const bar = new Parameter('bar'); + node.append([foo, bar]); + expect(node.nodes).toEqual([foo, bar]); + }); + + it('a single string', () => { + node.append('foo'); + expect(node.nodes[0]).toBeInstanceOf(Parameter); + expect(node.nodes[0].name).toBe('foo'); + expect(node.nodes[0].defaultValue).toBeUndefined(); + expect(node.nodes[0]).toHaveProperty('parent', node); + }); + + it('a string array', () => { + node.append(['foo']); + expect(node.nodes[0]).toBeInstanceOf(Parameter); + expect(node.nodes[0].name).toBe('foo'); + expect(node.nodes[0].defaultValue).toBeUndefined(); + expect(node.nodes[0]).toHaveProperty('parent', node); + }); + + it('a single pair', () => { + node.append(['foo', {text: 'bar'}]); + expect(node.nodes[0]).toBeInstanceOf(Parameter); + expect(node.nodes[0].name).toBe('foo'); + expect(node.nodes[0]).toHaveStringExpression('defaultValue', 'bar'); + expect(node.nodes[0]).toHaveProperty('parent', node); + }); + + it('a list of pairs', () => { + node.append([ + ['foo', {text: 'bar'}], + ['baz', {text: 'qux'}], + ]); + expect(node.nodes[0]).toBeInstanceOf(Parameter); + expect(node.nodes[0].name).toBe('foo'); + expect(node.nodes[0]).toHaveStringExpression('defaultValue', 'bar'); + expect(node.nodes[0]).toHaveProperty('parent', node); + expect(node.nodes[1]).toBeInstanceOf(Parameter); + expect(node.nodes[1].name).toBe('baz'); + expect(node.nodes[1]).toHaveStringExpression('defaultValue', 'qux'); + expect(node.nodes[1]).toHaveProperty('parent', node); + }); + + it("a single parameter's properties", () => { + node.append({name: 'foo'}); + expect(node.nodes[0]).toBeInstanceOf(Parameter); + expect(node.nodes[0].name).toBe('foo'); + expect(node.nodes[0].defaultValue).toBeUndefined(); + expect(node.nodes[0]).toHaveProperty('parent', node); + }); + + it("multiple parameters' properties", () => { + node.append([{name: 'foo'}, {name: 'bar'}]); + expect(node.nodes[0]).toBeInstanceOf(Parameter); + expect(node.nodes[0].name).toBe('foo'); + expect(node.nodes[0].defaultValue).toBeUndefined(); + expect(node.nodes[0]).toHaveProperty('parent', node); + expect(node.nodes[1]).toBeInstanceOf(Parameter); + expect(node.nodes[1].name).toBe('bar'); + expect(node.nodes[1].defaultValue).toBeUndefined(); + expect(node.nodes[1]).toHaveProperty('parent', node); + }); + + it('undefined', () => { + node.append(undefined); + expect(node.nodes).toHaveLength(0); + }); + }); + + describe('append', () => { + beforeEach(() => void (node = new ParameterList(['foo', 'bar']))); + + it('adds multiple children to the end', () => { + node.append('baz', 'qux'); + expect(node.nodes[0].name).toBe('foo'); + expect(node.nodes[1].name).toBe('bar'); + expect(node.nodes[2].name).toBe('baz'); + expect(node.nodes[3].name).toBe('qux'); + }); + + it('can be called during iteration', () => + testEachMutation(['foo', 'bar', 'baz'], 0, () => node.append('baz'))); + + it('returns itself', () => expect(node.append()).toBe(node)); + }); + + describe('each', () => { + beforeEach(() => void (node = new ParameterList(['foo', 'bar']))); + + it('calls the callback for each node', () => { + const fn: EachFn = jest.fn(); + node.each(fn); + expect(fn).toHaveBeenCalledTimes(2); + expect(fn).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({name: 'foo'}), + 0, + ); + expect(fn).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({name: 'bar'}), + 1, + ); + }); + + it('returns undefined if the callback is void', () => + expect(node.each(() => {})).toBeUndefined()); + + it('returns false and stops iterating if the callback returns false', () => { + const fn: EachFn = jest.fn(() => false); + expect(node.each(fn)).toBe(false); + expect(fn).toHaveBeenCalledTimes(1); + }); + }); + + describe('every', () => { + beforeEach(() => void (node = new ParameterList(['foo', 'bar', 'baz']))); + + it('returns true if the callback returns true for all elements', () => + expect(node.every(() => true)).toBe(true)); + + it('returns false if the callback returns false for any element', () => + expect(node.every(element => element.name !== 'bar')).toBe(false)); + }); + + describe('index', () => { + beforeEach(() => void (node = new ParameterList(['foo', 'bar', 'baz']))); + + it('returns the first index of a given parameter', () => + expect(node.index(node.nodes[2])).toBe(2)); + + it('returns a number as-is', () => expect(node.index(3)).toBe(3)); + }); + + describe('insertAfter', () => { + beforeEach( + () => void (node = new ParameterList({nodes: ['foo', 'bar', 'baz']})), + ); + + it('inserts a node after the given element', () => { + node.insertAfter(node.nodes[1], 'qux'); + expect(node.nodes[0].name).toBe('foo'); + expect(node.nodes[1].name).toBe('bar'); + expect(node.nodes[2].name).toBe('qux'); + expect(node.nodes[3].name).toBe('baz'); + }); + + it('inserts a node at the beginning', () => { + node.insertAfter(-1, 'qux'); + expect(node.nodes[0].name).toBe('qux'); + expect(node.nodes[1].name).toBe('foo'); + expect(node.nodes[2].name).toBe('bar'); + expect(node.nodes[3].name).toBe('baz'); + }); + + it('inserts a node at the end', () => { + node.insertAfter(3, 'qux'); + expect(node.nodes[0].name).toBe('foo'); + expect(node.nodes[1].name).toBe('bar'); + expect(node.nodes[2].name).toBe('baz'); + expect(node.nodes[3].name).toBe('qux'); + }); + + it('inserts multiple nodes', () => { + node.insertAfter(1, ['qux', 'qax', 'qix']); + expect(node.nodes[0].name).toBe('foo'); + expect(node.nodes[1].name).toBe('bar'); + expect(node.nodes[2].name).toBe('qux'); + expect(node.nodes[3].name).toBe('qax'); + expect(node.nodes[4].name).toBe('qix'); + expect(node.nodes[5].name).toBe('baz'); + }); + + it('inserts before an iterator', () => + testEachMutation(['foo', 'bar', ['baz', 5]], 1, () => + node.insertAfter(0, ['qux', 'qax', 'qix']), + )); + + it('inserts after an iterator', () => + testEachMutation(['foo', 'bar', 'qux', 'qax', 'qix', 'baz'], 1, () => + node.insertAfter(1, ['qux', 'qax', 'qix']), + )); + + it('returns itself', () => expect(node.insertAfter(0, 'qux')).toBe(node)); + }); + + describe('insertBefore', () => { + beforeEach(() => void (node = new ParameterList(['foo', 'bar', 'baz']))); + + it('inserts a node before the given element', () => { + node.insertBefore(node.nodes[1], 'qux'); + expect(node.nodes[0].name).toBe('foo'); + expect(node.nodes[1].name).toBe('qux'); + expect(node.nodes[2].name).toBe('bar'); + expect(node.nodes[3].name).toBe('baz'); + }); + + it('inserts a node at the beginning', () => { + node.insertBefore(0, 'qux'); + expect(node.nodes[0].name).toBe('qux'); + expect(node.nodes[1].name).toBe('foo'); + expect(node.nodes[2].name).toBe('bar'); + expect(node.nodes[3].name).toBe('baz'); + }); + + it('inserts a node at the end', () => { + node.insertBefore(4, 'qux'); + expect(node.nodes[0].name).toBe('foo'); + expect(node.nodes[1].name).toBe('bar'); + expect(node.nodes[2].name).toBe('baz'); + expect(node.nodes[3].name).toBe('qux'); + }); + + it('inserts multiple nodes', () => { + node.insertBefore(1, ['qux', 'qax', 'qix']); + expect(node.nodes[0].name).toBe('foo'); + expect(node.nodes[1].name).toBe('qux'); + expect(node.nodes[2].name).toBe('qax'); + expect(node.nodes[3].name).toBe('qix'); + expect(node.nodes[4].name).toBe('bar'); + expect(node.nodes[5].name).toBe('baz'); + }); + + it('inserts before an iterator', () => + testEachMutation(['foo', 'bar', ['baz', 5]], 1, () => + node.insertBefore(1, ['qux', 'qax', 'qix']), + )); + + it('inserts after an iterator', () => + testEachMutation(['foo', 'bar', 'qux', 'qax', 'qix', 'baz'], 1, () => + node.insertBefore(2, ['qux', 'qax', 'qix']), + )); + + it('returns itself', () => expect(node.insertBefore(0, 'qux')).toBe(node)); + }); + + describe('prepend', () => { + beforeEach(() => void (node = new ParameterList(['foo', 'bar', 'baz']))); + + it('inserts one node', () => { + node.prepend('qux'); + expect(node.nodes[0].name).toBe('qux'); + expect(node.nodes[1].name).toBe('foo'); + expect(node.nodes[2].name).toBe('bar'); + expect(node.nodes[3].name).toBe('baz'); + }); + + it('inserts multiple nodes', () => { + node.prepend('qux', 'qax', 'qix'); + expect(node.nodes[0].name).toBe('qux'); + expect(node.nodes[1].name).toBe('qax'); + expect(node.nodes[2].name).toBe('qix'); + expect(node.nodes[3].name).toBe('foo'); + expect(node.nodes[4].name).toBe('bar'); + expect(node.nodes[5].name).toBe('baz'); + }); + + it('inserts before an iterator', () => + testEachMutation(['foo', 'bar', ['baz', 5]], 1, () => + node.prepend('qux', 'qax', 'qix'), + )); + + it('returns itself', () => expect(node.prepend('qux')).toBe(node)); + }); + + describe('push', () => { + beforeEach(() => void (node = new ParameterList(['foo', 'bar']))); + + it('inserts one node', () => { + node.push(new Parameter('baz')); + expect(node.nodes[0].name).toBe('foo'); + expect(node.nodes[1].name).toBe('bar'); + expect(node.nodes[2].name).toBe('baz'); + }); + + it('can be called during iteration', () => + testEachMutation(['foo', 'bar', 'baz'], 0, () => + node.push(new Parameter('baz')), + )); + + it('returns itself', () => + expect(node.push(new Parameter('baz'))).toBe(node)); + }); + + describe('removeAll', () => { + beforeEach(() => void (node = new ParameterList(['foo', 'bar', 'baz']))); + + it('removes all nodes', () => { + node.removeAll(); + expect(node.nodes).toHaveLength(0); + }); + + it("removes a node's parents", () => { + const child = node.nodes[1]; + node.removeAll(); + expect(child).toHaveProperty('parent', undefined); + }); + + it('can be called during iteration', () => + testEachMutation(['foo'], 0, () => node.removeAll())); + + it('returns itself', () => expect(node.removeAll()).toBe(node)); + }); + + describe('removeChild', () => { + beforeEach(() => void (node = new ParameterList(['foo', 'bar', 'baz']))); + + it('removes a matching node', () => { + node.removeChild(node.nodes[0]); + expect(node.nodes[0].name).toBe('bar'); + expect(node.nodes[1].name).toBe('baz'); + }); + + it('removes a node at index', () => { + node.removeChild(1); + expect(node.nodes[0].name).toBe('foo'); + expect(node.nodes[1].name).toBe('baz'); + }); + + it("removes a node's parents", () => { + const child = node.nodes[1]; + node.removeChild(1); + expect(child).toHaveProperty('parent', undefined); + }); + + it('removes a node before the iterator', () => + testEachMutation(['foo', 'bar', ['baz', 1]], 1, () => + node.removeChild(1), + )); + + it('removes a node after the iterator', () => + testEachMutation(['foo', 'bar'], 1, () => node.removeChild(2))); + + it('returns itself', () => expect(node.removeChild(0)).toBe(node)); + }); + + describe('some', () => { + beforeEach(() => void (node = new ParameterList(['foo', 'bar', 'baz']))); + + it('returns false if the callback returns false for all elements', () => + expect(node.some(() => false)).toBe(false)); + + it('returns true if the callback returns true for any element', () => + expect(node.some(element => element.name === 'bar')).toBe(true)); + }); + + describe('first', () => { + it('returns the first element', () => + expect(new ParameterList(['foo', 'bar', 'baz']).first!.name).toBe('foo')); + + it('returns undefined for an empty list', () => + expect(new ParameterList().first).toBeUndefined()); + }); + + describe('last', () => { + it('returns the last element', () => + expect(new ParameterList({nodes: ['foo', 'bar', 'baz']}).last!.name).toBe( + 'baz', + )); + + it('returns undefined for an empty list', () => + expect(new ParameterList().last).toBeUndefined()); + }); + + describe('stringifies', () => { + describe('with no nodes or rest parameter', () => { + it('with default raws', () => + expect(new ParameterList().toString()).toBe('()')); + + it('ignores restParameter', () => + expect( + new ParameterList({ + raws: {restParameter: {value: 'foo', raw: 'foo'}}, + }).toString(), + ).toBe('()')); + + it('ignores comma', () => + expect(new ParameterList({raws: {comma: true}}).toString()).toBe('()')); + + it('with after', () => + expect(new ParameterList({raws: {after: '/**/'}}).toString()).toBe( + '(/**/)', + )); + }); + + describe('with parameters', () => { + it('with default raws', () => + expect(new ParameterList(['foo', 'bar', 'baz']).toString()).toBe( + '($foo, $bar, $baz)', + )); + + it('ignores beforeRestParameter', () => + expect( + new ParameterList({ + nodes: ['foo', 'bar', 'baz'], + raws: {beforeRestParameter: '/**/'}, + }).toString(), + ).toBe('($foo, $bar, $baz)')); + + it('ignores restParameter', () => + expect( + new ParameterList({ + nodes: ['foo', 'bar', 'baz'], + raws: {restParameter: {value: 'foo', raw: 'foo'}}, + }).toString(), + ).toBe('($foo, $bar, $baz)')); + + it('with comma: true', () => + expect( + new ParameterList({ + nodes: ['foo', 'bar', 'baz'], + raws: {comma: true}, + }).toString(), + ).toBe('($foo, $bar, $baz,)')); + + describe('with after', () => { + it('with comma: false', () => + expect( + new ParameterList({ + nodes: ['foo', 'bar', 'baz'], + raws: {after: '/**/'}, + }).toString(), + ).toBe('($foo, $bar, $baz/**/)')); + + it('with comma: true', () => + expect( + new ParameterList({ + nodes: ['foo', 'bar', 'baz'], + raws: {comma: true, after: '/**/'}, + }).toString(), + ).toBe('($foo, $bar, $baz,/**/)')); + }); + + describe('with a parameter with after', () => { + it('with comma: false and no after', () => + expect( + new ParameterList({ + nodes: [ + 'foo', + 'bar', + new Parameter({name: 'baz', raws: {after: ' '}}), + ], + }).toString(), + ).toBe('($foo, $bar, $baz )')); + + it('with comma: false and after', () => + expect( + new ParameterList({ + nodes: [ + 'foo', + 'bar', + new Parameter({name: 'baz', raws: {after: ' '}}), + ], + raws: {after: '/**/'}, + }).toString(), + ).toBe('($foo, $bar, $baz /**/)')); + + it('with comma: true', () => + expect( + new ParameterList({ + nodes: [ + 'foo', + 'bar', + new Parameter({name: 'baz', raws: {after: ' '}}), + ], + raws: {comma: true}, + }).toString(), + ).toBe('($foo, $bar, $baz ,)')); + }); + }); + + describe('with restParameter', () => { + it('with default raws', () => + expect(new ParameterList({restParameter: 'foo'}).toString()).toBe( + '($foo...)', + )); + + it("that's not an identifier", () => + expect(new ParameterList({restParameter: 'f o'}).toString()).toBe( + '($f\\20o...)', + )); + + it('with parameters', () => + expect( + new ParameterList({ + nodes: ['foo', 'bar'], + restParameter: 'baz', + }).toString(), + ).toBe('($foo, $bar, $baz...)')); + + describe('with beforeRestParameter', () => { + it('with no parameters', () => + expect( + new ParameterList({ + restParameter: 'foo', + raws: {beforeRestParameter: '/**/'}, + }).toString(), + ).toBe('(/**/$foo...)')); + + it('with parameters', () => + expect( + new ParameterList({ + nodes: ['foo', 'bar'], + restParameter: 'baz', + raws: {beforeRestParameter: '/**/'}, + }).toString(), + ).toBe('($foo, $bar,/**/$baz...)')); + }); + + it('with matching restParameter', () => + expect( + new ParameterList({ + restParameter: 'foo', + raws: {restParameter: {value: 'foo', raw: 'f\\6fo'}}, + }).toString(), + ).toBe('($f\\6fo...)')); + + it('with non-matching restParameter', () => + expect( + new ParameterList({ + restParameter: 'foo', + raws: {restParameter: {value: 'bar', raw: 'b\\61r'}}, + }).toString(), + ).toBe('($foo...)')); + + it('ignores comma', () => + expect( + new ParameterList({ + restParameter: 'foo', + raws: {comma: true}, + }).toString(), + ).toBe('($foo...)')); + + it('with after', () => + expect( + new ParameterList({ + restParameter: 'foo', + raws: {after: '/**/'}, + }).toString(), + ).toBe('($foo.../**/)')); + }); + }); + + describe('clone', () => { + let original: ParameterList; + beforeEach( + () => + void (original = new ParameterList({ + nodes: ['foo', 'bar'], + restParameter: 'baz', + raws: {after: ' '}, + })), + ); + + describe('with no overrides', () => { + let clone: ParameterList; + beforeEach(() => void (clone = original.clone())); + + describe('has the same properties:', () => { + it('nodes', () => { + expect(clone.nodes[0].name).toBe('foo'); + expect(clone.nodes[0].parent).toBe(clone); + expect(clone.nodes[1].name).toBe('bar'); + expect(clone.nodes[1].parent).toBe(clone); + expect(clone.restParameter).toBe('baz'); + }); + + it('restParameter', () => expect(clone.restParameter).toBe('baz')); + + it('raws', () => expect(clone.raws).toEqual({after: ' '})); + + it('source', () => expect(clone.source).toBe(original.source)); + }); + + describe('creates a new', () => { + it('self', () => expect(clone).not.toBe(original)); + + for (const attr of ['raws', 'nodes'] as const) { + it(attr, () => expect(clone[attr]).not.toBe(original[attr])); + } + }); + + describe('sets parent for', () => { + it('nodes', () => + expect(clone.nodes[0]).toHaveProperty('parent', clone)); + }); + }); + + 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('nodes', () => { + it('defined', () => { + const clone = original.clone({nodes: ['qux']}); + expect(clone.nodes[0].name).toBe('qux'); + }); + + it('undefined', () => { + const clone = original.clone({nodes: undefined}); + expect(clone.nodes).toHaveLength(2); + expect(clone.nodes[0].name).toBe('foo'); + expect(clone.nodes[1].name).toBe('bar'); + }); + }); + + describe('restParameter', () => { + it('defined', () => + expect(original.clone({restParameter: 'qux'}).restParameter).toBe( + 'qux', + )); + + it('undefined', () => + expect( + original.clone({restParameter: undefined}).restParameter, + ).toBeUndefined()); + }); + }); + }); + + it('toJSON', () => + expect( + (scss.parse('@function x($foo, $bar...) {}').nodes[0] as FunctionRule) + .parameters, + ).toMatchSnapshot()); +}); + +/** + * Runs `node.each`, asserting that it sees a parameter with each name and index + * in {@link elements} in order. If an index isn't explicitly provided, it + * defaults to the index in {@link elements}. + * + * When it reaches {@link indexToModify}, it calls {@link modify}, which is + * expected to modify `node.nodes`. + */ +function testEachMutation( + elements: ([string, number] | string)[], + indexToModify: number, + modify: () => void, +): void { + const fn: EachFn = jest.fn((child, i) => { + if (i === indexToModify) modify(); + }); + node.each(fn); + + for (let i = 0; i < elements.length; i++) { + const element = elements[i]; + const [name, index] = Array.isArray(element) ? element : [element, i]; + expect(fn).toHaveBeenNthCalledWith( + i + 1, + expect.objectContaining({name}), + index, + ); + } + expect(fn).toHaveBeenCalledTimes(elements.length); +} diff --git a/pkg/sass-parser/lib/src/parameter-list.ts b/pkg/sass-parser/lib/src/parameter-list.ts new file mode 100644 index 000000000..ced94ca78 --- /dev/null +++ b/pkg/sass-parser/lib/src/parameter-list.ts @@ -0,0 +1,357 @@ +// 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 {Container} from './container'; +import {Parameter, ParameterProps} from './parameter'; +import {LazySource} from './lazy-source'; +import {Node} from './node'; +import {RawWithValue} from './raw-with-value'; +import * as sassInternal from './sass-internal'; +import * as utils from './utils'; + +/** + * The type of new nodes that can be passed into a parameter list, either a + * single parameter or multiple parameters. + * + * @category Statement + */ +export type NewParameters = + | Parameter + | ParameterProps + | ReadonlyArray + | undefined; + +/** + * The initializer properties for {@link ParameterList} passed as an options + * object. + * + * @category Statement + */ +export interface ParameterListObjectProps { + nodes?: ReadonlyArray; + restParameter?: string; + raws?: ParameterListRaws; +} + +/** + * The initializer properties for {@link ParameterList}. + * + * @category Statement + */ +export type ParameterListProps = + | ParameterListObjectProps + | ReadonlyArray; + +/** + * Raws indicating how to precisely serialize a {@link ParameterList} node. + * + * @category Statement + */ +export interface ParameterListRaws { + /** Whitespace before the rest parameter, if one exists. */ + beforeRestParameter?: string; + + /** + * The name of the rest parameter, if one exists. + * + * This may be different than {@link ParameterList.restParameter} if the name + * contains escape codes or underscores. + */ + restParameter?: RawWithValue; + + /** + * Whether the final parameter has a trailing comma. + * + * Ignored if {@link ParameterList.nodes} is empty or if + * {@link ParameterList.restParameter} is set. + */ + comma?: boolean; + + /** + * The whitespace between the final parameter (or its trailing comma if it has + * one) and the closing parenthesis. + */ + after?: string; +} + +/** + * A list of parameters, as in a `@function` or `@mixin` rule. + * + * @category Statement + */ +export class ParameterList + extends Node + implements Container +{ + readonly sassType = 'parameter-list' as const; + declare raws: ParameterListRaws; + + get nodes(): ReadonlyArray { + return this._nodes!; + } + /** @hidden */ + set nodes(nodes: Array) { + // This *should* only ever be called by the superclass constructor. + this._nodes = nodes; + } + private _nodes?: Array; + + /** + * The name of the rest parameter (such as `args` in `...$args`) in this + * parameter list. + * + * This is the parsed and normalized value, with underscores converted to + * hyphens and escapes resolved to the characters they represent. + */ + declare restParameter?: string; + + /** + * Iterators that are currently active within this parameter list. Their + * indices refer to the last position that has already been sent to the + * callback, and are updated when {@link _nodes} is modified. + */ + readonly #iterators: Array<{index: number}> = []; + + constructor(defaults?: ParameterListProps); + /** @hidden */ + constructor(_: undefined, inner: sassInternal.ArgumentDeclaration); + constructor(defaults?: object, inner?: sassInternal.ArgumentDeclaration) { + super(Array.isArray(defaults) ? {nodes: defaults} : defaults); + if (inner) { + this.source = new LazySource(inner); + // TODO: set lazy raws here to use when stringifying + this._nodes = []; + this.restParameter = inner.restArgument ?? undefined; + for (const argument of inner.arguments) { + this.append(new Parameter(undefined, argument)); + } + } + if (this._nodes === undefined) this._nodes = []; + } + + clone(overrides?: Partial): this { + return utils.cloneNode(this, overrides, [ + 'nodes', + {name: 'restParameter', explicitUndefined: true}, + 'raws', + ]); + } + + toJSON(): object; + /** @hidden */ + toJSON(_: string, inputs: Map): object; + toJSON(_?: string, inputs?: Map): object { + return utils.toJSON(this, ['nodes', 'restParameter'], inputs); + } + + append(...nodes: NewParameters[]): this { + // TODO - postcss/postcss#1957: Mark this as dirty + this._nodes!.push(...this._normalizeList(nodes)); + return this; + } + + each( + callback: (node: Parameter, index: number) => false | void, + ): false | undefined { + const iterator = {index: 0}; + this.#iterators.push(iterator); + + try { + while (iterator.index < this.nodes.length) { + const result = callback(this.nodes[iterator.index], iterator.index); + if (result === false) return false; + iterator.index += 1; + } + return undefined; + } finally { + this.#iterators.splice(this.#iterators.indexOf(iterator), 1); + } + } + + every( + condition: ( + node: Parameter, + index: number, + nodes: ReadonlyArray, + ) => boolean, + ): boolean { + return this.nodes.every(condition); + } + + index(child: Parameter | number): number { + return typeof child === 'number' ? child : this.nodes.indexOf(child); + } + + insertAfter(oldNode: Parameter | number, newNode: NewParameters): this { + // TODO - postcss/postcss#1957: Mark this as dirty + const index = this.index(oldNode); + const normalized = this._normalize(newNode); + this._nodes!.splice(index + 1, 0, ...normalized); + + for (const iterator of this.#iterators) { + if (iterator.index > index) iterator.index += normalized.length; + } + + return this; + } + + insertBefore(oldNode: Parameter | number, newNode: NewParameters): this { + // TODO - postcss/postcss#1957: Mark this as dirty + const index = this.index(oldNode); + const normalized = this._normalize(newNode); + this._nodes!.splice(index, 0, ...normalized); + + for (const iterator of this.#iterators) { + if (iterator.index >= index) iterator.index += normalized.length; + } + + return this; + } + + prepend(...nodes: NewParameters[]): this { + // TODO - postcss/postcss#1957: Mark this as dirty + const normalized = this._normalizeList(nodes); + this._nodes!.unshift(...normalized); + + for (const iterator of this.#iterators) { + iterator.index += normalized.length; + } + + return this; + } + + push(child: Parameter): this { + return this.append(child); + } + + removeAll(): this { + // TODO - postcss/postcss#1957: Mark this as dirty + for (const node of this.nodes) { + node.parent = undefined; + } + this._nodes!.length = 0; + return this; + } + + removeChild(child: Parameter | number): this { + // TODO - postcss/postcss#1957: Mark this as dirty + const index = this.index(child); + const parameter = this._nodes![index]; + if (parameter) parameter.parent = undefined; + this._nodes!.splice(index, 1); + + for (const iterator of this.#iterators) { + if (iterator.index >= index) iterator.index--; + } + + return this; + } + + some( + condition: ( + node: Parameter, + index: number, + nodes: ReadonlyArray, + ) => boolean, + ): boolean { + return this.nodes.some(condition); + } + + get first(): Parameter | undefined { + return this.nodes[0]; + } + + get last(): Parameter | undefined { + return this.nodes[this.nodes.length - 1]; + } + + /** @hidden */ + toString(): string { + let result = '('; + let first = true; + for (const parameter of this.nodes) { + if (first) { + result += parameter.raws.before ?? ''; + first = false; + } else { + result += ','; + result += parameter.raws.before ?? ' '; + } + result += parameter.toString(); + result += parameter.raws.after ?? ''; + } + + if (this.restParameter) { + if (this.nodes.length) { + result += ',' + (this.raws.beforeRestParameter ?? ' '); + } else if (this.raws.beforeRestParameter) { + result += this.raws.beforeRestParameter; + } + result += + '$' + + (this.raws.restParameter?.value === this.restParameter + ? this.raws.restParameter.raw + : sassInternal.toCssIdentifier(this.restParameter)) + + '...'; + } + if (this.raws.comma && this.nodes.length && !this.restParameter) { + result += ','; + } + return result + (this.raws.after ?? '') + ')'; + } + + /** + * Normalizes a single parameter declaration or list of parameters. + */ + private _normalize(nodes: NewParameters): Parameter[] { + const normalized = this._normalizeBeforeParent(nodes); + for (const node of normalized) { + node.parent = this; + } + return normalized; + } + + /** Like {@link _normalize}, but doesn't set the parameter's parents. */ + private _normalizeBeforeParent(nodes: NewParameters): Parameter[] { + if (nodes === undefined) return []; + if (Array.isArray(nodes)) { + if ( + nodes.length === 2 && + typeof nodes[0] === 'string' && + typeof nodes[1] === 'object' && + !('name' in nodes[1]) + ) { + return [new Parameter(nodes)]; + } else { + return (nodes as ReadonlyArray).map(node => + typeof node === 'object' && 'sassType' in node + ? (node as Parameter) + : new Parameter(node), + ); + } + } else { + return [ + typeof nodes === 'object' && 'sassType' in nodes + ? (nodes as Parameter) + : new Parameter(nodes as ParameterProps), + ]; + } + } + + /** Like {@link _normalize}, but also flattens a list of nodes. */ + private _normalizeList(nodes: ReadonlyArray): Parameter[] { + const result: Array = []; + for (const node of nodes) { + result.push(...this._normalize(node)); + } + return result; + } + + /** @hidden */ + get nonStatementChildren(): ReadonlyArray { + return this.nodes; + } +} diff --git a/pkg/sass-parser/lib/src/parameter.test.ts b/pkg/sass-parser/lib/src/parameter.test.ts new file mode 100644 index 000000000..336d67463 --- /dev/null +++ b/pkg/sass-parser/lib/src/parameter.test.ts @@ -0,0 +1,389 @@ +// 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 { + FunctionRule, + Parameter, + ParameterList, + StringExpression, + sass, + scss, +} from '..'; + +describe('a parameter', () => { + let node: Parameter; + beforeEach( + () => + void (node = new Parameter({ + name: 'foo', + defaultValue: {text: 'bar', quotes: true}, + })), + ); + + describe('with no default', () => { + function describeNode(description: string, create: () => Parameter): void { + describe(description, () => { + beforeEach(() => (node = create())); + + it('has a sassType', () => + expect(node.sassType.toString()).toBe('parameter')); + + it('has a name', () => expect(node.name).toBe('foo')); + + it('has no default value', () => + expect(node.defaultValue).toBeUndefined()); + }); + } + + describeNode( + 'parsed as SCSS', + () => + (scss.parse('@function a($foo) {}').nodes[0] as FunctionRule).parameters + .nodes[0], + ); + + describeNode( + 'parsed as Sass', + () => + (sass.parse('@function a($foo)').nodes[0] as FunctionRule).parameters + .nodes[0], + ); + + describe('constructed manually', () => { + describeNode('with a string', () => new Parameter('foo')); + + describeNode('with an object', () => new Parameter({name: 'foo'})); + }); + + describe('constructed from properties', () => { + describeNode( + 'a string', + () => new ParameterList({nodes: ['foo']}).nodes[0], + ); + + describeNode( + 'an object', + () => new ParameterList({nodes: [{name: 'foo'}]}).nodes[0], + ); + }); + }); + + describe('with a default', () => { + function describeNode(description: string, create: () => Parameter): void { + describe(description, () => { + beforeEach(() => (node = create())); + + it('has a sassType', () => + expect(node.sassType.toString()).toBe('parameter')); + + it('has a name', () => expect(node.name).toBe('foo')); + + it('has a default value', () => + expect(node).toHaveStringExpression('defaultValue', 'bar')); + }); + } + + describeNode( + 'parsed as SCSS', + () => + (scss.parse('@function a($foo: "bar") {}').nodes[0] as FunctionRule) + .parameters.nodes[0], + ); + + describeNode( + 'parsed as Sass', + () => + (sass.parse('@function a($foo: "bar")').nodes[0] as FunctionRule) + .parameters.nodes[0], + ); + + describe('constructed manually', () => { + describe('with an array', () => { + describeNode( + 'with an Expression', + () => + new Parameter([ + 'foo', + new StringExpression({text: 'bar', quotes: true}), + ]), + ); + + describeNode( + 'with ExpressionProps', + () => new Parameter(['foo', {text: 'bar', quotes: true}]), + ); + + describe('with an object', () => { + describeNode( + 'with an expression', + () => + new Parameter([ + 'foo', + { + defaultValue: new StringExpression({ + text: 'bar', + quotes: true, + }), + }, + ]), + ); + + describeNode( + 'with ExpressionProps', + () => + new Parameter([ + 'foo', + {defaultValue: {text: 'bar', quotes: true}}, + ]), + ); + }); + }); + + describe('with an object', () => { + describeNode( + 'with an expression', + () => + new Parameter({ + name: 'foo', + defaultValue: new StringExpression({text: 'bar', quotes: true}), + }), + ); + + describeNode( + 'with ExpressionProps', + () => + new Parameter({ + name: 'foo', + defaultValue: {text: 'bar', quotes: true}, + }), + ); + }); + }); + + describe('constructed from properties', () => { + describe('an array', () => { + describeNode( + 'with ExpressionProps', + () => + new ParameterList({ + nodes: [['foo', {text: 'bar', quotes: true}]], + }).nodes[0], + ); + + describeNode( + 'with an Expression', + () => + new ParameterList({ + nodes: [ + ['foo', new StringExpression({text: 'bar', quotes: true})], + ], + }).nodes[0], + ); + + describeNode( + 'with ParameterObjectProps', + () => + new ParameterList({ + nodes: [['foo', {defaultValue: {text: 'bar', quotes: true}}]], + }).nodes[0], + ); + }); + + describe('an object', () => { + describeNode( + 'with ExpressionProps', + () => + new ParameterList({ + nodes: [{name: 'foo', defaultValue: {text: 'bar', quotes: true}}], + }).nodes[0], + ); + + describeNode( + 'with an Expression', + () => + new ParameterList({ + nodes: [ + { + name: 'foo', + defaultValue: new StringExpression({ + text: 'bar', + quotes: true, + }), + }, + ], + }).nodes[0], + ); + }); + }); + }); + + it('assigned a new name', () => { + node.name = 'baz'; + expect(node.name).toBe('baz'); + }); + + it('assigned a new default', () => { + const old = node.defaultValue!; + node.defaultValue = {text: 'baz', quotes: true}; + expect(old.parent).toBeUndefined(); + expect(node).toHaveStringExpression('defaultValue', 'baz'); + }); + + describe('stringifies', () => { + describe('to SCSS', () => { + describe('with default raws', () => { + it('with no default', () => + expect(new Parameter('foo').toString()).toBe('$foo')); + + it('with a default', () => + expect( + new Parameter(['foo', {text: 'bar', quotes: true}]).toString(), + ).toBe('$foo: "bar"')); + + it('with a non-identifier name', () => + expect(new Parameter('f o').toString()).toBe('$f\\20o')); + }); + + // raws.before is only used as part of a ParameterList + it('ignores before', () => + expect( + new Parameter({ + name: 'foo', + raws: {before: '/**/'}, + }).toString(), + ).toBe('$foo')); + + it('with matching name', () => + expect( + new Parameter({ + name: 'foo', + raws: {name: {raw: 'f\\6fo', value: 'foo'}}, + }).toString(), + ).toBe('$f\\6fo')); + + it('with non-matching name', () => + expect( + new Parameter({ + name: 'foo', + raws: {name: {raw: 'f\\41o', value: 'fao'}}, + }).toString(), + ).toBe('$foo')); + + it('with between', () => + expect( + new Parameter({ + name: 'foo', + defaultValue: {text: 'bar', quotes: true}, + raws: {between: ' : '}, + }).toString(), + ).toBe('$foo : "bar"')); + + it('ignores between with no defaultValue', () => + expect( + new Parameter({ + name: 'foo', + raws: {between: ' : '}, + }).toString(), + ).toBe('$foo')); + + // raws.before is only used as part of a Configuration + describe('ignores after', () => { + it('with no default', () => + expect( + new Parameter({ + name: 'foo', + raws: {after: '/**/'}, + }).toString(), + ).toBe('$foo')); + + it('with a default', () => + expect( + new Parameter({ + name: 'foo', + defaultValue: {text: 'bar', quotes: true}, + raws: {after: '/**/'}, + }).toString(), + ).toBe('$foo: "bar"')); + }); + }); + }); + + describe('clone()', () => { + let original: Parameter; + beforeEach(() => { + original = ( + scss.parse('@function x($foo: "bar") {}').nodes[0] as FunctionRule + ).parameters.nodes[0]; + original.raws.between = ' : '; + }); + + describe('with no overrides', () => { + let clone: Parameter; + beforeEach(() => void (clone = original.clone())); + + describe('has the same properties:', () => { + it('name', () => expect(clone.name).toBe('foo')); + + it('defaultValue', () => + expect(clone).toHaveStringExpression('defaultValue', 'bar')); + }); + + describe('creates a new', () => { + it('self', () => expect(clone).not.toBe(original)); + + for (const attr of ['defaultValue', '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('name', () => { + it('defined', () => + expect(original.clone({name: 'baz'}).name).toBe('baz')); + + it('undefined', () => + expect(original.clone({name: undefined}).name).toBe('foo')); + }); + + describe('defaultValue', () => { + it('defined', () => + expect( + original.clone({defaultValue: {text: 'baz', quotes: true}}), + ).toHaveStringExpression('defaultValue', 'baz')); + + it('undefined', () => + expect( + original.clone({defaultValue: undefined}).defaultValue, + ).toBeUndefined()); + }); + }); + }); + + describe('toJSON', () => { + it('with a default', () => + expect( + (scss.parse('@function x($baz: "qux") {}').nodes[0] as FunctionRule) + .parameters.nodes[0], + ).toMatchSnapshot()); + + it('with no default', () => + expect( + (scss.parse('@function x($baz) {}').nodes[0] as FunctionRule).parameters + .nodes[0], + ).toMatchSnapshot()); + }); +}); diff --git a/pkg/sass-parser/lib/src/parameter.ts b/pkg/sass-parser/lib/src/parameter.ts new file mode 100644 index 000000000..ea5acdbd7 --- /dev/null +++ b/pkg/sass-parser/lib/src/parameter.ts @@ -0,0 +1,175 @@ +// 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 {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 {ParameterList} from './parameter-list'; +import * as sassInternal from './sass-internal'; +import {RawWithValue} from './raw-with-value'; +import * as utils from './utils'; + +/** + * The set of raws supported by {@link Parameter}. + * + * @category Statement + */ +export interface ParameterRaws { + /** The whitespace before the parameter name. */ + before?: string; + + /** + * The parameter's name, not including the `$`. + * + * This may be different than {@link Parameter.name} if the name contains + * escape codes or underscores. + */ + name?: RawWithValue; + + /** + * The whitespace and colon between the parameter name and default value. This + * is ignored unless the parameter has a default value. + */ + between?: string; + + /** + * The space symbols between the end of the parameter (after the default value + * if it has one or the parameter name if it doesn't) and the comma afterwards. + * Always empty for a parameter that doesn't have a trailing comma. + */ + after?: string; +} + +/** + * The initializer properties for {@link Parameter} passed as an + * options object. + * + * @category Statement + */ +export interface ParameterObjectProps { + raws?: ParameterRaws; + name: string; + defaultValue?: Expression | ExpressionProps; +} + +/** + * Properties used to initialize a {@link Parameter} without an explicit name. + * This is used when the name is given elsewhere, either in the array form of + * {@link ParameterProps} or the record form of [@link + * ParameterDeclarationProps}. + */ +export type ParameterExpressionProps = + | Expression + | ExpressionProps + | Omit; + +/** + * The initializer properties for {@link Parameter}. + * + * @category Statement + */ +export type ParameterProps = + | ParameterObjectProps + | string + | [string, ParameterExpressionProps]; + +/** + * A single parameter defined in the parameter declaration of a `@mixin` or + * `@function` rule. This is always included in a {@link ParameterList}. + * + * @category Statement + */ +export class Parameter extends Node { + readonly sassType = 'parameter' as const; + declare raws: ParameterRaws; + declare parent: ParameterList | undefined; + + /** + * The parameter name, not including `$`. + * + * This is the parsed and normalized value, with underscores converted to + * hyphens and escapes resolved to the characters they represent. + */ + declare name: string; + + /** The expression that provides the default value for the parameter. */ + get defaultValue(): Expression | undefined { + return this._defaultValue!; + } + set defaultValue(value: Expression | ExpressionProps | undefined) { + if (this._defaultValue) this._defaultValue.parent = undefined; + if (!value) { + this._defaultValue = undefined; + } else { + if (!('sassType' in value)) value = fromProps(value); + if (value) value.parent = this; + this._defaultValue = value; + } + } + private declare _defaultValue?: Expression; + + constructor(defaults: ParameterProps); + /** @hidden */ + constructor(_: undefined, inner: sassInternal.Argument); + constructor(defaults?: ParameterProps, inner?: sassInternal.Argument) { + if (typeof defaults === 'string') { + defaults = {name: defaults}; + } else if (Array.isArray(defaults)) { + const [name, rest] = defaults; + if ('sassType' in rest || !('defaultValue' in rest)) { + defaults = { + name, + defaultValue: rest as Expression | ExpressionProps, + }; + } else { + defaults = {name, ...rest}; + } + } + super(defaults); + this.raws ??= {}; + + if (inner) { + this.source = new LazySource(inner); + this.name = inner.name; + this.defaultValue = inner.defaultValue + ? convertExpression(inner.defaultValue) + : undefined; + } + } + + clone(overrides?: Partial): this { + return utils.cloneNode(this, overrides, [ + 'raws', + 'name', + {name: 'defaultValue', explicitUndefined: true}, + ]); + } + + toJSON(): object; + /** @hidden */ + toJSON(_: string, inputs: Map): object; + toJSON(_?: string, inputs?: Map): object { + return utils.toJSON(this, ['name', 'defaultValue'], inputs); + } + + /** @hidden */ + toString(): string { + return ( + '$' + + (this.raws.name?.value === this.name + ? this.raws.name.raw + : sassInternal.toCssIdentifier(this.name)) + + (this.defaultValue ? (this.raws.between ?? ': ') + this.defaultValue : '') + ); + } + + /** @hidden */ + get nonStatementChildren(): ReadonlyArray { + return this.defaultValue ? [this.defaultValue] : []; + } +} diff --git a/pkg/sass-parser/lib/src/sass-internal.ts b/pkg/sass-parser/lib/src/sass-internal.ts index e0ba70554..48574f451 100644 --- a/pkg/sass-parser/lib/src/sass-internal.ts +++ b/pkg/sass-parser/lib/src/sass-internal.ts @@ -66,6 +66,16 @@ declare namespace SassInternal { readonly span: FileSpan; } + class ArgumentDeclaration extends SassNode { + readonly arguments: Argument[]; + readonly restArgument?: string; + } + + class Argument extends SassNode { + readonly name: string; + readonly defaultValue?: Expression; + } + class Interpolation extends SassNode { contents: (string | Expression)[]; get asPlain(): string | undefined; @@ -124,6 +134,11 @@ declare namespace SassInternal { readonly configuration: ConfiguredVariable[]; } + class FunctionRule extends ParentStatement { + readonly name: string; + readonly arguments: ArgumentDeclaration; + } + class LoudComment extends Statement { readonly text: Interpolation; } @@ -267,6 +282,7 @@ export type ErrorRule = SassInternal.ErrorRule; export type ExtendRule = SassInternal.ExtendRule; export type ForRule = SassInternal.ForRule; export type ForwardRule = SassInternal.ForwardRule; +export type FunctionRule = SassInternal.FunctionRule; export type LoudComment = SassInternal.LoudComment; export type MediaRule = SassInternal.MediaRule; export type SilentComment = SassInternal.SilentComment; @@ -277,6 +293,8 @@ export type UseRule = SassInternal.UseRule; export type VariableDeclaration = SassInternal.VariableDeclaration; export type WarnRule = SassInternal.WarnRule; export type WhileRule = SassInternal.WhileRule; +export type Argument = SassInternal.Argument; +export type ArgumentDeclaration = SassInternal.ArgumentDeclaration; export type ConfiguredVariable = SassInternal.ConfiguredVariable; export type Interpolation = SassInternal.Interpolation; export type Expression = SassInternal.Expression; @@ -294,6 +312,7 @@ export interface StatementVisitorObject { visitExtendRule(node: ExtendRule): T; visitForRule(node: ForRule): T; visitForwardRule(node: ForwardRule): T; + visitFunctionRule(node: FunctionRule): T; visitLoudComment(node: LoudComment): T; visitMediaRule(node: MediaRule): T; visitSilentComment(node: SilentComment): T; diff --git a/pkg/sass-parser/lib/src/statement/__snapshots__/function-rule.test.ts.snap b/pkg/sass-parser/lib/src/statement/__snapshots__/function-rule.test.ts.snap new file mode 100644 index 000000000..c482b974f --- /dev/null +++ b/pkg/sass-parser/lib/src/statement/__snapshots__/function-rule.test.ts.snap @@ -0,0 +1,21 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`a @function rule toJSON 1`] = ` +{ + "functionName": "foo", + "inputs": [ + { + "css": "@function foo($bar) {}", + "hasBOM": false, + "id": "", + }, + ], + "name": "function", + "nodes": [], + "parameters": <($bar)>, + "raws": {}, + "sassType": "function-rule", + "source": <1:1-1:23 in 0>, + "type": "atrule", +} +`; diff --git a/pkg/sass-parser/lib/src/statement/forward-rule.test.ts b/pkg/sass-parser/lib/src/statement/forward-rule.test.ts index 56f22600c..08a81f843 100644 --- a/pkg/sass-parser/lib/src/statement/forward-rule.test.ts +++ b/pkg/sass-parser/lib/src/statement/forward-rule.test.ts @@ -291,7 +291,7 @@ describe('a @forward rule', () => { 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].name).toBe('baz'); expect(variables[0]).toHaveStringExpression('expression', 'qux'); }); @@ -714,7 +714,7 @@ describe('a @forward rule', () => { expect(clone.configuration.size).toBe(1); expect(clone.configuration.parent).toBe(clone); const variables = [...clone.configuration.variables()]; - expect(variables[0].variableName).toBe('zip'); + expect(variables[0].name).toBe('zip'); expect(variables[0]).toHaveStringExpression('expression', 'zap'); }); @@ -912,7 +912,7 @@ describe('a @forward rule', () => { expect(clone.configuration.size).toBe(1); expect(clone.configuration.parent).toBe(clone); const variables = [...clone.configuration.variables()]; - expect(variables[0].variableName).toBe('zip'); + expect(variables[0].name).toBe('zip'); expect(variables[0]).toHaveStringExpression('expression', 'zap'); }); diff --git a/pkg/sass-parser/lib/src/statement/function-rule.test.ts b/pkg/sass-parser/lib/src/statement/function-rule.test.ts new file mode 100644 index 000000000..0c2579ab8 --- /dev/null +++ b/pkg/sass-parser/lib/src/statement/function-rule.test.ts @@ -0,0 +1,305 @@ +// 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 {FunctionRule, ParameterList, sass, scss} from '../..'; +import * as utils from '../../../test/utils'; + +describe('a @function rule', () => { + let node: FunctionRule; + describe('with empty children', () => { + function describeNode( + description: string, + create: () => FunctionRule, + ): void { + describe(description, () => { + beforeEach(() => void (node = create())); + + it('has a name', () => expect(node.name.toString()).toBe('function')); + + it('has a function name', () => + expect(node.functionName.toString()).toBe('foo')); + + it('has a parameter', () => + expect(node.parameters.nodes[0].name).toEqual('bar')); + + it('has matching params', () => expect(node.params).toBe('foo($bar)')); + + it('has empty nodes', () => expect(node.nodes).toEqual([])); + }); + } + + describeNode( + 'parsed as SCSS', + () => scss.parse('@function foo($bar) {}').nodes[0] as FunctionRule, + ); + + describeNode( + 'parsed as Sass', + () => sass.parse('@function foo($bar)').nodes[0] as FunctionRule, + ); + + describeNode( + 'constructed manually', + () => new FunctionRule({functionName: 'foo', parameters: ['bar']}), + ); + + describeNode('constructed from ChildProps', () => + utils.fromChildProps({functionName: 'foo', parameters: ['bar']}), + ); + }); + + // TODO(nweiz): Enable this when we parse ReturnRule. + // + // describe('with a child', () => { + // function describeNode(description: string, create: () => FunctionRule): void { + // describe(description, () => { + // beforeEach(() => void (node = create())); + // + // it('has a name', () => expect(node.name.toString()).toBe('function')); + // + // it('has a function name', () => expect(node.functionName.toString()).toBe('foo')); + // + // it('has a parameter', () => + // expect(node.parameters.nodes[0].name).toEqual('bar')); + // + // it('has matching params', () => + // expect(node.params).toBe('foo($bar)')); + // + // it('has a child node', () => { + // expect(node.nodes).toHaveLength(1); + // expect(node.nodes[0]).toBeInstanceOf(ReturnRule); + // expect(node.nodes[0]).toHaveStringExpression('returnExpression', 'baz'); + // }); + // }); + // } + // + // describeNode( + // 'parsed as SCSS', + // () => scss.parse('@function foo($bar) {@return "baz"}').nodes[0] as FunctionRule, + // ); + // + // describeNode( + // 'parsed as Sass', + // () => + // sass.parse('@function foo($bar)\n @return "baz"').nodes[0] as FunctionRule, + // ); + // + // describeNode( + // 'constructed manually', + // () => + // new FunctionRule({ + // name: 'foo', + // parameters: ['bar'], + // nodes: [{returnExpression: 'child'}], + // }), + // ); + // + // describeNode('constructed from ChildProps', () => + // utils.fromChildProps({ + // name: 'foo', + // parameters: ['bar'], + // nodes: [{returnExpression: 'child'}], + // }), + // ); + // }); + + describe('throws an error when assigned a new', () => { + beforeEach( + () => + void (node = scss.parse('@function foo($bar) {}') + .nodes[0] as FunctionRule), + ); + + it('name', () => expect(() => (node.name = 'qux')).toThrow()); + + it('params', () => expect(() => (node.params = 'zip($zap)')).toThrow()); + }); + + describe('assigned new parameters', () => { + beforeEach( + () => + void (node = scss.parse('@function foo($bar) {}') + .nodes[0] as FunctionRule), + ); + + it("removes the old parameters' parent", () => { + const oldParameters = node.parameters; + node.parameters = ['qux']; + expect(oldParameters.parent).toBeUndefined(); + }); + + it("assigns the new parameters' parent", () => { + const parameters = new ParameterList(['qux']); + node.parameters = parameters; + expect(parameters.parent).toBe(node); + }); + + it('assigns the parameters explicitly', () => { + const parameters = new ParameterList(['qux']); + node.parameters = parameters; + expect(node.parameters).toBe(parameters); + }); + + it('assigns the expression as ParametersProps', () => { + node.parameters = ['qux']; + expect(node.parameters.nodes[0].name).toBe('qux'); + expect(node.parameters.parent).toBe(node); + }); + }); + + describe('stringifies', () => { + describe('to SCSS', () => { + it('with default raws', () => + expect( + new FunctionRule({ + functionName: 'foo', + parameters: ['bar'], + }).toString(), + ).toBe('@function foo($bar) {}')); + + it('with a non-identifier name', () => + expect( + new FunctionRule({ + functionName: 'f o', + parameters: ['bar'], + }).toString(), + ).toBe('@function f\\20o($bar) {}')); + + it('with afterName', () => + expect( + new FunctionRule({ + functionName: 'foo', + parameters: ['bar'], + raws: {afterName: '/**/'}, + }).toString(), + ).toBe('@function/**/foo($bar) {}')); + + it('with matching functionName', () => + expect( + new FunctionRule({ + functionName: 'foo', + parameters: ['bar'], + raws: {functionName: {value: 'foo', raw: 'f\\6fo'}}, + }).toString(), + ).toBe('@function f\\6fo($bar) {}')); + + it('with non-matching functionName', () => + expect( + new FunctionRule({ + functionName: 'foo', + parameters: ['bar'], + raws: {functionName: {value: 'fao', raw: 'f\\41o'}}, + }).toString(), + ).toBe('@function foo($bar) {}')); + }); + }); + + describe('clone', () => { + let original: FunctionRule; + beforeEach(() => { + original = scss.parse('@function foo($bar) {}').nodes[0] as FunctionRule; + // TODO: remove this once raws are properly parsed + original.raws.between = ' '; + }); + + describe('with no overrides', () => { + let clone: FunctionRule; + beforeEach(() => void (clone = original.clone())); + + describe('has the same properties:', () => { + it('params', () => expect(clone.params).toBe('foo($bar)')); + + it('functionName', () => expect(clone.functionName).toBe('foo')); + + it('parameters', () => { + expect(clone.parameters.nodes[0].name).toBe('bar'); + expect(clone.parameters.parent).toBe(clone); + }); + + 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 ['parameters', '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('functionName', () => { + describe('defined', () => { + let clone: FunctionRule; + beforeEach(() => { + clone = original.clone({functionName: 'baz'}); + }); + + it('changes params', () => expect(clone.params).toBe('baz($bar)')); + + it('changes functionName', () => + expect(clone.functionName).toEqual('baz')); + }); + + describe('undefined', () => { + let clone: FunctionRule; + beforeEach(() => { + clone = original.clone({functionName: undefined}); + }); + + it('preserves params', () => expect(clone.params).toBe('foo($bar)')); + + it('preserves functionName', () => + expect(clone.functionName).toEqual('foo')); + }); + }); + + describe('parameters', () => { + describe('defined', () => { + let clone: FunctionRule; + beforeEach(() => { + clone = original.clone({parameters: ['baz']}); + }); + + it('changes params', () => expect(clone.params).toBe('foo($baz)')); + + it('changes parameters', () => { + expect(clone.parameters.nodes[0].name).toBe('baz'); + expect(clone.parameters.parent).toBe(clone); + }); + }); + + describe('undefined', () => { + let clone: FunctionRule; + beforeEach(() => { + clone = original.clone({parameters: undefined}); + }); + + it('preserves params', () => expect(clone.params).toBe('foo($bar)')); + + it('preserves parameters', () => + expect(clone.parameters.nodes[0].name).toBe('bar')); + }); + }); + }); + }); + + it('toJSON', () => + expect(scss.parse('@function foo($bar) {}').nodes[0]).toMatchSnapshot()); +}); diff --git a/pkg/sass-parser/lib/src/statement/function-rule.ts b/pkg/sass-parser/lib/src/statement/function-rule.ts new file mode 100644 index 000000000..4b87811d8 --- /dev/null +++ b/pkg/sass-parser/lib/src/statement/function-rule.ts @@ -0,0 +1,161 @@ +// 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 {LazySource} from '../lazy-source'; +import {ParameterList, ParameterListProps} from '../parameter-list'; +import {RawWithValue} from '../raw-with-value'; +import * as sassInternal from '../sass-internal'; +import * as utils from '../utils'; +import { + ChildNode, + ContainerProps, + NewNode, + Statement, + StatementWithChildren, + appendInternalChildren, + normalize, +} from '.'; +import {_AtRule} from './at-rule-internal'; +import {interceptIsClean} from './intercept-is-clean'; +import * as sassParser from '../..'; + +/** + * The set of raws supported by {@link FunctionRule}. + * + * @category Statement + */ +export interface FunctionRuleRaws extends Omit { + /** + * The function's name. + * + * This may be different than {@link Function.functionName} if the name contains + * escape codes or underscores. + */ + functionName?: RawWithValue; +} + +/** + * The initializer properties for {@link FunctionRule}. + * + * @category Statement + */ +export type FunctionRuleProps = ContainerProps & { + raws?: FunctionRuleRaws; + functionName: string; + parameters?: ParameterList | ParameterListProps; +}; + +/** + * A `@function` rule. Extends [`postcss.AtRule`]. + * + * [`postcss.AtRule`]: https://postcss.org/api/#atrule + * + * @category Statement + */ +export class FunctionRule + extends _AtRule> + implements Statement +{ + readonly sassType = 'function-rule' as const; + declare parent: StatementWithChildren | undefined; + declare raws: FunctionRuleRaws; + declare nodes: ChildNode[]; + + /** + * The name of the function. + * + * This is the parsed and normalized value, with underscores converted to + * hyphens and escapes resolved to the characters they represent. + */ + declare functionName: string; + + /** The parameters that this function takes. */ + get parameters(): ParameterList { + return this._parameters!; + } + set parameters(parameters: ParameterList | ParameterListProps) { + if (this._parameters) { + this._parameters.parent = undefined; + } + this._parameters = + 'sassType' in parameters ? parameters : new ParameterList(parameters); + this._parameters.parent = this; + } + private declare _parameters: ParameterList; + + get name(): string { + return 'function'; + } + set name(value: string) { + throw new Error("FunctionRule.name can't be overwritten."); + } + + get params(): string { + return ( + (this.raws.functionName?.value === this.functionName + ? this.raws.functionName!.raw + : sassInternal.toCssIdentifier(this.functionName)) + this.parameters + ); + } + set params(value: string | number | undefined) { + throw new Error("FunctionRule.params can't be overwritten."); + } + + constructor(defaults: FunctionRuleProps); + /** @hidden */ + constructor(_: undefined, inner: sassInternal.FunctionRule); + constructor(defaults?: FunctionRuleProps, inner?: sassInternal.FunctionRule) { + super(defaults as unknown as postcss.AtRuleProps); + this.nodes ??= []; + + if (inner) { + this.source = new LazySource(inner); + this.functionName = inner.name; + this.parameters = new ParameterList(undefined, inner.arguments); + appendInternalChildren(this, inner.children); + } + } + + clone(overrides?: Partial): this { + return utils.cloneNode(this, overrides, [ + 'raws', + 'functionName', + 'parameters', + ]); + } + + toJSON(): object; + /** @hidden */ + toJSON(_: string, inputs: Map): object; + toJSON(_?: string, inputs?: Map): object { + return utils.toJSON( + this, + ['name', 'functionName', 'parameters', 'nodes'], + inputs, + ); + } + + /** @hidden */ + toString( + stringifier: postcss.Stringifier | postcss.Syntax = sassParser.scss + .stringify, + ): string { + return super.toString(stringifier); + } + + /** @hidden */ + get nonStatementChildren(): ReadonlyArray { + return [this.parameters]; + } + + /** @hidden */ + normalize(node: NewNode, sample?: postcss.Node): ChildNode[] { + return normalize(this, node, sample); + } +} + +interceptIsClean(FunctionRule); diff --git a/pkg/sass-parser/lib/src/statement/index.ts b/pkg/sass-parser/lib/src/statement/index.ts index 416352f0f..ac4fff418 100644 --- a/pkg/sass-parser/lib/src/statement/index.ts +++ b/pkg/sass-parser/lib/src/statement/index.ts @@ -4,6 +4,7 @@ import * as postcss from 'postcss'; +import {Container} from '../container'; import {Interpolation} from '../interpolation'; import {LazySource} from '../lazy-source'; import {Node, NodeProps} from '../node'; @@ -16,6 +17,7 @@ import {EachRule, EachRuleProps} from './each-rule'; import {ErrorRule, ErrorRuleProps} from './error-rule'; import {ForRule, ForRuleProps} from './for-rule'; import {ForwardRule, ForwardRuleProps} from './forward-rule'; +import {FunctionRule, FunctionRuleProps} from './function-rule'; import {Root} from './root'; import {Rule, RuleProps} from './rule'; import {UseRule, UseRuleProps} from './use-rule'; @@ -55,6 +57,7 @@ export type StatementType = | 'each-rule' | 'for-rule' | 'forward-rule' + | 'function-rule' | 'error-rule' | 'use-rule' | 'sass-comment' @@ -73,6 +76,7 @@ export type AtRule = | ErrorRule | ForRule | ForwardRule + | FunctionRule | GenericAtRule | UseRule | WarnRule @@ -109,6 +113,7 @@ export type ChildProps = | ErrorRuleProps | ForRuleProps | ForwardRuleProps + | FunctionRuleProps | GenericAtRuleProps | RuleProps | SassCommentChildProps @@ -131,9 +136,9 @@ export interface ContainerProps extends NodeProps { * * @category Statement */ -export type StatementWithChildren = postcss.Container & { - nodes: ChildNode[]; -} & Statement; +export type StatementWithChildren = postcss.Container & + Container & + Statement; /** * A statement in a Sass stylesheet. @@ -170,6 +175,7 @@ const visitor = sassInternal.createStatementVisitor({ visitEachRule: inner => new EachRule(undefined, inner), visitForRule: inner => new ForRule(undefined, inner), visitForwardRule: inner => new ForwardRule(undefined, inner), + visitFunctionRule: inner => new FunctionRule(undefined, inner), visitExtendRule: inner => { const paramsInterpolation = new Interpolation(undefined, inner.selector); if (inner.isOptional) paramsInterpolation.append('!optional'); @@ -317,6 +323,8 @@ export function normalize( result.push(new ForRule(node)); } else if ('forwardUrl' in node) { result.push(new ForwardRule(node)); + } else if ('functionName' in node) { + result.push(new FunctionRule(node)); } else if ('errorExpression' in node) { result.push(new ErrorRule(node)); } else if ('text' in node || 'textInterpolation' in node) { diff --git a/pkg/sass-parser/lib/src/statement/use-rule.test.ts b/pkg/sass-parser/lib/src/statement/use-rule.test.ts index fa079fb76..2786a3033 100644 --- a/pkg/sass-parser/lib/src/statement/use-rule.test.ts +++ b/pkg/sass-parser/lib/src/statement/use-rule.test.ts @@ -134,7 +134,7 @@ describe('a @use rule', () => { 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].name).toBe('baz'); expect(variables[0]).toHaveStringExpression('expression', 'qux'); }); @@ -418,7 +418,7 @@ describe('a @use rule', () => { 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].name).toBe('baz'); expect(variables[0]).toHaveStringExpression('expression', 'qux'); }); @@ -536,7 +536,7 @@ describe('a @use rule', () => { 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].name).toBe('baz'); expect(variables[0]).toHaveStringExpression('expression', 'qux'); }); diff --git a/pkg/sass-parser/lib/src/stringifier.ts b/pkg/sass-parser/lib/src/stringifier.ts index 8ad173e58..3e0499ebc 100644 --- a/pkg/sass-parser/lib/src/stringifier.ts +++ b/pkg/sass-parser/lib/src/stringifier.ts @@ -34,6 +34,7 @@ import {EachRule} from './statement/each-rule'; import {ErrorRule} from './statement/error-rule'; import {ForRule} from './statement/for-rule'; import {ForwardRule} from './statement/forward-rule'; +import {FunctionRule} from './statement/function-rule'; import {GenericAtRule} from './statement/generic-at-rule'; import {Rule} from './statement/rule'; import {SassComment} from './statement/sass-comment'; @@ -96,6 +97,10 @@ export class Stringifier extends PostCssStringifier { this.sassAtRule(node, semicolon); } + private ['function-rule'](node: FunctionRule, semicolon: boolean): void { + this.sassAtRule(node, semicolon); + } + private atrule(node: GenericAtRule, semicolon: boolean): void { // In the @at-root shorthand, stringify `@at-root {.foo {...}}` as // `@at-root .foo {...}`.