From 0918ade37fa5ef5ef80f26e85df173e2ea62d2a1 Mon Sep 17 00:00:00 2001 From: Natalie Weizenbaum Date: Fri, 22 Nov 2024 16:32:03 -0800 Subject: [PATCH] Add sass-parser support for `@return` and `@mixin` --- CHANGELOG.md | 4 + lib/src/js/parser.dart | 3 +- pkg/sass-parser/CHANGELOG.md | 6 + pkg/sass-parser/lib/index.ts | 10 + pkg/sass-parser/lib/src/sass-internal.ts | 13 + .../__snapshots__/mixin-rule.test.ts.snap | 21 ++ .../__snapshots__/return-rule.test.ts.snap | 20 ++ .../lib/src/statement/function-rule.test.ts | 117 +++---- pkg/sass-parser/lib/src/statement/index.ts | 26 +- .../lib/src/statement/mixin-rule.test.ts | 296 ++++++++++++++++++ .../lib/src/statement/mixin-rule.ts | 161 ++++++++++ .../lib/src/statement/return-rule.test.ts | 216 +++++++++++++ .../lib/src/statement/return-rule.ts | 129 ++++++++ pkg/sass-parser/lib/src/stringifier.ts | 10 + pkg/sass-parser/package.json | 2 +- pkg/sass_api/CHANGELOG.md | 4 + pkg/sass_api/pubspec.yaml | 4 +- pubspec.yaml | 2 +- 18 files changed, 978 insertions(+), 66 deletions(-) create mode 100644 pkg/sass-parser/lib/src/statement/__snapshots__/mixin-rule.test.ts.snap create mode 100644 pkg/sass-parser/lib/src/statement/__snapshots__/return-rule.test.ts.snap create mode 100644 pkg/sass-parser/lib/src/statement/mixin-rule.test.ts create mode 100644 pkg/sass-parser/lib/src/statement/mixin-rule.ts create mode 100644 pkg/sass-parser/lib/src/statement/return-rule.test.ts create mode 100644 pkg/sass-parser/lib/src/statement/return-rule.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index c6730d729..c669e0692 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +## 1.82.1-dev + +* No user-visible changes. + ## 1.82.0 ### Command-Line Interface diff --git a/lib/src/js/parser.dart b/lib/src/js/parser.dart index 582e733d7..d641c7123 100644 --- a/lib/src/js/parser.dart +++ b/lib/src/js/parser.dart @@ -93,7 +93,8 @@ void _updateAstPrototypes() { .defineGetter('arguments', (ArgumentDeclaration self) => self.arguments); var function = FunctionRule('a', arguments, [], bogusSpan); getJSClass(function) - .defineGetter('arguments', (FunctionRule self) => self.arguments); + .superclass + .defineGetter('arguments', (CallableDeclaration self) => self.arguments); _addSupportsConditionToInterpolation(); diff --git a/pkg/sass-parser/CHANGELOG.md b/pkg/sass-parser/CHANGELOG.md index 621869053..2f6690e4b 100644 --- a/pkg/sass-parser/CHANGELOG.md +++ b/pkg/sass-parser/CHANGELOG.md @@ -1,3 +1,9 @@ +## 0.4.8-dev + +Add support for parsing the `@mixin` rule. + +Add support for parsing the `@return` rule. + ## 0.4.7 * No user-visible changes. diff --git a/pkg/sass-parser/lib/index.ts b/pkg/sass-parser/lib/index.ts index a3db171fd..8f1b22778 100644 --- a/pkg/sass-parser/lib/index.ts +++ b/pkg/sass-parser/lib/index.ts @@ -104,6 +104,16 @@ export { GenericAtRuleProps, GenericAtRuleRaws, } from './src/statement/generic-at-rule'; +export { + MixinRule, + MixinRuleProps, + MixinRuleRaws, +} from './src/statement/mixin-rule'; +export { + ReturnRule, + ReturnRuleProps, + ReturnRuleRaws, +} from './src/statement/return-rule'; export {Root, RootProps, RootRaws} from './src/statement/root'; export {Rule, RuleProps, RuleRaws} from './src/statement/rule'; export { diff --git a/pkg/sass-parser/lib/src/sass-internal.ts b/pkg/sass-parser/lib/src/sass-internal.ts index 48574f451..325eddb2b 100644 --- a/pkg/sass-parser/lib/src/sass-internal.ts +++ b/pkg/sass-parser/lib/src/sass-internal.ts @@ -147,6 +147,15 @@ declare namespace SassInternal { readonly query: Interpolation; } + class MixinRule extends ParentStatement { + readonly name: string; + readonly arguments: ArgumentDeclaration; + } + + class ReturnRule extends Statement { + readonly expression: Expression; + } + class SilentComment extends Statement { readonly text: string; } @@ -285,6 +294,8 @@ export type ForwardRule = SassInternal.ForwardRule; export type FunctionRule = SassInternal.FunctionRule; export type LoudComment = SassInternal.LoudComment; export type MediaRule = SassInternal.MediaRule; +export type MixinRule = SassInternal.MixinRule; +export type ReturnRule = SassInternal.ReturnRule; export type SilentComment = SassInternal.SilentComment; export type Stylesheet = SassInternal.Stylesheet; export type StyleRule = SassInternal.StyleRule; @@ -315,6 +326,8 @@ export interface StatementVisitorObject { visitFunctionRule(node: FunctionRule): T; visitLoudComment(node: LoudComment): T; visitMediaRule(node: MediaRule): T; + visitMixinRule(node: MixinRule): T; + visitReturnRule(node: ReturnRule): T; visitSilentComment(node: SilentComment): T; visitStyleRule(node: StyleRule): T; visitSupportsRule(node: SupportsRule): T; diff --git a/pkg/sass-parser/lib/src/statement/__snapshots__/mixin-rule.test.ts.snap b/pkg/sass-parser/lib/src/statement/__snapshots__/mixin-rule.test.ts.snap new file mode 100644 index 000000000..553da4b9e --- /dev/null +++ b/pkg/sass-parser/lib/src/statement/__snapshots__/mixin-rule.test.ts.snap @@ -0,0 +1,21 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`a @mixin rule toJSON 1`] = ` +{ + "inputs": [ + { + "css": "@mixin foo($bar) {}", + "hasBOM": false, + "id": "", + }, + ], + "mixinName": "foo", + "name": "mixin", + "nodes": [], + "parameters": <($bar)>, + "raws": {}, + "sassType": "mixin-rule", + "source": <1:1-1:20 in 0>, + "type": "atrule", +} +`; diff --git a/pkg/sass-parser/lib/src/statement/__snapshots__/return-rule.test.ts.snap b/pkg/sass-parser/lib/src/statement/__snapshots__/return-rule.test.ts.snap new file mode 100644 index 000000000..e7880d8b5 --- /dev/null +++ b/pkg/sass-parser/lib/src/statement/__snapshots__/return-rule.test.ts.snap @@ -0,0 +1,20 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`a @return rule toJSON 1`] = ` +{ + "inputs": [ + { + "css": "@function x() {@return foo}", + "hasBOM": false, + "id": "", + }, + ], + "name": "return", + "params": "foo", + "raws": {}, + "returnExpression": , + "sassType": "return-rule", + "source": <1:16-1:27 in 0>, + "type": "atrule", +} +`; diff --git a/pkg/sass-parser/lib/src/statement/function-rule.test.ts b/pkg/sass-parser/lib/src/statement/function-rule.test.ts index 0c2579ab8..3b2ce97eb 100644 --- a/pkg/sass-parser/lib/src/statement/function-rule.test.ts +++ b/pkg/sass-parser/lib/src/statement/function-rule.test.ts @@ -2,7 +2,7 @@ // 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 {FunctionRule, ParameterList, ReturnRule, sass, scss} from '../..'; import * as utils from '../../../test/utils'; describe('a @function rule', () => { @@ -49,60 +49,67 @@ describe('a @function rule', () => { ); }); - // 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('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({ + functionName: 'foo', + parameters: ['bar'], + nodes: [{returnExpression: {text: 'baz'}}], + }), + ); + + describeNode('constructed from ChildProps', () => + utils.fromChildProps({ + functionName: 'foo', + parameters: ['bar'], + nodes: [{returnExpression: {text: 'baz'}}], + }), + ); + }); describe('throws an error when assigned a new', () => { beforeEach( diff --git a/pkg/sass-parser/lib/src/statement/index.ts b/pkg/sass-parser/lib/src/statement/index.ts index ac4fff418..b5bf47e4a 100644 --- a/pkg/sass-parser/lib/src/statement/index.ts +++ b/pkg/sass-parser/lib/src/statement/index.ts @@ -18,6 +18,8 @@ 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 {MixinRule, MixinRuleProps} from './mixin-rule'; +import {ReturnRule, ReturnRuleProps} from './return-rule'; import {Root} from './root'; import {Rule, RuleProps} from './rule'; import {UseRule, UseRuleProps} from './use-rule'; @@ -55,12 +57,14 @@ export type StatementType = | 'comment' | 'debug-rule' | 'each-rule' + | 'error-rule' | 'for-rule' | 'forward-rule' | 'function-rule' - | 'error-rule' - | 'use-rule' + | 'mixin-rule' + | 'return-rule' | 'sass-comment' + | 'use-rule' | 'variable-declaration' | 'warn-rule' | 'while-rule'; @@ -78,6 +82,8 @@ export type AtRule = | ForwardRule | FunctionRule | GenericAtRule + | MixinRule + | ReturnRule | UseRule | WarnRule | WhileRule; @@ -115,6 +121,8 @@ export type ChildProps = | ForwardRuleProps | FunctionRuleProps | GenericAtRuleProps + | MixinRuleProps + | ReturnRuleProps | RuleProps | SassCommentChildProps | UseRuleProps @@ -195,6 +203,8 @@ const visitor = sassInternal.createStatementVisitor({ appendInternalChildren(rule, inner.children); return rule; }, + visitMixinRule: inner => new MixinRule(undefined, inner), + visitReturnRule: inner => new ReturnRule(undefined, inner), visitSilentComment: inner => new SassComment(undefined, inner), visitStyleRule: inner => new Rule(undefined, inner), visitSupportsRule: inner => { @@ -319,18 +329,22 @@ export function normalize( result.push(new DebugRule(node)); } else if ('eachExpression' in node) { result.push(new EachRule(node)); + } else if ('errorExpression' in node) { + result.push(new ErrorRule(node)); } else if ('fromExpression' in node) { 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) { - result.push(new CssComment(node as CssCommentProps)); + } else if ('mixinName' in node) { + result.push(new MixinRule(node)); + } else if ('returnExpression' in node) { + result.push(new ReturnRule(node)); } else if ('silentText' in node) { result.push(new SassComment(node)); + } else if ('text' in node || 'textInterpolation' in node) { + result.push(new CssComment(node as CssCommentProps)); } else if ('useUrl' in node) { result.push(new UseRule(node)); } else if ('variableName' in node) { diff --git a/pkg/sass-parser/lib/src/statement/mixin-rule.test.ts b/pkg/sass-parser/lib/src/statement/mixin-rule.test.ts new file mode 100644 index 000000000..a1e7665fb --- /dev/null +++ b/pkg/sass-parser/lib/src/statement/mixin-rule.test.ts @@ -0,0 +1,296 @@ +// 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 {GenericAtRule, MixinRule, ParameterList, sass, scss} from '../..'; +import * as utils from '../../../test/utils'; + +describe('a @mixin rule', () => { + let node: MixinRule; + describe('with empty children', () => { + function describeNode(description: string, create: () => MixinRule): void { + describe(description, () => { + beforeEach(() => void (node = create())); + + it('has a name', () => expect(node.name.toString()).toBe('mixin')); + + it('has a mixin name', () => + expect(node.mixinName.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('@mixin foo($bar) {}').nodes[0] as MixinRule, + ); + + describeNode( + 'parsed as Sass', + () => sass.parse('@mixin foo($bar)').nodes[0] as MixinRule, + ); + + describeNode( + 'constructed manually', + () => new MixinRule({mixinName: 'foo', parameters: ['bar']}), + ); + + describeNode('constructed from ChildProps', () => + utils.fromChildProps({mixinName: 'foo', parameters: ['bar']}), + ); + }); + + describe('with a child', () => { + function describeNode(description: string, create: () => MixinRule): void { + describe(description, () => { + beforeEach(() => void (node = create())); + + it('has a name', () => expect(node.name.toString()).toBe('mixin')); + + it('has a mixin name', () => + expect(node.mixinName.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(GenericAtRule); + expect(node.nodes[0]).toHaveInterpolation('nameInterpolation', 'baz'); + }); + }); + } + + describeNode( + 'parsed as SCSS', + () => scss.parse('@mixin foo($bar) {@baz}').nodes[0] as MixinRule, + ); + + describeNode( + 'parsed as Sass', + () => sass.parse('@mixin foo($bar)\n @baz').nodes[0] as MixinRule, + ); + + describeNode( + 'constructed manually', + () => + new MixinRule({ + mixinName: 'foo', + parameters: ['bar'], + nodes: [{nameInterpolation: 'baz'}], + }), + ); + + describeNode('constructed from ChildProps', () => + utils.fromChildProps({ + mixinName: 'foo', + parameters: ['bar'], + nodes: [{nameInterpolation: 'baz'}], + }), + ); + }); + + describe('throws an error when assigned a new', () => { + beforeEach( + () => + void (node = scss.parse('@mixin foo($bar) {}').nodes[0] as MixinRule), + ); + + it('name', () => expect(() => (node.name = 'qux')).toThrow()); + + it('params', () => expect(() => (node.params = 'zip($zap)')).toThrow()); + }); + + describe('assigned new parameters', () => { + beforeEach( + () => + void (node = scss.parse('@mixin foo($bar) {}').nodes[0] as MixinRule), + ); + + 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 MixinRule({ + mixinName: 'foo', + parameters: ['bar'], + }).toString(), + ).toBe('@mixin foo($bar) {}')); + + it('with a non-identifier name', () => + expect( + new MixinRule({ + mixinName: 'f o', + parameters: ['bar'], + }).toString(), + ).toBe('@mixin f\\20o($bar) {}')); + + it('with afterName', () => + expect( + new MixinRule({ + mixinName: 'foo', + parameters: ['bar'], + raws: {afterName: '/**/'}, + }).toString(), + ).toBe('@mixin/**/foo($bar) {}')); + + it('with matching mixinName', () => + expect( + new MixinRule({ + mixinName: 'foo', + parameters: ['bar'], + raws: {mixinName: {value: 'foo', raw: 'f\\6fo'}}, + }).toString(), + ).toBe('@mixin f\\6fo($bar) {}')); + + it('with non-matching mixinName', () => + expect( + new MixinRule({ + mixinName: 'foo', + parameters: ['bar'], + raws: {mixinName: {value: 'fao', raw: 'f\\41o'}}, + }).toString(), + ).toBe('@mixin foo($bar) {}')); + }); + }); + + describe('clone', () => { + let original: MixinRule; + beforeEach(() => { + original = scss.parse('@mixin foo($bar) {}').nodes[0] as MixinRule; + // TODO: remove this once raws are properly parsed + original.raws.between = ' '; + }); + + describe('with no overrides', () => { + let clone: MixinRule; + beforeEach(() => void (clone = original.clone())); + + describe('has the same properties:', () => { + it('params', () => expect(clone.params).toBe('foo($bar)')); + + it('mixinName', () => expect(clone.mixinName).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('mixinName', () => { + describe('defined', () => { + let clone: MixinRule; + beforeEach(() => { + clone = original.clone({mixinName: 'baz'}); + }); + + it('changes params', () => expect(clone.params).toBe('baz($bar)')); + + it('changes mixinName', () => expect(clone.mixinName).toEqual('baz')); + }); + + describe('undefined', () => { + let clone: MixinRule; + beforeEach(() => { + clone = original.clone({mixinName: undefined}); + }); + + it('preserves params', () => expect(clone.params).toBe('foo($bar)')); + + it('preserves mixinName', () => + expect(clone.mixinName).toEqual('foo')); + }); + }); + + describe('parameters', () => { + describe('defined', () => { + let clone: MixinRule; + 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: MixinRule; + 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('@mixin foo($bar) {}').nodes[0]).toMatchSnapshot()); +}); diff --git a/pkg/sass-parser/lib/src/statement/mixin-rule.ts b/pkg/sass-parser/lib/src/statement/mixin-rule.ts new file mode 100644 index 000000000..8020f5fb4 --- /dev/null +++ b/pkg/sass-parser/lib/src/statement/mixin-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 MixinRule}. + * + * @category Statement + */ +export interface MixinRuleRaws extends Omit { + /** + * The mixin's name. + * + * This may be different than {@link Mixin.mixinName} if the name contains + * escape codes or underscores. + */ + mixinName?: RawWithValue; +} + +/** + * The initializer properties for {@link MixinRule}. + * + * @category Statement + */ +export type MixinRuleProps = ContainerProps & { + raws?: MixinRuleRaws; + mixinName: string; + parameters?: ParameterList | ParameterListProps; +}; + +/** + * A `@mixin` rule. Extends [`postcss.AtRule`]. + * + * [`postcss.AtRule`]: https://postcss.org/api/#atrule + * + * @category Statement + */ +export class MixinRule + extends _AtRule> + implements Statement +{ + readonly sassType = 'mixin-rule' as const; + declare parent: StatementWithChildren | undefined; + declare raws: MixinRuleRaws; + declare nodes: ChildNode[]; + + /** + * The name of the mixin. + * + * This is the parsed and normalized value, with underscores converted to + * hyphens and escapes resolved to the characters they represent. + */ + declare mixinName: string; + + /** The parameters that this mixin 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 'mixin'; + } + set name(value: string) { + throw new Error("MixinRule.name can't be overwritten."); + } + + get params(): string { + return ( + (this.raws.mixinName?.value === this.mixinName + ? this.raws.mixinName!.raw + : sassInternal.toCssIdentifier(this.mixinName)) + this.parameters + ); + } + set params(value: string | number | undefined) { + throw new Error("MixinRule.params can't be overwritten."); + } + + constructor(defaults: MixinRuleProps); + /** @hidden */ + constructor(_: undefined, inner: sassInternal.MixinRule); + constructor(defaults?: MixinRuleProps, inner?: sassInternal.MixinRule) { + super(defaults as unknown as postcss.AtRuleProps); + this.nodes ??= []; + + if (inner) { + this.source = new LazySource(inner); + this.mixinName = inner.name; + this.parameters = new ParameterList(undefined, inner.arguments); + appendInternalChildren(this, inner.children); + } + } + + clone(overrides?: Partial): this { + return utils.cloneNode(this, overrides, [ + 'raws', + 'mixinName', + 'parameters', + ]); + } + + toJSON(): object; + /** @hidden */ + toJSON(_: string, inputs: Map): object; + toJSON(_?: string, inputs?: Map): object { + return utils.toJSON( + this, + ['name', 'mixinName', '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(MixinRule); diff --git a/pkg/sass-parser/lib/src/statement/return-rule.test.ts b/pkg/sass-parser/lib/src/statement/return-rule.test.ts new file mode 100644 index 000000000..eaf371870 --- /dev/null +++ b/pkg/sass-parser/lib/src/statement/return-rule.test.ts @@ -0,0 +1,216 @@ +// 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, ReturnRule, StringExpression, sass, scss} from '../..'; +import * as utils from '../../../test/utils'; + +describe('a @return rule', () => { + let node: ReturnRule; + function describeNode(description: string, create: () => ReturnRule): void { + describe(description, () => { + beforeEach(() => void (node = create())); + + it('has a name', () => expect(node.name.toString()).toBe('return')); + + it('has an expression', () => + expect(node).toHaveStringExpression('returnExpression', 'foo')); + + it('has matching params', () => expect(node.params).toBe('foo')); + + it('has undefined nodes', () => expect(node.nodes).toBeUndefined()); + }); + } + + describeNode( + 'parsed as SCSS', + () => + (scss.parse('@function x() {@return foo}').nodes[0] as FunctionRule) + .nodes[0] as ReturnRule, + ); + + describeNode( + 'parsed as Sass', + () => + (sass.parse('@function x()\n @return foo').nodes[0] as FunctionRule) + .nodes[0] as ReturnRule, + ); + + describeNode( + 'constructed manually', + () => + new ReturnRule({ + returnExpression: {text: 'foo'}, + }), + ); + + describeNode('constructed from ChildProps', () => + utils.fromChildProps({ + returnExpression: {text: 'foo'}, + }), + ); + + it('throws an error when assigned a new name', () => + expect( + () => + (new ReturnRule({ + returnExpression: {text: 'foo'}, + }).name = 'bar'), + ).toThrow()); + + describe('assigned a new expression', () => { + beforeEach(() => { + node = ( + scss.parse('@function x() {@return foo}').nodes[0] as FunctionRule + ).nodes[0] as ReturnRule; + }); + + it('sets an empty string expression as undefined params', () => { + node.params = undefined; + expect(node.params).toBe(''); + expect(node).toHaveStringExpression('returnExpression', ''); + }); + + it('sets an empty string expression as empty string params', () => { + node.params = ''; + expect(node.params).toBe(''); + expect(node).toHaveStringExpression('returnExpression', ''); + }); + + it("removes the old expression's parent", () => { + const oldExpression = node.returnExpression; + node.returnExpression = {text: 'bar'}; + expect(oldExpression.parent).toBeUndefined(); + }); + + it("assigns the new expression's parent", () => { + const expression = new StringExpression({text: 'bar'}); + node.returnExpression = expression; + expect(expression.parent).toBe(node); + }); + + it('assigns the expression explicitly', () => { + const expression = new StringExpression({text: 'bar'}); + node.returnExpression = expression; + expect(node.returnExpression).toBe(expression); + }); + + it('assigns the expression as ExpressionProps', () => { + node.returnExpression = {text: 'bar'}; + expect(node).toHaveStringExpression('returnExpression', 'bar'); + }); + + it('assigns the expression as params', () => { + node.params = 'bar'; + expect(node).toHaveStringExpression('returnExpression', 'bar'); + }); + }); + + describe('stringifies', () => { + describe('to SCSS', () => { + it('with default raws', () => + expect( + new ReturnRule({ + returnExpression: {text: 'foo'}, + }).toString(), + ).toBe('@return foo;')); + + it('with afterName', () => + expect( + new ReturnRule({ + returnExpression: {text: 'foo'}, + raws: {afterName: '/**/'}, + }).toString(), + ).toBe('@return/**/foo;')); + + it('with between', () => + expect( + new ReturnRule({ + returnExpression: {text: 'foo'}, + raws: {between: '/**/'}, + }).toString(), + ).toBe('@return foo/**/;')); + }); + }); + + describe('clone', () => { + let original: ReturnRule; + beforeEach(() => { + original = ( + scss.parse('@function x() {@return foo}').nodes[0] as FunctionRule + ).nodes[0] as ReturnRule; + // TODO: remove this once raws are properly parsed + original.raws.between = ' '; + }); + + describe('with no overrides', () => { + let clone: ReturnRule; + beforeEach(() => void (clone = original.clone())); + + describe('has the same properties:', () => { + it('params', () => expect(clone.params).toBe('foo')); + + it('returnExpression', () => + expect(clone).toHaveStringExpression('returnExpression', 'foo')); + + it('raws', () => expect(clone.raws).toEqual({between: ' '})); + + it('source', () => expect(clone.source).toBe(original.source)); + }); + + describe('creates a new', () => { + it('self', () => expect(clone).not.toBe(original)); + + for (const attr of ['returnExpression', '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('returnExpression', () => { + describe('defined', () => { + let clone: ReturnRule; + beforeEach(() => { + clone = original.clone({returnExpression: {text: 'bar'}}); + }); + + it('changes params', () => expect(clone.params).toBe('bar')); + + it('changes returnExpression', () => + expect(clone).toHaveStringExpression('returnExpression', 'bar')); + }); + + describe('undefined', () => { + let clone: ReturnRule; + beforeEach(() => { + clone = original.clone({returnExpression: undefined}); + }); + + it('preserves params', () => expect(clone.params).toBe('foo')); + + it('preserves returnExpression', () => + expect(clone).toHaveStringExpression('returnExpression', 'foo')); + }); + }); + }); + }); + + it('toJSON', () => + expect( + (scss.parse('@function x() {@return foo}').nodes[0] as FunctionRule) + .nodes[0], + ).toMatchSnapshot()); +}); diff --git a/pkg/sass-parser/lib/src/statement/return-rule.ts b/pkg/sass-parser/lib/src/statement/return-rule.ts new file mode 100644 index 000000000..56608da5d --- /dev/null +++ b/pkg/sass-parser/lib/src/statement/return-rule.ts @@ -0,0 +1,129 @@ +// Copyright 2024 Google Inc. Use of this source code is governed by an +// MIT-style license that can be found in the LICENSE file or at +// https://opensource.org/licenses/MIT. + +import * as postcss from 'postcss'; +import type {AtRuleRaws as PostcssAtRuleRaws} from 'postcss/lib/at-rule'; + +import {convertExpression} from '../expression/convert'; +import {Expression, ExpressionProps} from '../expression'; +import {fromProps} from '../expression/from-props'; +import {LazySource} from '../lazy-source'; +import type * as sassInternal from '../sass-internal'; +import * as utils from '../utils'; +import {Statement, StatementWithChildren} from '.'; +import {_AtRule} from './at-rule-internal'; +import {interceptIsClean} from './intercept-is-clean'; +import * as sassParser from '../..'; + +/** + * The set of raws supported by {@link ReturnRule}. + * + * @category Statement + */ +export type ReturnRuleRaws = Pick< + PostcssAtRuleRaws, + 'afterName' | 'before' | 'between' +>; + +/** + * The initializer properties for {@link ReturnRule}. + * + * @category Statement + */ +export type ReturnRuleProps = postcss.NodeProps & { + raws?: ReturnRuleRaws; + returnExpression: Expression | ExpressionProps; +}; + +/** + * A `@return` rule. Extends [`postcss.AtRule`]. + * + * [`postcss.AtRule`]: https://postcss.org/api/#atrule + * + * @category Statement + */ +export class ReturnRule + extends _AtRule> + implements Statement +{ + readonly sassType = 'return-rule' as const; + declare parent: StatementWithChildren | undefined; + declare raws: ReturnRuleRaws; + declare readonly nodes: undefined; + + get name(): string { + return 'return'; + } + set name(value: string) { + throw new Error("ReturnRule.name can't be overwritten."); + } + + get params(): string { + return this.returnExpression.toString(); + } + set params(value: string | number | undefined) { + this.returnExpression = {text: value?.toString() ?? ''}; + } + + /** The expresison whose value is emitted when the return rule is executed. */ + get returnExpression(): Expression { + return this._returnExpression!; + } + set returnExpression(returnExpression: Expression | ExpressionProps) { + if (this._returnExpression) this._returnExpression.parent = undefined; + if (!('sassType' in returnExpression)) { + returnExpression = fromProps(returnExpression); + } + if (returnExpression) returnExpression.parent = this; + this._returnExpression = returnExpression; + } + private _returnExpression?: Expression; + + constructor(defaults: ReturnRuleProps); + /** @hidden */ + constructor(_: undefined, inner: sassInternal.ReturnRule); + constructor(defaults?: ReturnRuleProps, inner?: sassInternal.ReturnRule) { + super(defaults as unknown as postcss.AtRuleProps); + + if (inner) { + this.source = new LazySource(inner); + this.returnExpression = convertExpression(inner.expression); + } + } + + clone(overrides?: Partial): this { + return utils.cloneNode( + this, + overrides, + ['raws', 'returnExpression'], + [{name: 'params', explicitUndefined: true}], + ); + } + + toJSON(): object; + /** @hidden */ + toJSON(_: string, inputs: Map): object; + toJSON(_?: string, inputs?: Map): object { + return utils.toJSON( + this, + ['name', 'returnExpression', 'params', 'nodes'], + inputs, + ); + } + + /** @hidden */ + toString( + stringifier: postcss.Stringifier | postcss.Syntax = sassParser.scss + .stringify, + ): string { + return super.toString(stringifier); + } + + /** @hidden */ + get nonStatementChildren(): ReadonlyArray { + return [this.returnExpression]; + } +} + +interceptIsClean(ReturnRule); diff --git a/pkg/sass-parser/lib/src/stringifier.ts b/pkg/sass-parser/lib/src/stringifier.ts index 3e0499ebc..d54ebb24c 100644 --- a/pkg/sass-parser/lib/src/stringifier.ts +++ b/pkg/sass-parser/lib/src/stringifier.ts @@ -36,6 +36,8 @@ 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 {MixinRule} from './statement/mixin-rule'; +import {ReturnRule} from './statement/return-rule'; import {Rule} from './statement/rule'; import {SassComment} from './statement/sass-comment'; import {UseRule} from './statement/use-rule'; @@ -101,6 +103,10 @@ export class Stringifier extends PostCssStringifier { this.sassAtRule(node, semicolon); } + private ['mixin-rule'](node: MixinRule, 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 {...}`. @@ -134,6 +140,10 @@ export class Stringifier extends PostCssStringifier { } } + private ['return-rule'](node: ReturnRule, semicolon: boolean): void { + this.sassAtRule(node, semicolon); + } + private rule(node: Rule): void { this.block(node, node.selectorInterpolation.toString()); } diff --git a/pkg/sass-parser/package.json b/pkg/sass-parser/package.json index bd9bc108c..2693992ee 100644 --- a/pkg/sass-parser/package.json +++ b/pkg/sass-parser/package.json @@ -1,6 +1,6 @@ { "name": "sass-parser", - "version": "0.4.7", + "version": "0.4.8-dev", "description": "A PostCSS-compatible wrapper of the official Sass parser", "repository": "sass/sass", "author": "Google Inc.", diff --git a/pkg/sass_api/CHANGELOG.md b/pkg/sass_api/CHANGELOG.md index b6af1a883..56eae578f 100644 --- a/pkg/sass_api/CHANGELOG.md +++ b/pkg/sass_api/CHANGELOG.md @@ -1,3 +1,7 @@ +## 14.4.1-dev + +* No user-visible changes. + ## 14.4.0 * No user-visible changes. diff --git a/pkg/sass_api/pubspec.yaml b/pkg/sass_api/pubspec.yaml index a3f48aa05..30e6dee11 100644 --- a/pkg/sass_api/pubspec.yaml +++ b/pkg/sass_api/pubspec.yaml @@ -2,7 +2,7 @@ name: sass_api # Note: Every time we add a new Sass AST node, we need to bump the *major* # version because it's a breaking change for anyone who's implementing the # visitor interface(s). -version: 14.4.0 +version: 14.4.1-dev description: Additional APIs for Dart Sass. homepage: https://github.com/sass/dart-sass @@ -10,7 +10,7 @@ environment: sdk: ">=3.3.0 <4.0.0" dependencies: - sass: 1.82.0 + sass: 1.82.1 dev_dependencies: dartdoc: ^8.0.14 diff --git a/pubspec.yaml b/pubspec.yaml index 589fb93cc..08e11e159 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,5 +1,5 @@ name: sass -version: 1.82.0 +version: 1.82.1-dev description: A Sass implementation in Dart. homepage: https://github.com/sass/dart-sass