From 39129e33d1325191749684f5cad7b426e22260fc Mon Sep 17 00:00:00 2001 From: Natalie Weizenbaum Date: Wed, 19 Feb 2025 15:07:53 -0800 Subject: [PATCH 1/2] Add support for function call expressions --- lib/src/js/parser.dart | 6 + pkg/sass-parser/CHANGELOG.md | 2 + pkg/sass-parser/lib/index.ts | 5 + .../__snapshots__/function.test.ts.snap | 52 +++ pkg/sass-parser/lib/src/expression/convert.ts | 8 + .../lib/src/expression/from-props.ts | 2 + .../lib/src/expression/function.test.ts | 316 ++++++++++++++++++ .../lib/src/expression/function.ts | 150 +++++++++ pkg/sass-parser/lib/src/expression/index.ts | 4 + pkg/sass-parser/lib/src/sass-internal.ts | 14 + 10 files changed, 559 insertions(+) create mode 100644 pkg/sass-parser/lib/src/expression/__snapshots__/function.test.ts.snap create mode 100644 pkg/sass-parser/lib/src/expression/function.test.ts create mode 100644 pkg/sass-parser/lib/src/expression/function.ts diff --git a/lib/src/js/parser.dart b/lib/src/js/parser.dart index 616415b59..17132c35c 100644 --- a/lib/src/js/parser.dart +++ b/lib/src/js/parser.dart @@ -109,6 +109,12 @@ void _updateAstPrototypes() { getJSClass( ContentRule(arguments, bogusSpan), ).defineGetter('arguments', (ContentRule self) => self.arguments); + getJSClass( + FunctionExpression('a', arguments, bogusSpan), + ).defineGetter('arguments', (FunctionExpression self) => self.arguments); + getJSClass( + IfExpression(arguments, bogusSpan), + ).defineGetter('arguments', (IfExpression self) => self.arguments); _addSupportsConditionToInterpolation(); diff --git a/pkg/sass-parser/CHANGELOG.md b/pkg/sass-parser/CHANGELOG.md index 5929ae329..33632d5a1 100644 --- a/pkg/sass-parser/CHANGELOG.md +++ b/pkg/sass-parser/CHANGELOG.md @@ -4,6 +4,8 @@ * Add support for parsing map expressions. +* Add support for parsing function calls. + ## 0.4.14 * Add support for parsing color expressions. diff --git a/pkg/sass-parser/lib/index.ts b/pkg/sass-parser/lib/index.ts index b0912611f..4d3143c2d 100644 --- a/pkg/sass-parser/lib/index.ts +++ b/pkg/sass-parser/lib/index.ts @@ -70,6 +70,11 @@ export { ColorExpressionProps, ColorExpressionRaws, } from './src/expression/color'; +export { + FunctionExpression, + FunctionExpressionProps, + FunctionExpressionRaws, +} from './src/expression/function'; export { ListExpression, ListExpressionProps, diff --git a/pkg/sass-parser/lib/src/expression/__snapshots__/function.test.ts.snap b/pkg/sass-parser/lib/src/expression/__snapshots__/function.test.ts.snap new file mode 100644 index 000000000..87bcba7a1 --- /dev/null +++ b/pkg/sass-parser/lib/src/expression/__snapshots__/function.test.ts.snap @@ -0,0 +1,52 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`a function expression toJSON if() 1`] = ` +{ + "arguments": <(cond, true, false)>, + "inputs": [ + { + "css": "@#{if(cond, true, false)}", + "hasBOM": false, + "id": "", + }, + ], + "name": "if", + "raws": {}, + "sassType": "function-call", +} +`; + +exports[`a function expression toJSON with a namespace 1`] = ` +{ + "arguments": <(bar)>, + "inputs": [ + { + "css": "@#{baz.foo(bar)}", + "hasBOM": false, + "id": "", + }, + ], + "name": "foo", + "namespace": "baz", + "raws": {}, + "sassType": "function-call", + "source": <1:4-1:16 in 0>, +} +`; + +exports[`a function expression toJSON without a namespace 1`] = ` +{ + "arguments": <(bar)>, + "inputs": [ + { + "css": "@#{foo(bar)}", + "hasBOM": false, + "id": "", + }, + ], + "name": "foo", + "raws": {}, + "sassType": "function-call", + "source": <1:4-1:12 in 0>, +} +`; diff --git a/pkg/sass-parser/lib/src/expression/convert.ts b/pkg/sass-parser/lib/src/expression/convert.ts index eb522da5d..586102cf7 100644 --- a/pkg/sass-parser/lib/src/expression/convert.ts +++ b/pkg/sass-parser/lib/src/expression/convert.ts @@ -4,10 +4,12 @@ import * as sassInternal from '../sass-internal'; +import {ArgumentList} from '../argument-list'; import {Expression} from '.'; import {BinaryOperationExpression} from './binary-operation'; import {BooleanExpression} from './boolean'; import {ColorExpression} from './color'; +import {FunctionExpression} from './function'; import {ListExpression} from './list'; import {MapExpression} from './map'; import {NumberExpression} from './number'; @@ -20,6 +22,12 @@ const visitor = sassInternal.createExpressionVisitor({ visitStringExpression: inner => new StringExpression(undefined, inner), visitBooleanExpression: inner => new BooleanExpression(undefined, inner), visitColorExpression: inner => new ColorExpression(undefined, inner), + visitFunctionExpression: inner => new FunctionExpression(undefined, inner), + visitIfExpression: inner => + new FunctionExpression({ + name: 'if', + arguments: new ArgumentList(undefined, inner.arguments), + }), visitListExpression: inner => new ListExpression(undefined, inner), visitMapExpression: inner => new MapExpression(undefined, inner), visitNumberExpression: inner => new NumberExpression(undefined, inner), diff --git a/pkg/sass-parser/lib/src/expression/from-props.ts b/pkg/sass-parser/lib/src/expression/from-props.ts index 33eb0bdcd..d8829a377 100644 --- a/pkg/sass-parser/lib/src/expression/from-props.ts +++ b/pkg/sass-parser/lib/src/expression/from-props.ts @@ -7,6 +7,7 @@ import {Expression, ExpressionProps} from '.'; import {BinaryOperationExpression} from './binary-operation'; import {BooleanExpression} from './boolean'; import {ColorExpression} from './color'; +import {FunctionExpression} from './function'; import {ListExpression} from './list'; import {MapExpression} from './map'; import {NumberExpression} from './number'; @@ -18,6 +19,7 @@ export function fromProps(props: ExpressionProps): Expression { if ('left' in props) return new BinaryOperationExpression(props); if ('separator' in props) return new ListExpression(props); if ('nodes' in props) return new MapExpression(props); + if ('name' in props) return new FunctionExpression(props); if ('value' in props) { if (typeof props.value === 'boolean') return new BooleanExpression(props); if (typeof props.value === 'number') return new NumberExpression(props); diff --git a/pkg/sass-parser/lib/src/expression/function.test.ts b/pkg/sass-parser/lib/src/expression/function.test.ts new file mode 100644 index 000000000..052259488 --- /dev/null +++ b/pkg/sass-parser/lib/src/expression/function.test.ts @@ -0,0 +1,316 @@ +// 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 {ArgumentList, FunctionExpression} from '../..'; +import * as utils from '../../../test/utils'; + +describe('a function expression', () => { + let node: FunctionExpression; + + describe('with no namespace', () => { + function describeNode( + description: string, + create: () => FunctionExpression, + ): void { + describe(description, () => { + beforeEach(() => void (node = create())); + + it('has sassType function-call', () => + expect(node.sassType).toBe('function-call')); + + it('has no namespace', () => expect(node.namespace).toBe(undefined)); + + it('has a name', () => expect(node.name).toBe('foo')); + + it('has an argument', () => + expect(node.arguments.nodes[0]).toHaveStringExpression( + 'value', + 'bar', + )); + }); + } + + describeNode('parsed', () => utils.parseExpression('foo(bar)')); + + describeNode( + 'constructed manually', + () => new FunctionExpression({name: 'foo', arguments: [{text: 'bar'}]}), + ); + + describeNode('constructed from ExpressionProps', () => + utils.fromExpressionProps({name: 'foo', arguments: [{text: 'bar'}]}), + ); + }); + + describe('with a namespace', () => { + function describeNode( + description: string, + create: () => FunctionExpression, + ): void { + describe(description, () => { + beforeEach(() => void (node = create())); + + it('has sassType function-call', () => + expect(node.sassType).toBe('function-call')); + + it('has a namespace', () => expect(node.namespace).toBe('baz')); + + it('has a name', () => expect(node.name).toBe('foo')); + + it('has an argument', () => + expect(node.arguments.nodes[0]).toHaveStringExpression( + 'value', + 'bar', + )); + }); + } + + describeNode('parsed', () => utils.parseExpression('baz.foo(bar)')); + + describeNode( + 'constructed manually', + () => + new FunctionExpression({ + namespace: 'baz', + name: 'foo', + arguments: [{text: 'bar'}], + }), + ); + + describeNode('constructed from ExpressionProps', () => + utils.fromExpressionProps({ + namespace: 'baz', + name: 'foo', + arguments: [{text: 'bar'}], + }), + ); + }); + + describe('if()', () => { + beforeEach(() => void (node = utils.parseExpression('if(cond, bar, baz)'))); + + it('has sassType function-call', () => + expect(node.sassType).toBe('function-call')); + + it('has no namespace', () => expect(node.namespace).toBe(undefined)); + + it('has a name', () => expect(node.name).toBe('if')); + + it('has three arguments', () => { + expect(node.arguments.nodes[0]).toHaveStringExpression('value', 'cond'); + expect(node.arguments.nodes[1]).toHaveStringExpression('value', 'bar'); + expect(node.arguments.nodes[2]).toHaveStringExpression('value', 'baz'); + }); + }); + + describe('assigned new namespace', () => { + it('defined', () => { + node = utils.parseExpression('foo(bar)'); + node.namespace = 'baz'; + expect(node.namespace).toBe('baz'); + }); + + it('undefined', () => { + node = utils.parseExpression('baz.foo(bar)'); + node.namespace = undefined; + expect(node.namespace).toBe(undefined); + }); + }); + + it('assigned new name', () => { + node = utils.parseExpression('foo(bar)'); + node.name = 'baz'; + expect(node.name).toBe('baz'); + }); + + describe('assigned new arguments', () => { + beforeEach(() => void (node = utils.parseExpression('foo(bar)'))); + + it("removes the old arguments' parent", () => { + const oldArguments = node.arguments; + node.arguments = [{text: 'qux'}]; + expect(oldArguments.parent).toBeUndefined(); + }); + + it("assigns the new arguments' parent", () => { + const args = new ArgumentList([{text: 'qux'}]); + node.arguments = args; + expect(args.parent).toBe(node); + }); + + it('assigns the arguments explicitly', () => { + const args = new ArgumentList([{text: 'qux'}]); + node.arguments = args; + expect(node.arguments).toBe(args); + }); + + it('assigns the expression as ArgumentProps', () => { + node.arguments = [{text: 'qux'}]; + expect(node.arguments.nodes[0]).toHaveStringExpression('value', 'qux'); + expect(node.arguments.parent).toBe(node); + }); + }); + + describe('stringifies', () => { + describe('with default raws', () => { + it('with no namespace', () => + expect( + new FunctionExpression({ + name: 'foo', + arguments: [{text: 'bar'}], + }).toString(), + ).toBe('foo(bar)')); + + it('with a namespace', () => + expect( + new FunctionExpression({ + namespace: 'baz', + name: 'foo', + arguments: [{text: 'bar'}], + }).toString(), + ).toBe('baz.foo(bar)')); + }); + + it('with matching namespace', () => + expect( + new FunctionExpression({ + namespace: 'baz', + name: 'foo', + arguments: [{text: 'bar'}], + raws: {namespace: {value: 'baz', raw: 'b\\61z'}}, + }).toString(), + ).toBe('b\\61z.foo(bar)')); + + it('with non-matching namespace', () => + expect( + new FunctionExpression({ + namespace: 'zip', + name: 'foo', + arguments: [{text: 'bar'}], + raws: {namespace: {value: 'baz', raw: 'b\\61z'}}, + }).toString(), + ).toBe('zip.foo(bar)')); + + it('with matching name', () => + expect( + new FunctionExpression({ + name: 'foo', + arguments: [{text: 'bar'}], + raws: {name: {value: 'foo', raw: 'f\\6fo'}}, + }).toString(), + ).toBe('f\\6fo(bar)')); + + it('with non-matching name', () => + expect( + new FunctionExpression({ + name: 'zip', + arguments: [{text: 'bar'}], + raws: {name: {value: 'foo', raw: 'f\\6fo'}}, + }).toString(), + ).toBe('zip(bar)')); + }); + + describe('clone', () => { + let original: FunctionExpression; + + beforeEach(() => { + original = utils.parseExpression('baz.foo(bar)'); + // TODO: remove this once raws are properly parsed + original.raws.name = {value: 'foo', raw: 'f\\6fo'}; + }); + + describe('with no overrides', () => { + let clone: FunctionExpression; + + beforeEach(() => void (clone = original.clone())); + + describe('has the same properties:', () => { + it('namespace', () => expect(clone.namespace).toBe('baz')); + + it('name', () => expect(clone.name).toBe('foo')); + + it('arguments', () => { + expect(clone.arguments.nodes[0]).toHaveStringExpression( + 'value', + 'bar', + ); + expect(clone.arguments.parent).toBe(clone); + }); + + it('raws', () => + expect(clone.raws).toEqual({name: {value: 'foo', raw: 'f\\6fo'}})); + + it('source', () => expect(clone.source).toBe(original.source)); + }); + + describe('creates a new', () => { + it('self', () => expect(clone).not.toBe(original)); + + for (const attr of ['arguments', 'raws'] as const) { + it(attr, () => expect(clone[attr]).not.toBe(original[attr])); + } + }); + }); + + describe('overrides', () => { + describe('raws', () => { + it('defined', () => + expect( + original.clone({raws: {namespace: {value: 'baz', raw: 'b\\61z'}}}) + .raws, + ).toEqual({namespace: {value: 'baz', raw: 'b\\61z'}})); + + it('undefined', () => + expect(original.clone({raws: undefined}).raws).toEqual({ + name: {value: 'foo', raw: 'f\\6fo'}, + })); + }); + + describe('namespace', () => { + it('defined', () => + expect(original.clone({namespace: 'zip'}).namespace).toBe('zip')); + + it('undefined', () => + expect(original.clone({namespace: undefined}).namespace).toBe( + undefined, + )); + }); + + describe('name', () => { + it('defined', () => + expect(original.clone({name: 'zip'}).name).toBe('zip')); + + it('undefined', () => + expect(original.clone({name: undefined}).name).toBe('foo')); + }); + + describe('arguments', () => { + it('defined', () => { + const clone = original.clone({arguments: [{text: 'qux'}]}); + expect(clone.arguments.nodes[0]).toHaveStringExpression( + 'value', + 'qux', + ); + expect(clone.arguments.parent).toBe(clone); + }); + + it('undefined', () => + expect( + original.clone({arguments: undefined}).arguments.nodes[0], + ).toHaveStringExpression('value', 'bar')); + }); + }); + }); + + describe('toJSON', () => { + it('without a namespace', () => + expect(utils.parseExpression('foo(bar)')).toMatchSnapshot()); + + it('with a namespace', () => + expect(utils.parseExpression('baz.foo(bar)')).toMatchSnapshot()); + + it('if()', () => + expect(utils.parseExpression('if(cond, true, false)')).toMatchSnapshot()); + }); +}); diff --git a/pkg/sass-parser/lib/src/expression/function.ts b/pkg/sass-parser/lib/src/expression/function.ts new file mode 100644 index 000000000..7494efcc4 --- /dev/null +++ b/pkg/sass-parser/lib/src/expression/function.ts @@ -0,0 +1,150 @@ +// Copyright 2025 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 {ArgumentList, ArgumentListProps} from '../argument-list'; +import {LazySource} from '../lazy-source'; +import {NodeProps} from '../node'; +import {RawWithValue} from '../raw-with-value'; +import type * as sassInternal from '../sass-internal'; +import * as utils from '../utils'; +import {Expression} from '.'; + +/** + * The initializer properties for {@link FunctionExpression}. + * + * @category Expression + */ +export interface FunctionExpressionProps extends NodeProps { + namespace?: string; + name: string; + arguments: ArgumentList | ArgumentListProps; + raws?: FunctionExpressionRaws; +} + +/** + * Raws indicating how to precisely serialize a {@link FunctionExpression}. + * + * @category Expression + */ +export interface FunctionExpressionRaws { + /** + * The function's namespace. + * + * This may be different than {@link FunctionExpression.namespace} if the + * namespace contains escape codes or underscores. + */ + namespace?: RawWithValue; + + /** + * The function's name. + * + * This may be different than {@link FunctionExpression.name} if the name + * contains escape codes or underscores. + */ + name?: RawWithValue; +} + +/** + * An expression representing a (non-interpolated) function call in Sass. + * + * @category Expression + */ +export class FunctionExpression extends Expression { + readonly sassType = 'function-call' as const; + declare raws: FunctionExpressionRaws; + + /** + * This function's namespace. + * + * This is the parsed and normalized value, with underscores converted to + * hyphens and escapes resolved to the characters they represent. + */ + get namespace(): string | undefined { + return this._namespace; + } + set namespace(namespace: string | undefined) { + // TODO - postcss/postcss#1957: Mark this as dirty + this._namespace = namespace; + } + private declare _namespace: string | undefined; + + /** + * This function's name. + * + * This is the parsed and normalized value, with underscores converted to + * hyphens and escapes resolved to the characters they represent. + */ + get name(): string { + return this._name; + } + set name(name: string) { + // TODO - postcss/postcss#1957: Mark this as dirty + this._name = name; + } + private declare _name: string; + + /** The arguments to pass to the function. */ + get arguments(): ArgumentList { + return this._arguments!; + } + set arguments(args: ArgumentList | ArgumentListProps | undefined) { + if (this._arguments) this._arguments.parent = undefined; + this._arguments = args + ? 'sassType' in args + ? args + : new ArgumentList(args) + : new ArgumentList(); + this._arguments.parent = this; + } + private declare _arguments: ArgumentList; + + constructor(defaults: FunctionExpressionProps); + /** @hidden */ + constructor(_: undefined, inner: sassInternal.FunctionExpression); + constructor(defaults?: object, inner?: sassInternal.FunctionExpression) { + super(defaults); + if (inner) { + this.source = new LazySource(inner); + this.namespace = inner.namespace ?? undefined; + this.name = inner.name; + this.arguments = new ArgumentList(undefined, inner.arguments); + } + } + + clone(overrides?: Partial): this { + return utils.cloneNode(this, overrides, [ + 'raws', + {name: 'namespace', explicitUndefined: true}, + 'name', + 'arguments', + ]); + } + + toJSON(): object; + /** @hidden */ + toJSON(_: string, inputs: Map): object; + toJSON(_?: string, inputs?: Map): object { + return utils.toJSON(this, ['namespace', 'name', 'arguments'], inputs); + } + + /** @hidden */ + toString(): string { + return ( + (this.namespace + ? (this.raws.namespace?.value === this.namespace + ? this.raws.namespace.raw + : this.namespace) + '.' + : '') + + (this.raws.name?.value === this.name ? this.raws.name.raw : this.name) + + this.arguments + ); + } + + /** @hidden */ + get nonStatementChildren(): ReadonlyArray { + return [this.arguments]; + } +} diff --git a/pkg/sass-parser/lib/src/expression/index.ts b/pkg/sass-parser/lib/src/expression/index.ts index 0dc1d14f0..c5f3e8cf6 100644 --- a/pkg/sass-parser/lib/src/expression/index.ts +++ b/pkg/sass-parser/lib/src/expression/index.ts @@ -9,6 +9,7 @@ import type { } from './binary-operation'; import {BooleanExpression, BooleanExpressionProps} from './boolean'; import {ColorExpression, ColorExpressionProps} from './color'; +import {FunctionExpression, FunctionExpressionProps} from './function'; import {ListExpression, ListExpressionProps} from './list'; import {MapExpression, MapExpressionProps} from './map'; import {NumberExpression, NumberExpressionProps} from './number'; @@ -23,6 +24,7 @@ export type AnyExpression = | BinaryOperationExpression | BooleanExpression | ColorExpression + | FunctionExpression | ListExpression | MapExpression | NumberExpression @@ -37,6 +39,7 @@ export type ExpressionType = | 'binary-operation' | 'boolean' | 'color' + | 'function-call' | 'list' | 'map' | 'number' @@ -52,6 +55,7 @@ export type ExpressionProps = | BinaryOperationExpressionProps | BooleanExpressionProps | ColorExpressionProps + | FunctionExpressionProps | ListExpressionProps | MapExpressionProps | NumberExpressionProps diff --git a/pkg/sass-parser/lib/src/sass-internal.ts b/pkg/sass-parser/lib/src/sass-internal.ts index ecc47a865..bbcba7e9e 100644 --- a/pkg/sass-parser/lib/src/sass-internal.ts +++ b/pkg/sass-parser/lib/src/sass-internal.ts @@ -330,6 +330,16 @@ declare namespace SassInternal { readonly hasQuotes: boolean; } + class FunctionExpression extends Expression { + readonly namespace: string | null | undefined; + readonly name: string; + readonly arguments: ArgumentList; + } + + class IfExpression extends Expression { + readonly arguments: ArgumentList; + } + class ListExpression extends Expression { readonly contents: Expression[]; readonly separator: ListSeparator; @@ -409,6 +419,8 @@ export type ConfiguredVariable = SassInternal.ConfiguredVariable; export type Interpolation = SassInternal.Interpolation; export type Expression = SassInternal.Expression; export type BinaryOperationExpression = SassInternal.BinaryOperationExpression; +export type FunctionExpression = SassInternal.FunctionExpression; +export type IfExpression = SassInternal.IfExpression; export type ListExpression = SassInternal.ListExpression; export type ListSeparator = SassInternal.ListSeparator; export type MapExpression = SassInternal.MapExpression; @@ -450,6 +462,8 @@ export interface ExpressionVisitorObject { visitStringExpression(node: StringExpression): T; visitBooleanExpression(node: BooleanExpression): T; visitColorExpression(node: ColorExpression): T; + visitFunctionExpression(node: FunctionExpression): T; + visitIfExpression(node: IfExpression): T; visitListExpression(node: ListExpression): T; visitMapExpression(node: MapExpression): T; visitNumberExpression(node: NumberExpression): T; From 7956585d62d5d39bee0bbda8c938eff433657282 Mon Sep 17 00:00:00 2001 From: Natalie Weizenbaum Date: Fri, 21 Feb 2025 15:27:21 -0800 Subject: [PATCH 2/2] Update pkg/sass-parser/lib/src/expression/function.test.ts Co-authored-by: Carlos (Goodwine) <2022649+Goodwine@users.noreply.github.com> --- pkg/sass-parser/lib/src/expression/function.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/sass-parser/lib/src/expression/function.test.ts b/pkg/sass-parser/lib/src/expression/function.test.ts index 052259488..44ada16e4 100644 --- a/pkg/sass-parser/lib/src/expression/function.test.ts +++ b/pkg/sass-parser/lib/src/expression/function.test.ts @@ -1,4 +1,4 @@ -// Copyright 2024 Google Inc. Use of this source code is governed by an +// Copyright 2025 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.