From bf50fad3c0248bca45f260174bbe3883b75529c9 Mon Sep 17 00:00:00 2001 From: Natalie Weizenbaum Date: Wed, 4 Dec 2024 16:26:11 -0800 Subject: [PATCH 1/2] Explicitly annotate GenericAtRule as having optional nodes --- .../lib/src/statement/container.test.ts | 364 ++++++++++-------- .../lib/src/statement/generic-at-rule.ts | 4 +- pkg/sass-parser/lib/src/stringifier.ts | 1 + 3 files changed, 197 insertions(+), 172 deletions(-) diff --git a/pkg/sass-parser/lib/src/statement/container.test.ts b/pkg/sass-parser/lib/src/statement/container.test.ts index 52e736787..d6ca1da91 100644 --- a/pkg/sass-parser/lib/src/statement/container.test.ts +++ b/pkg/sass-parser/lib/src/statement/container.test.ts @@ -6,183 +6,207 @@ import * as postcss from 'postcss'; import {GenericAtRule, Root, Rule} from '../..'; -let root: Root; describe('a container node', () => { - beforeEach(() => { - root = new Root(); - }); - - describe('can add', () => { - it('a single Sass node', () => { - const rule = new Rule({selector: '.foo'}); - root.append(rule); - expect(root.nodes).toEqual([rule]); - expect(rule.parent).toBe(root); - }); - - it('a list of Sass nodes', () => { - const rule1 = new Rule({selector: '.foo'}); - const rule2 = new Rule({selector: '.bar'}); - root.append([rule1, rule2]); - expect(root.nodes).toEqual([rule1, rule2]); - expect(rule1.parent).toBe(root); - expect(rule2.parent).toBe(root); - }); - - it('a Sass root node', () => { - const rule1 = new Rule({selector: '.foo'}); - const rule2 = new Rule({selector: '.bar'}); - const otherRoot = new Root({nodes: [rule1, rule2]}); - root.append(otherRoot); - expect(root.nodes[0]).toBeInstanceOf(Rule); - expect(root.nodes[0]).toHaveInterpolation( - 'selectorInterpolation', - '.foo', - ); - expect(root.nodes[1]).toBeInstanceOf(Rule); - expect(root.nodes[1]).toHaveInterpolation( - 'selectorInterpolation', - '.bar', - ); - expect(root.nodes[0].parent).toBe(root); - expect(root.nodes[1].parent).toBe(root); - expect(rule1.parent).toBeUndefined(); - expect(rule2.parent).toBeUndefined(); + describe('with nodes', () => { + let root: Root; + beforeEach(() => { + root = new Root(); }); - it('a PostCSS rule node', () => { - const node = postcss.parse('.foo {}').nodes[0]; - root.append(node); - expect(root.nodes[0]).toBeInstanceOf(Rule); - expect(root.nodes[0]).toHaveInterpolation( - 'selectorInterpolation', - '.foo', - ); - expect(root.nodes[0].parent).toBe(root); - expect(root.nodes[0].source).toBe(node.source); - expect(node.parent).toBeUndefined(); - }); - - it('a PostCSS at-rule node', () => { - const node = postcss.parse('@foo bar').nodes[0]; - root.append(node); - expect(root.nodes[0]).toBeInstanceOf(GenericAtRule); - expect(root.nodes[0]).toHaveInterpolation('nameInterpolation', 'foo'); - expect(root.nodes[0]).toHaveInterpolation('paramsInterpolation', 'bar'); - expect(root.nodes[0].parent).toBe(root); - expect(root.nodes[0].source).toBe(node.source); - expect(node.parent).toBeUndefined(); - }); - - it('a list of PostCSS nodes', () => { - const rule1 = new postcss.Rule({selector: '.foo'}); - const rule2 = new postcss.Rule({selector: '.bar'}); - root.append([rule1, rule2]); - expect(root.nodes[0]).toBeInstanceOf(Rule); - expect(root.nodes[0]).toHaveInterpolation( - 'selectorInterpolation', - '.foo', - ); - expect(root.nodes[1]).toBeInstanceOf(Rule); - expect(root.nodes[1]).toHaveInterpolation( - 'selectorInterpolation', - '.bar', - ); - expect(root.nodes[0].parent).toBe(root); - expect(root.nodes[1].parent).toBe(root); - expect(rule1.parent).toBeUndefined(); - expect(rule2.parent).toBeUndefined(); - }); - - it('a PostCSS root node', () => { - const rule1 = new postcss.Rule({selector: '.foo'}); - const rule2 = new postcss.Rule({selector: '.bar'}); - const otherRoot = new postcss.Root({nodes: [rule1, rule2]}); - root.append(otherRoot); - expect(root.nodes[0]).toBeInstanceOf(Rule); - expect(root.nodes[0]).toHaveInterpolation( - 'selectorInterpolation', - '.foo', - ); - expect(root.nodes[1]).toBeInstanceOf(Rule); - expect(root.nodes[1]).toHaveInterpolation( - 'selectorInterpolation', - '.bar', - ); - expect(root.nodes[0].parent).toBe(root); - expect(root.nodes[1].parent).toBe(root); - expect(rule1.parent).toBeUndefined(); - expect(rule2.parent).toBeUndefined(); - }); - - it("a single Sass node's properties", () => { - root.append({selectorInterpolation: '.foo'}); - expect(root.nodes[0]).toBeInstanceOf(Rule); - expect(root.nodes[0]).toHaveInterpolation( - 'selectorInterpolation', - '.foo', - ); - expect(root.nodes[0].parent).toBe(root); - }); - - it("a single PostCSS node's properties", () => { - root.append({selector: '.foo'}); - expect(root.nodes[0]).toBeInstanceOf(Rule); - expect(root.nodes[0]).toHaveInterpolation( - 'selectorInterpolation', - '.foo', - ); - expect(root.nodes[0].parent).toBe(root); - }); - - it('a list of properties', () => { - root.append( - {selectorInterpolation: '.foo'}, - {selectorInterpolation: '.bar'}, - ); - expect(root.nodes[0]).toBeInstanceOf(Rule); - expect(root.nodes[0]).toHaveInterpolation( - 'selectorInterpolation', - '.foo', - ); - expect(root.nodes[1]).toBeInstanceOf(Rule); - expect(root.nodes[1]).toHaveInterpolation( - 'selectorInterpolation', - '.bar', - ); - expect(root.nodes[0].parent).toBe(root); - expect(root.nodes[1].parent).toBe(root); - }); - - it('a plain CSS string', () => { - root.append('.foo {}'); - expect(root.nodes[0]).toBeInstanceOf(Rule); - expect(root.nodes[0]).toHaveInterpolation( - 'selectorInterpolation', - '.foo', - ); - expect(root.nodes[0].parent).toBe(root); + describe('can add', () => { + it('a single Sass node', () => { + const rule = new Rule({selector: '.foo'}); + root.append(rule); + expect(root.nodes).toEqual([rule]); + expect(rule.parent).toBe(root); + }); + + it('a list of Sass nodes', () => { + const rule1 = new Rule({selector: '.foo'}); + const rule2 = new Rule({selector: '.bar'}); + root.append([rule1, rule2]); + expect(root.nodes).toEqual([rule1, rule2]); + expect(rule1.parent).toBe(root); + expect(rule2.parent).toBe(root); + }); + + it('a Sass root node', () => { + const rule1 = new Rule({selector: '.foo'}); + const rule2 = new Rule({selector: '.bar'}); + const otherRoot = new Root({nodes: [rule1, rule2]}); + root.append(otherRoot); + expect(root.nodes[0]).toBeInstanceOf(Rule); + expect(root.nodes[0]).toHaveInterpolation( + 'selectorInterpolation', + '.foo', + ); + expect(root.nodes[1]).toBeInstanceOf(Rule); + expect(root.nodes[1]).toHaveInterpolation( + 'selectorInterpolation', + '.bar', + ); + expect(root.nodes[0].parent).toBe(root); + expect(root.nodes[1].parent).toBe(root); + expect(rule1.parent).toBeUndefined(); + expect(rule2.parent).toBeUndefined(); + }); + + it('a PostCSS rule node', () => { + const node = postcss.parse('.foo {}').nodes[0]; + root.append(node); + expect(root.nodes[0]).toBeInstanceOf(Rule); + expect(root.nodes[0]).toHaveInterpolation( + 'selectorInterpolation', + '.foo', + ); + expect(root.nodes[0].parent).toBe(root); + expect(root.nodes[0].source).toBe(node.source); + expect(node.parent).toBeUndefined(); + }); + + it('a PostCSS at-rule node', () => { + const node = postcss.parse('@foo bar').nodes[0]; + root.append(node); + expect(root.nodes[0]).toBeInstanceOf(GenericAtRule); + expect(root.nodes[0]).toHaveInterpolation('nameInterpolation', 'foo'); + expect(root.nodes[0]).toHaveInterpolation('paramsInterpolation', 'bar'); + expect(root.nodes[0].parent).toBe(root); + expect(root.nodes[0].source).toBe(node.source); + expect(node.parent).toBeUndefined(); + }); + + it('a list of PostCSS nodes', () => { + const rule1 = new postcss.Rule({selector: '.foo'}); + const rule2 = new postcss.Rule({selector: '.bar'}); + root.append([rule1, rule2]); + expect(root.nodes[0]).toBeInstanceOf(Rule); + expect(root.nodes[0]).toHaveInterpolation( + 'selectorInterpolation', + '.foo', + ); + expect(root.nodes[1]).toBeInstanceOf(Rule); + expect(root.nodes[1]).toHaveInterpolation( + 'selectorInterpolation', + '.bar', + ); + expect(root.nodes[0].parent).toBe(root); + expect(root.nodes[1].parent).toBe(root); + expect(rule1.parent).toBeUndefined(); + expect(rule2.parent).toBeUndefined(); + }); + + it('a PostCSS root node', () => { + const rule1 = new postcss.Rule({selector: '.foo'}); + const rule2 = new postcss.Rule({selector: '.bar'}); + const otherRoot = new postcss.Root({nodes: [rule1, rule2]}); + root.append(otherRoot); + expect(root.nodes[0]).toBeInstanceOf(Rule); + expect(root.nodes[0]).toHaveInterpolation( + 'selectorInterpolation', + '.foo', + ); + expect(root.nodes[1]).toBeInstanceOf(Rule); + expect(root.nodes[1]).toHaveInterpolation( + 'selectorInterpolation', + '.bar', + ); + expect(root.nodes[0].parent).toBe(root); + expect(root.nodes[1].parent).toBe(root); + expect(rule1.parent).toBeUndefined(); + expect(rule2.parent).toBeUndefined(); + }); + + it("a single Sass node's properties", () => { + root.append({selectorInterpolation: '.foo'}); + expect(root.nodes[0]).toBeInstanceOf(Rule); + expect(root.nodes[0]).toHaveInterpolation( + 'selectorInterpolation', + '.foo', + ); + expect(root.nodes[0].parent).toBe(root); + }); + + it("a single PostCSS node's properties", () => { + root.append({selector: '.foo'}); + expect(root.nodes[0]).toBeInstanceOf(Rule); + expect(root.nodes[0]).toHaveInterpolation( + 'selectorInterpolation', + '.foo', + ); + expect(root.nodes[0].parent).toBe(root); + }); + + it('a list of properties', () => { + root.append( + {selectorInterpolation: '.foo'}, + {selectorInterpolation: '.bar'}, + ); + expect(root.nodes[0]).toBeInstanceOf(Rule); + expect(root.nodes[0]).toHaveInterpolation( + 'selectorInterpolation', + '.foo', + ); + expect(root.nodes[1]).toBeInstanceOf(Rule); + expect(root.nodes[1]).toHaveInterpolation( + 'selectorInterpolation', + '.bar', + ); + expect(root.nodes[0].parent).toBe(root); + expect(root.nodes[1].parent).toBe(root); + }); + + it('a plain CSS string', () => { + root.append('.foo {}'); + expect(root.nodes[0]).toBeInstanceOf(Rule); + expect(root.nodes[0]).toHaveInterpolation( + 'selectorInterpolation', + '.foo', + ); + expect(root.nodes[0].parent).toBe(root); + }); + + it('a list of plain CSS strings', () => { + root.append(['.foo {}', '.bar {}']); + expect(root.nodes[0]).toBeInstanceOf(Rule); + expect(root.nodes[0]).toHaveInterpolation( + 'selectorInterpolation', + '.foo', + ); + expect(root.nodes[1]).toBeInstanceOf(Rule); + expect(root.nodes[1]).toHaveInterpolation( + 'selectorInterpolation', + '.bar', + ); + expect(root.nodes[0].parent).toBe(root); + expect(root.nodes[1].parent).toBe(root); + }); + + it('undefined', () => { + root.append(undefined); + expect(root.nodes).toHaveLength(0); + }); }); + }); - it('a list of plain CSS strings', () => { - root.append(['.foo {}', '.bar {}']); - expect(root.nodes[0]).toBeInstanceOf(Rule); - expect(root.nodes[0]).toHaveInterpolation( - 'selectorInterpolation', - '.foo', - ); - expect(root.nodes[1]).toBeInstanceOf(Rule); - expect(root.nodes[1]).toHaveInterpolation( - 'selectorInterpolation', - '.bar', - ); - expect(root.nodes[0].parent).toBe(root); - expect(root.nodes[1].parent).toBe(root); + describe('without nodes', () => { + let rule: GenericAtRule; + beforeEach(() => { + rule = new GenericAtRule({name: 'foo'}); }); - it('undefined', () => { - root.append(undefined); - expect(root.nodes).toHaveLength(0); + describe('can add', () => { + it('a node', () => { + rule.append('@bar'); + expect(rule.nodes).not.toBeUndefined(); + expect(rule.nodes![0]).toBeInstanceOf(GenericAtRule); + expect(rule.nodes![0]).toHaveInterpolation('nameInterpolation', 'bar'); + }); + + it('undefined', () => { + rule.append(undefined); + expect(rule.nodes).not.toBeUndefined(); + expect(rule.nodes).toHaveLength(0); + }); }); }); }); diff --git a/pkg/sass-parser/lib/src/statement/generic-at-rule.ts b/pkg/sass-parser/lib/src/statement/generic-at-rule.ts index 2249993c2..02e1c5a32 100644 --- a/pkg/sass-parser/lib/src/statement/generic-at-rule.ts +++ b/pkg/sass-parser/lib/src/statement/generic-at-rule.ts @@ -72,7 +72,7 @@ export class GenericAtRule readonly sassType = 'atrule' as const; declare parent: StatementWithChildren | undefined; declare raws: GenericAtRuleRaws; - declare nodes: ChildNode[]; + declare nodes: ChildNode[] | undefined; get name(): string { return this.nameInterpolation.toString(); @@ -207,7 +207,7 @@ export class GenericAtRule /** @hidden */ normalize(node: NewNode, sample?: postcss.Node): ChildNode[] { - return normalize(this, node, sample); + return normalize(this as StatementWithChildren, node, sample); } } diff --git a/pkg/sass-parser/lib/src/stringifier.ts b/pkg/sass-parser/lib/src/stringifier.ts index d54ebb24c..4107180af 100644 --- a/pkg/sass-parser/lib/src/stringifier.ts +++ b/pkg/sass-parser/lib/src/stringifier.ts @@ -114,6 +114,7 @@ export class Stringifier extends PostCssStringifier { node.raws.atRootShorthand && node.name === 'at-root' && node.paramsInterpolation === undefined && + node.nodes && node.nodes.length === 1 && node.nodes[0].sassType === 'rule' ) { From bc39d0c6c3affd6d2267d8253c9c083f8b9f3d34 Mon Sep 17 00:00:00 2001 From: Natalie Weizenbaum Date: Thu, 12 Dec 2024 14:43:48 -0800 Subject: [PATCH 2/2] Add support for the `@include` rule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Carlos Israel Ortiz GarcĂ­a --- lib/src/js/parser.dart | 12 +- lib/src/js/utils.dart | 13 + pkg/sass-parser/CHANGELOG.md | 2 + pkg/sass-parser/lib/index.ts | 19 + .../__snapshots__/argument-list.test.ts.snap | 20 + .../src/__snapshots__/argument.test.ts.snap | 50 + pkg/sass-parser/lib/src/argument-list.test.ts | 903 ++++++++++++++++++ pkg/sass-parser/lib/src/argument-list.ts | 331 +++++++ pkg/sass-parser/lib/src/argument.test.ts | 514 ++++++++++ pkg/sass-parser/lib/src/argument.ts | 199 ++++ pkg/sass-parser/lib/src/node.d.ts | 4 +- pkg/sass-parser/lib/src/parameter.test.ts | 19 +- pkg/sass-parser/lib/src/parameter.ts | 32 +- pkg/sass-parser/lib/src/sass-internal.ts | 40 +- .../__snapshots__/include-rule.test.ts.snap | 68 ++ .../lib/src/statement/at-root-rule.test.ts | 2 +- .../lib/src/statement/function-rule.test.ts | 4 + .../lib/src/statement/function-rule.ts | 4 +- .../lib/src/statement/generic-at-rule.test.ts | 14 +- .../lib/src/statement/include-rule.test.ts | 581 +++++++++++ .../lib/src/statement/include-rule.ts | 251 +++++ pkg/sass-parser/lib/src/statement/index.ts | 7 + .../lib/src/statement/variable-declaration.ts | 2 +- pkg/sass-parser/lib/src/stringifier.ts | 4 + 24 files changed, 3062 insertions(+), 33 deletions(-) create mode 100644 pkg/sass-parser/lib/src/__snapshots__/argument-list.test.ts.snap create mode 100644 pkg/sass-parser/lib/src/__snapshots__/argument.test.ts.snap create mode 100644 pkg/sass-parser/lib/src/argument-list.test.ts create mode 100644 pkg/sass-parser/lib/src/argument-list.ts create mode 100644 pkg/sass-parser/lib/src/argument.test.ts create mode 100644 pkg/sass-parser/lib/src/argument.ts create mode 100644 pkg/sass-parser/lib/src/statement/__snapshots__/include-rule.test.ts.snap create mode 100644 pkg/sass-parser/lib/src/statement/include-rule.test.ts create mode 100644 pkg/sass-parser/lib/src/statement/include-rule.ts diff --git a/lib/src/js/parser.dart b/lib/src/js/parser.dart index 79d9a5cc9..aa2234608 100644 --- a/lib/src/js/parser.dart +++ b/lib/src/js/parser.dart @@ -20,6 +20,7 @@ import '../visitor/interface/expression.dart'; import '../visitor/interface/statement.dart'; import 'reflection.dart'; import 'set.dart'; +import 'utils.dart'; import 'visitor/expression.dart'; import 'visitor/statement.dart'; @@ -32,7 +33,8 @@ class ParserExports { required Function toCssIdentifier, required Function createExpressionVisitor, required Function createStatementVisitor, - required Function setToJS}); + required Function setToJS, + required Function mapToRecord}); external set parse(Function function); external set parseIdentifier(Function function); @@ -40,6 +42,7 @@ class ParserExports { external set createStatementVisitor(Function function); external set createExpressionVisitor(Function function); external set setToJS(Function function); + external set mapToRecord(Function function); } /// An empty interpolation, used to initialize empty AST entries to modify their @@ -61,7 +64,8 @@ ParserExports loadParserExports() { (JSExpressionVisitorObject inner) => JSExpressionVisitor(inner)), createStatementVisitor: allowInterop( (JSStatementVisitorObject inner) => JSStatementVisitor(inner)), - setToJS: allowInterop((Set set) => JSSet([...set]))); + setToJS: allowInterop((Set set) => JSSet([...set])), + mapToRecord: allowInterop(mapToObject)); } /// Modifies the prototypes of the Sass AST classes to provide access to JS. @@ -88,6 +92,10 @@ void _updateAstPrototypes() { 'accept', (Expression self, ExpressionVisitor visitor) => self.accept(visitor)); + var arguments = ArgumentList([], {}, bogusSpan); + var include = IncludeRule('a', arguments, bogusSpan); + getJSClass(include) + .defineGetter('arguments', (IncludeRule self) => self.arguments); _addSupportsConditionToInterpolation(); diff --git a/lib/src/js/utils.dart b/lib/src/js/utils.dart index e291054b5..abaf6e8ef 100644 --- a/lib/src/js/utils.dart +++ b/lib/src/js/utils.dart @@ -11,6 +11,7 @@ import 'package:js/js_util.dart'; import '../syntax.dart'; import '../utils.dart'; +import '../util/map.dart'; import '../value.dart'; import 'array.dart'; import 'function.dart'; @@ -223,6 +224,18 @@ Map objectToMap(Object object) { return map; } +@JS("Object") +external JSClass get _jsObjectClass; + +/// Converts a JavaScript record into a map from property names to their values. +Object mapToObject(Map map) { + var result = callConstructor(_jsObjectClass, const []); + for (var (key, value) in map.pairs) { + setProperty(result, key, value); + } + return result; +} + /// Converts a JavaScript separator string into a [ListSeparator]. ListSeparator jsToDartSeparator(String? separator) => switch (separator) { ' ' => ListSeparator.space, diff --git a/pkg/sass-parser/CHANGELOG.md b/pkg/sass-parser/CHANGELOG.md index f076e5c86..4f8ebeb30 100644 --- a/pkg/sass-parser/CHANGELOG.md +++ b/pkg/sass-parser/CHANGELOG.md @@ -1,5 +1,7 @@ ## 0.4.8 +Add support for parsing the `@include` rule. + Add support for parsing the `@mixin` rule. Add support for parsing the `@return` rule. diff --git a/pkg/sass-parser/lib/index.ts b/pkg/sass-parser/lib/index.ts index 8f1b22778..1fb3a90a2 100644 --- a/pkg/sass-parser/lib/index.ts +++ b/pkg/sass-parser/lib/index.ts @@ -8,6 +8,20 @@ import {Root} from './src/statement/root'; import * as sassInternal from './src/sass-internal'; import {Stringifier} from './src/stringifier'; +export { + Argument, + ArgumentExpressionProps, + ArgumentObjectProps, + ArgumentProps, + ArgumentRaws, +} from './src/argument'; +export { + ArgumentList, + ArgumentListObjectProps, + ArgumentListProps, + ArgumentListRaws, + NewArguments, +} from './src/argument-list'; export { Configuration, ConfigurationProps, @@ -50,6 +64,11 @@ export { NumberExpressionProps, NumberExpressionRaws, } from './src/expression/number'; +export { + IncludeRule, + IncludeRuleProps, + IncludeRuleRaws, +} from './src/statement/include-rule'; export { Interpolation, InterpolationProps, diff --git a/pkg/sass-parser/lib/src/__snapshots__/argument-list.test.ts.snap b/pkg/sass-parser/lib/src/__snapshots__/argument-list.test.ts.snap new file mode 100644 index 000000000..dd74492f2 --- /dev/null +++ b/pkg/sass-parser/lib/src/__snapshots__/argument-list.test.ts.snap @@ -0,0 +1,20 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`an argument list toJSON 1`] = ` +{ + "inputs": [ + { + "css": "@include x(foo, bar...)", + "hasBOM": false, + "id": "", + }, + ], + "nodes": [ + , + , + ], + "raws": {}, + "sassType": "argument-list", + "source": <1:11-1:24 in 0>, +} +`; diff --git a/pkg/sass-parser/lib/src/__snapshots__/argument.test.ts.snap b/pkg/sass-parser/lib/src/__snapshots__/argument.test.ts.snap new file mode 100644 index 000000000..85af46cc3 --- /dev/null +++ b/pkg/sass-parser/lib/src/__snapshots__/argument.test.ts.snap @@ -0,0 +1,50 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`a argument toJSON with a name 1`] = ` +{ + "inputs": [ + { + "css": "@include x($baz: qux)", + "hasBOM": false, + "id": "", + }, + ], + "name": "baz", + "raws": {}, + "rest": false, + "sassType": "argument", + "value": , +} +`; + +exports[`a argument toJSON with no name 1`] = ` +{ + "inputs": [ + { + "css": "@include x(qux)", + "hasBOM": false, + "id": "", + }, + ], + "raws": {}, + "rest": false, + "sassType": "argument", + "value": , +} +`; + +exports[`a argument toJSON with rest 1`] = ` +{ + "inputs": [ + { + "css": "@include x(qux...)", + "hasBOM": false, + "id": "", + }, + ], + "raws": {}, + "rest": true, + "sassType": "argument", + "value": , +} +`; diff --git a/pkg/sass-parser/lib/src/argument-list.test.ts b/pkg/sass-parser/lib/src/argument-list.test.ts new file mode 100644 index 000000000..3d1a77d88 --- /dev/null +++ b/pkg/sass-parser/lib/src/argument-list.test.ts @@ -0,0 +1,903 @@ +// 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 { + Argument, + ArgumentList, + IncludeRule, + StringExpression, + sass, + scss, +} from '..'; + +type EachFn = Parameters[0]; + +let node: ArgumentList; +describe('an argument list', () => { + describe('empty', () => { + function describeNode( + description: string, + create: () => ArgumentList, + ): void { + describe(description, () => { + beforeEach(() => void (node = create())); + + it('has a sassType', () => expect(node.sassType).toBe('argument-list')); + + it('has no nodes', () => expect(node.nodes).toHaveLength(0)); + }); + } + + describeNode( + 'parsed as SCSS', + () => (scss.parse('@include x()').nodes[0] as IncludeRule).arguments, + ); + + describeNode( + 'parsed as Sass', + () => (sass.parse('@include x()').nodes[0] as IncludeRule).arguments, + ); + + describe('constructed manually', () => { + describeNode('with no arguments', () => new ArgumentList()); + + describeNode('with an array', () => new ArgumentList([])); + + describeNode('with an object', () => new ArgumentList({})); + + describeNode( + 'with an object with an array', + () => new ArgumentList({nodes: []}), + ); + }); + + describe('constructed from properties', () => { + describeNode( + 'an object', + () => new IncludeRule({includeName: 'x', arguments: {}}).arguments, + ); + + describeNode( + 'an array', + () => new IncludeRule({includeName: 'x', arguments: []}).arguments, + ); + }); + }); + + describe('with an argument with no name', () => { + function describeNode( + description: string, + create: () => ArgumentList, + ): void { + describe(description, () => { + beforeEach(() => void (node = create())); + + it('has a sassType', () => expect(node.sassType).toBe('argument-list')); + + it('has a node', () => { + expect(node.nodes.length).toBe(1); + expect(node.nodes[0].name).toBeUndefined; + expect(node.nodes[0]).toHaveStringExpression('value', 'bar'); + expect(node.nodes[0].parent).toBe(node); + }); + }); + } + + describeNode( + 'parsed as SCSS', + () => (scss.parse('@include x(bar)').nodes[0] as IncludeRule).arguments, + ); + + describeNode( + 'parsed as Sass', + () => (sass.parse('@include x(bar)').nodes[0] as IncludeRule).arguments, + ); + + describe('constructed manually', () => { + describe('with an array', () => { + describeNode( + 'with an expression', + () => new ArgumentList([new StringExpression({text: 'bar'})]), + ); + + describeNode( + 'with an Argument', + () => new ArgumentList([new Argument({text: 'bar'})]), + ); + + describeNode( + 'with ArgumentProps', + () => new ArgumentList([{value: {text: 'bar'}}]), + ); + + describeNode( + 'with ExpressionProps', + () => new ArgumentList([{text: 'bar'}]), + ); + }); + + describe('with an object', () => { + describeNode( + 'with an expression', + () => + new ArgumentList({nodes: [new StringExpression({text: 'bar'})]}), + ); + + describeNode( + 'with an Argument', + () => new ArgumentList({nodes: [new Argument({text: 'bar'})]}), + ); + + describeNode( + 'with ArgumentProps', + () => new ArgumentList({nodes: [{value: {text: 'bar'}}]}), + ); + + describeNode( + 'with ExpressionProps', + () => new ArgumentList({nodes: [{text: 'bar'}]}), + ); + }); + }); + + describe('constructed from properties', () => { + describeNode( + 'an object', + () => + new IncludeRule({ + includeName: 'x', + arguments: {nodes: [{text: 'bar'}]}, + }).arguments, + ); + + describeNode( + 'an array', + () => + new IncludeRule({includeName: 'x', arguments: [{text: 'bar'}]}) + .arguments, + ); + }); + }); + + describe('with an argument with a name', () => { + function describeNode( + description: string, + create: () => ArgumentList, + ): void { + describe(description, () => { + beforeEach(() => void (node = create())); + + it('has a sassType', () => expect(node.sassType).toBe('argument-list')); + + it('has a node', () => { + expect(node.nodes.length).toBe(1); + expect(node.nodes[0].name).toBe('foo'); + expect(node.nodes[0]).toHaveStringExpression('value', 'bar'); + expect(node.nodes[0]).toHaveProperty('parent', node); + }); + }); + } + + describeNode( + 'parsed as SCSS', + () => + (scss.parse('@include x($foo: bar)').nodes[0] as IncludeRule).arguments, + ); + + describeNode( + 'parsed as Sass', + () => + (sass.parse('@include x($foo: bar)').nodes[0] as IncludeRule).arguments, + ); + + describe('constructed manually', () => { + describe('with an array', () => { + describeNode( + 'with a sub-array', + () => new ArgumentList([['foo', {text: 'bar'}]]), + ); + + describeNode( + 'with an object', + () => new ArgumentList([{name: 'foo', value: {text: 'bar'}}]), + ); + + describeNode( + 'with a Argument', + () => + new ArgumentList([ + new Argument({name: 'foo', value: {text: 'bar'}}), + ]), + ); + }); + + describe('with an object', () => { + describeNode( + 'with a sub-array', + () => new ArgumentList({nodes: [['foo', {text: 'bar'}]]}), + ); + + describeNode( + 'with an object', + () => + new ArgumentList({ + nodes: [{name: 'foo', value: {text: 'bar'}}], + }), + ); + + describeNode( + 'with a Argument', + () => + new ArgumentList({ + nodes: [new Argument({name: 'foo', value: {text: 'bar'}})], + }), + ); + }); + }); + + describe('constructed from properties', () => { + describeNode( + 'an object', + () => + new IncludeRule({ + includeName: 'x', + arguments: {nodes: [['foo', {text: 'bar'}]]}, + }).arguments, + ); + + describeNode( + 'an array', + () => + new IncludeRule({ + includeName: 'x', + arguments: [['foo', {text: 'bar'}]], + }).arguments, + ); + }); + }); + + describe('can add', () => { + beforeEach(() => void (node = new ArgumentList())); + + it('a single argument', () => { + const argument = new Argument({text: 'foo'}); + node.append(argument); + expect(node.nodes).toEqual([argument]); + expect(argument).toHaveProperty('parent', node); + }); + + it('a list of arguments', () => { + const foo = new Argument({text: 'foo'}); + const bar = new Argument({text: 'bar'}); + node.append([foo, bar]); + expect(node.nodes).toEqual([foo, bar]); + }); + + it('ExpressionProps', () => { + node.append({text: 'bar'}); + expect(node.nodes[0]).toBeInstanceOf(Argument); + expect(node.nodes[0].name).toBeUndefined(); + expect(node.nodes[0]).toHaveStringExpression('value', 'bar'); + expect(node.nodes[0]).toHaveProperty('parent', node); + }); + + it('an array of ExpressionProps', () => { + node.append([{text: 'bar'}]); + expect(node.nodes[0]).toBeInstanceOf(Argument); + expect(node.nodes[0].name).toBeUndefined(); + expect(node.nodes[0]).toHaveStringExpression('value', 'bar'); + expect(node.nodes[0]).toHaveProperty('parent', node); + }); + + it('a single pair', () => { + node.append(['foo', {text: 'bar'}]); + expect(node.nodes[0]).toBeInstanceOf(Argument); + expect(node.nodes[0].name).toBe('foo'); + expect(node.nodes[0]).toHaveStringExpression('value', '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(Argument); + expect(node.nodes[0].name).toBe('foo'); + expect(node.nodes[0]).toHaveStringExpression('value', 'bar'); + expect(node.nodes[0]).toHaveProperty('parent', node); + expect(node.nodes[1]).toBeInstanceOf(Argument); + expect(node.nodes[1].name).toBe('baz'); + expect(node.nodes[1]).toHaveStringExpression('value', 'qux'); + expect(node.nodes[1]).toHaveProperty('parent', node); + }); + + it('a single ArgumentProps', () => { + node.append({value: {text: 'bar'}}); + expect(node.nodes[0]).toBeInstanceOf(Argument); + expect(node.nodes[0].name).toBeUndefined(); + expect(node.nodes[0]).toHaveStringExpression('value', 'bar'); + expect(node.nodes[0]).toHaveProperty('parent', node); + }); + + it('multiple ArgumentProps', () => { + node.append([{value: {text: 'bar'}}, {value: {text: 'baz'}}]); + expect(node.nodes[0]).toBeInstanceOf(Argument); + expect(node.nodes[0].name).toBeUndefined(); + expect(node.nodes[0]).toHaveStringExpression('value', 'bar'); + expect(node.nodes[0]).toHaveProperty('parent', node); + expect(node.nodes[1]).toBeInstanceOf(Argument); + expect(node.nodes[1].name).toBeUndefined(); + expect(node.nodes[1]).toHaveStringExpression('value', 'baz'); + expect(node.nodes[1]).toHaveProperty('parent', node); + }); + + it('undefined', () => { + node.append(undefined); + expect(node.nodes).toHaveLength(0); + }); + }); + + describe('append', () => { + beforeEach( + () => void (node = new ArgumentList([{text: 'foo'}, {text: 'bar'}])), + ); + + it('adds multiple children to the end', () => { + node.append({text: 'baz'}, {text: 'qux'}); + expect(node.nodes[0]).toHaveStringExpression('value', 'foo'); + expect(node.nodes[1]).toHaveStringExpression('value', 'bar'); + expect(node.nodes[2]).toHaveStringExpression('value', 'baz'); + expect(node.nodes[3]).toHaveStringExpression('value', 'qux'); + }); + + it('can be called during iteration', () => + testEachMutation(['foo', 'bar', 'baz'], 0, () => + node.append({text: 'baz'}), + )); + + it('returns itself', () => expect(node.append()).toBe(node)); + }); + + describe('each', () => { + beforeEach( + () => void (node = new ArgumentList([{text: 'foo'}, {text: '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.toHaveStringExpression('value', 'foo'), + 0, + ); + expect(fn).toHaveBeenNthCalledWith( + 2, + expect.toHaveStringExpression('value', '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 ArgumentList([ + {text: 'foo'}, + {text: 'bar'}, + {text: '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.value as StringExpression).text.asPlain !== 'bar', + ), + ).toBe(false)); + }); + + describe('index', () => { + beforeEach( + () => + void (node = new ArgumentList([ + {text: 'foo'}, + {text: 'bar'}, + {text: 'baz'}, + ])), + ); + + it('returns the first index of a given argument', () => + 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 ArgumentList({ + nodes: [{text: 'foo'}, {text: 'bar'}, {text: 'baz'}], + })), + ); + + it('inserts a node after the given element', () => { + node.insertAfter(node.nodes[1], {text: 'qux'}); + expect(node.nodes[0]).toHaveStringExpression('value', 'foo'); + expect(node.nodes[1]).toHaveStringExpression('value', 'bar'); + expect(node.nodes[2]).toHaveStringExpression('value', 'qux'); + expect(node.nodes[3]).toHaveStringExpression('value', 'baz'); + }); + + it('inserts a node at the beginning', () => { + node.insertAfter(-1, {text: 'qux'}); + expect(node.nodes[0]).toHaveStringExpression('value', 'qux'); + expect(node.nodes[1]).toHaveStringExpression('value', 'foo'); + expect(node.nodes[2]).toHaveStringExpression('value', 'bar'); + expect(node.nodes[3]).toHaveStringExpression('value', 'baz'); + }); + + it('inserts a node at the end', () => { + node.insertAfter(3, {text: 'qux'}); + expect(node.nodes[0]).toHaveStringExpression('value', 'foo'); + expect(node.nodes[1]).toHaveStringExpression('value', 'bar'); + expect(node.nodes[2]).toHaveStringExpression('value', 'baz'); + expect(node.nodes[3]).toHaveStringExpression('value', 'qux'); + }); + + it('inserts multiple nodes', () => { + node.insertAfter(1, [{text: 'qux'}, {text: 'qax'}, {text: 'qix'}]); + expect(node.nodes[0]).toHaveStringExpression('value', 'foo'); + expect(node.nodes[1]).toHaveStringExpression('value', 'bar'); + expect(node.nodes[2]).toHaveStringExpression('value', 'qux'); + expect(node.nodes[3]).toHaveStringExpression('value', 'qax'); + expect(node.nodes[4]).toHaveStringExpression('value', 'qix'); + expect(node.nodes[5]).toHaveStringExpression('value', 'baz'); + }); + + it('inserts before an iterator', () => + testEachMutation(['foo', 'bar', ['baz', 5]], 1, () => + node.insertAfter(0, [{text: 'qux'}, {text: 'qax'}, {text: 'qix'}]), + )); + + it('inserts after an iterator', () => + testEachMutation(['foo', 'bar', 'qux', 'qax', 'qix', 'baz'], 1, () => + node.insertAfter(1, [{text: 'qux'}, {text: 'qax'}, {text: 'qix'}]), + )); + + it('returns itself', () => + expect(node.insertAfter(0, {text: 'qux'})).toBe(node)); + }); + + describe('insertBefore', () => { + beforeEach( + () => + void (node = new ArgumentList([ + {text: 'foo'}, + {text: 'bar'}, + {text: 'baz'}, + ])), + ); + + it('inserts a node before the given element', () => { + node.insertBefore(node.nodes[1], {text: 'qux'}); + expect(node.nodes[0]).toHaveStringExpression('value', 'foo'); + expect(node.nodes[1]).toHaveStringExpression('value', 'qux'); + expect(node.nodes[2]).toHaveStringExpression('value', 'bar'); + expect(node.nodes[3]).toHaveStringExpression('value', 'baz'); + }); + + it('inserts a node at the beginning', () => { + node.insertBefore(0, {text: 'qux'}); + expect(node.nodes[0]).toHaveStringExpression('value', 'qux'); + expect(node.nodes[1]).toHaveStringExpression('value', 'foo'); + expect(node.nodes[2]).toHaveStringExpression('value', 'bar'); + expect(node.nodes[3]).toHaveStringExpression('value', 'baz'); + }); + + it('inserts a node at the end', () => { + node.insertBefore(4, {text: 'qux'}); + expect(node.nodes[0]).toHaveStringExpression('value', 'foo'); + expect(node.nodes[1]).toHaveStringExpression('value', 'bar'); + expect(node.nodes[2]).toHaveStringExpression('value', 'baz'); + expect(node.nodes[3]).toHaveStringExpression('value', 'qux'); + }); + + it('inserts multiple nodes', () => { + node.insertBefore(1, [{text: 'qux'}, {text: 'qax'}, {text: 'qix'}]); + expect(node.nodes[0]).toHaveStringExpression('value', 'foo'); + expect(node.nodes[1]).toHaveStringExpression('value', 'qux'); + expect(node.nodes[2]).toHaveStringExpression('value', 'qax'); + expect(node.nodes[3]).toHaveStringExpression('value', 'qix'); + expect(node.nodes[4]).toHaveStringExpression('value', 'bar'); + expect(node.nodes[5]).toHaveStringExpression('value', 'baz'); + }); + + it('inserts before an iterator', () => + testEachMutation(['foo', 'bar', ['baz', 5]], 1, () => + node.insertBefore(1, [{text: 'qux'}, {text: 'qax'}, {text: 'qix'}]), + )); + + it('inserts after an iterator', () => + testEachMutation(['foo', 'bar', 'qux', 'qax', 'qix', 'baz'], 1, () => + node.insertBefore(2, [{text: 'qux'}, {text: 'qax'}, {text: 'qix'}]), + )); + + it('returns itself', () => + expect(node.insertBefore(0, {text: 'qux'})).toBe(node)); + }); + + describe('prepend', () => { + beforeEach( + () => + void (node = new ArgumentList([ + {text: 'foo'}, + {text: 'bar'}, + {text: 'baz'}, + ])), + ); + + it('inserts one node', () => { + node.prepend({text: 'qux'}); + expect(node.nodes[0]).toHaveStringExpression('value', 'qux'); + expect(node.nodes[1]).toHaveStringExpression('value', 'foo'); + expect(node.nodes[2]).toHaveStringExpression('value', 'bar'); + expect(node.nodes[3]).toHaveStringExpression('value', 'baz'); + }); + + it('inserts multiple nodes', () => { + node.prepend({text: 'qux'}, {text: 'qax'}, {text: 'qix'}); + expect(node.nodes[0]).toHaveStringExpression('value', 'qux'); + expect(node.nodes[1]).toHaveStringExpression('value', 'qax'); + expect(node.nodes[2]).toHaveStringExpression('value', 'qix'); + expect(node.nodes[3]).toHaveStringExpression('value', 'foo'); + expect(node.nodes[4]).toHaveStringExpression('value', 'bar'); + expect(node.nodes[5]).toHaveStringExpression('value', 'baz'); + }); + + it('inserts before an iterator', () => + testEachMutation(['foo', 'bar', ['baz', 5]], 1, () => + node.prepend({text: 'qux'}, {text: 'qax'}, {text: 'qix'}), + )); + + it('returns itself', () => expect(node.prepend({text: 'qux'})).toBe(node)); + }); + + describe('push', () => { + beforeEach( + () => void (node = new ArgumentList([{text: 'foo'}, {text: 'bar'}])), + ); + + it('inserts one node', () => { + node.push(new Argument({text: 'baz'})); + expect(node.nodes[0]).toHaveStringExpression('value', 'foo'); + expect(node.nodes[1]).toHaveStringExpression('value', 'bar'); + expect(node.nodes[2]).toHaveStringExpression('value', 'baz'); + }); + + it('can be called during iteration', () => + testEachMutation(['foo', 'bar', 'baz'], 0, () => + node.push(new Argument({text: 'baz'})), + )); + + it('returns itself', () => + expect(node.push(new Argument({text: 'baz'}))).toBe(node)); + }); + + describe('removeAll', () => { + beforeEach( + () => + void (node = new ArgumentList([ + {text: 'foo'}, + {text: 'bar'}, + {text: '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 ArgumentList([ + {text: 'foo'}, + {text: 'bar'}, + {text: 'baz'}, + ])), + ); + + it('removes a matching node', () => { + node.removeChild(node.nodes[0]); + expect(node.nodes[0]).toHaveStringExpression('value', 'bar'); + expect(node.nodes[1]).toHaveStringExpression('value', 'baz'); + }); + + it('removes a node at index', () => { + node.removeChild(1); + expect(node.nodes[0]).toHaveStringExpression('value', 'foo'); + expect(node.nodes[1]).toHaveStringExpression('value', '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 ArgumentList([ + {text: 'foo'}, + {text: 'bar'}, + {text: '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.value as StringExpression).text.asPlain === 'bar', + ), + ).toBe(true)); + }); + + describe('first', () => { + it('returns the first element', () => + expect( + new ArgumentList([{text: 'foo'}, {text: 'bar'}, {text: 'baz'}]).first, + ).toHaveStringExpression('value', 'foo')); + + it('returns undefined for an empty list', () => + expect(new ArgumentList().first).toBeUndefined()); + }); + + describe('last', () => { + it('returns the last element', () => + expect( + new ArgumentList({nodes: [{text: 'foo'}, {text: 'bar'}, {text: 'baz'}]}) + .last, + ).toHaveStringExpression('value', 'baz')); + + it('returns undefined for an empty list', () => + expect(new ArgumentList().last).toBeUndefined()); + }); + + describe('stringifies', () => { + describe('with no nodes', () => { + it('with default raws', () => + expect(new ArgumentList().toString()).toBe('()')); + + it('ignores comma', () => + expect(new ArgumentList({raws: {comma: true}}).toString()).toBe('()')); + + it('with after', () => + expect(new ArgumentList({raws: {after: '/**/'}}).toString()).toBe( + '(/**/)', + )); + }); + + describe('with arguments', () => { + it('with default raws', () => + expect( + new ArgumentList([ + {text: 'foo'}, + {text: 'bar'}, + {text: 'baz'}, + ]).toString(), + ).toBe('(foo, bar, baz)')); + + it('with comma: true', () => + expect( + new ArgumentList({ + nodes: [{text: 'foo'}, {text: 'bar'}, {text: 'baz'}], + raws: {comma: true}, + }).toString(), + ).toBe('(foo, bar, baz,)')); + + describe('with after', () => { + it('with comma: false', () => + expect( + new ArgumentList({ + nodes: [{text: 'foo'}, {text: 'bar'}, {text: 'baz'}], + raws: {after: '/**/'}, + }).toString(), + ).toBe('(foo, bar, baz/**/)')); + + it('with comma: true', () => + expect( + new ArgumentList({ + nodes: [{text: 'foo'}, {text: 'bar'}, {text: 'baz'}], + raws: {comma: true, after: '/**/'}, + }).toString(), + ).toBe('(foo, bar, baz,/**/)')); + }); + + describe('with a argument with after', () => { + it('with comma: false and no after', () => + expect( + new ArgumentList({ + nodes: [ + {text: 'foo'}, + {text: 'bar'}, + new Argument({value: {text: 'baz'}, raws: {after: ' '}}), + ], + }).toString(), + ).toBe('(foo, bar, baz )')); + + it('with comma: false and after', () => + expect( + new ArgumentList({ + nodes: [ + {text: 'foo'}, + {text: 'bar'}, + new Argument({value: {text: 'baz'}, raws: {after: ' '}}), + ], + raws: {after: '/**/'}, + }).toString(), + ).toBe('(foo, bar, baz /**/)')); + + it('with comma: true', () => + expect( + new ArgumentList({ + nodes: [ + {text: 'foo'}, + {text: 'bar'}, + new Argument({value: {text: 'baz'}, raws: {after: ' '}}), + ], + raws: {comma: true}, + }).toString(), + ).toBe('(foo, bar, baz ,)')); + }); + }); + }); + + describe('clone', () => { + let original: ArgumentList; + beforeEach( + () => + void (original = new ArgumentList({ + nodes: [{text: 'foo'}, {text: 'bar'}], + raws: {after: ' '}, + })), + ); + + describe('with no overrides', () => { + let clone: ArgumentList; + beforeEach(() => void (clone = original.clone())); + + describe('has the same properties:', () => { + it('nodes', () => { + expect(clone.nodes[0]).toHaveStringExpression('value', 'foo'); + expect(clone.nodes[0].parent).toBe(clone); + expect(clone.nodes[1]).toHaveStringExpression('value', 'bar'); + expect(clone.nodes[1].parent).toBe(clone); + }); + + 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: [{text: 'qux'}]}); + expect(clone.nodes[0]).toHaveStringExpression('value', 'qux'); + }); + + it('undefined', () => { + const clone = original.clone({nodes: undefined}); + expect(clone.nodes).toHaveLength(2); + expect(clone.nodes[0]).toHaveStringExpression('value', 'foo'); + expect(clone.nodes[1]).toHaveStringExpression('value', 'bar'); + }); + }); + }); + }); + + it('toJSON', () => + expect( + (scss.parse('@include x(foo, bar...)').nodes[0] as IncludeRule).arguments, + ).toMatchSnapshot()); +}); + +/** + * Runs `node.each`, asserting that it sees an argument with each string value + * 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 [text, index] = Array.isArray(element) ? element : [element, i]; + expect(fn).toHaveBeenNthCalledWith( + i + 1, + expect.toHaveStringExpression('value', text), + index, + ); + } + expect(fn).toHaveBeenCalledTimes(elements.length); +} diff --git a/pkg/sass-parser/lib/src/argument-list.ts b/pkg/sass-parser/lib/src/argument-list.ts new file mode 100644 index 000000000..383474a41 --- /dev/null +++ b/pkg/sass-parser/lib/src/argument-list.ts @@ -0,0 +1,331 @@ +// 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 {Argument, ArgumentProps} from './argument'; +import {Container} from './container'; +import {convertExpression} from './expression/convert'; +import {LazySource} from './lazy-source'; +import {Node} from './node'; +import * as sassInternal from './sass-internal'; +import * as utils from './utils'; + +/** + * The type of new nodes that can be passed into a argument list, either a + * single argument or multiple arguments. + * + * @category Expression + */ +export type NewArguments = + | Argument + | ArgumentProps + | ReadonlyArray + | undefined; + +/** + * The initializer properties for {@link ArgumentList} passed as an options + * object. + * + * @category Expression + */ +export interface ArgumentListObjectProps { + nodes?: ReadonlyArray; + raws?: ArgumentListRaws; +} + +/** + * The initializer properties for {@link ArgumentList}. + * + * @category Expression + */ +export type ArgumentListProps = + | ArgumentListObjectProps + | ReadonlyArray; + +/** + * Raws indicating how to precisely serialize a {@link ArgumentList} node. + * + * @category Expression + */ +export interface ArgumentListRaws { + /** + * Whether the final argument has a trailing comma. + * + * Ignored if {@link ArgumentList.nodes} is empty. + */ + comma?: boolean; + + /** + * The whitespace between the final argument (or its trailing comma if it has + * one) and the closing parenthesis. + */ + after?: string; +} + +/** + * A list of arguments, as in an `@include` rule or a function call. + * + * @category Expression + */ +export class ArgumentList + extends Node + implements Container +{ + readonly sassType = 'argument-list' as const; + declare raws: ArgumentListRaws; + + 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 declare _nodes?: Array; + + /** + * Iterators that are currently active within this argument 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?: ArgumentListProps); + /** @hidden */ + constructor(_: undefined, inner: sassInternal.ArgumentList); + constructor(defaults?: object, inner?: sassInternal.ArgumentList) { + 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 = []; + for (const expression of inner.positional) { + this.append(new Argument(convertExpression(expression))); + } + for (const [name, expression] of Object.entries( + sassInternal.mapToRecord(inner.named), + )) { + this.append(new Argument({name, value: convertExpression(expression)})); + } + if (inner.rest) { + // TODO: Provide source information for this argument. + this.append({value: convertExpression(inner.rest), rest: true}); + } + if (inner.keywordRest) { + // TODO: Provide source information for this argument. + this.append({value: convertExpression(inner.keywordRest), rest: true}); + } + } + if (this._nodes === undefined) this._nodes = []; + } + + clone(overrides?: Partial): this { + return utils.cloneNode(this, overrides, ['nodes', 'raws']); + } + + toJSON(): object; + /** @hidden */ + toJSON(_: string, inputs: Map): object; + toJSON(_?: string, inputs?: Map): object { + return utils.toJSON(this, ['nodes'], inputs); + } + + append(...nodes: NewArguments[]): this { + // TODO - postcss/postcss#1957: Mark this as dirty + this._nodes!.push(...this._normalizeList(nodes)); + return this; + } + + each( + callback: (node: Argument, 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: Argument, + index: number, + nodes: ReadonlyArray, + ) => boolean, + ): boolean { + return this.nodes.every(condition); + } + + index(child: Argument | number): number { + return typeof child === 'number' ? child : this.nodes.indexOf(child); + } + + insertAfter(oldNode: Argument | number, newNode: NewArguments): 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: Argument | number, newNode: NewArguments): 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: NewArguments[]): 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: Argument): 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: Argument | number): this { + // TODO - postcss/postcss#1957: Mark this as dirty + const index = this.index(child); + const argument = this._nodes![index]; + if (argument) argument.parent = undefined; + this._nodes!.splice(index, 1); + + for (const iterator of this.#iterators) { + if (iterator.index >= index) iterator.index--; + } + + return this; + } + + some( + condition: ( + node: Argument, + index: number, + nodes: ReadonlyArray, + ) => boolean, + ): boolean { + return this.nodes.some(condition); + } + + get first(): Argument | undefined { + return this.nodes[0]; + } + + get last(): Argument | undefined { + return this.nodes[this.nodes.length - 1]; + } + + /** @hidden */ + toString(): string { + let result = '('; + let first = true; + for (const argument of this.nodes) { + if (first) { + result += argument.raws.before ?? ''; + first = false; + } else { + result += ','; + result += argument.raws.before ?? ' '; + } + result += argument.toString(); + result += argument.raws.after ?? ''; + } + if (this.raws.comma && this.nodes.length) result += ','; + return result + (this.raws.after ?? '') + ')'; + } + + /** + * Normalizes a single argument declaration or list of arguments. + */ + private _normalize(nodes: NewArguments): Argument[] { + const normalized = this._normalizeBeforeParent(nodes); + for (const node of normalized) { + node.parent = this; + } + return normalized; + } + + /** Like {@link _normalize}, but doesn't set the argument's parents. */ + private _normalizeBeforeParent(nodes: NewArguments): Argument[] { + 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 Argument(nodes)]; + } else { + return (nodes as ReadonlyArray).map(node => + typeof node === 'object' && + 'sassType' in node && + node.sassType === 'argument' + ? (node as Argument) + : new Argument(node), + ); + } + } else { + return [ + typeof nodes === 'object' && + 'sassType' in nodes && + nodes.sassType === 'argument' + ? (nodes as Argument) + : new Argument(nodes as ArgumentProps), + ]; + } + } + + /** Like {@link _normalize}, but also flattens a list of nodes. */ + private _normalizeList(nodes: ReadonlyArray): Argument[] { + 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/argument.test.ts b/pkg/sass-parser/lib/src/argument.test.ts new file mode 100644 index 000000000..b800f934a --- /dev/null +++ b/pkg/sass-parser/lib/src/argument.test.ts @@ -0,0 +1,514 @@ +// 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 { + Argument, + ArgumentList, + IncludeRule, + StringExpression, + sass, + scss, +} from '..'; + +describe('a argument', () => { + let node: Argument; + beforeEach( + () => + void (node = new Argument({ + name: 'foo', + value: {text: 'bar', quotes: true}, + })), + ); + + describe('with no name', () => { + function describeNode(description: string, create: () => Argument): void { + describe(description, () => { + beforeEach(() => (node = create())); + + it('has a sassType', () => expect(node.sassType).toBe('argument')); + + it('has no name', () => expect(node.name).toBeUndefined()); + + it('has a value', () => + expect(node).toHaveStringExpression('value', 'bar')); + + it('is not a rest parameter', () => expect(node.rest).toBe(false)); + }); + } + + describeNode('parsed as SCSS', () => { + const rule = scss.parse('@include a(bar)').nodes[0] as IncludeRule; + return rule.arguments.nodes[0]; + }); + + describeNode( + 'parsed as Sass', + () => + (sass.parse('@include a(bar)').nodes[0] as IncludeRule).arguments + .nodes[0], + ); + + describe('constructed manually', () => { + describeNode( + 'with an expression', + () => new Argument(new StringExpression({text: 'bar'})), + ); + + describe('with ArgumentProps', () => { + describeNode( + 'with an expression', + () => new Argument({value: new StringExpression({text: 'bar'})}), + ); + + describeNode( + 'with ExpressionProps', + () => new Argument({value: {text: 'bar'}}), + ); + }); + + describeNode('with ExpressionProps', () => new Argument({text: 'bar'})); + }); + + describe('constructed from properties', () => { + describeNode( + 'with an expression', + () => new ArgumentList([new StringExpression({text: 'bar'})]).nodes[0], + ); + + describe('with ArgumentProps', () => { + describeNode( + 'with an expression', + () => + new ArgumentList([{value: new StringExpression({text: 'bar'})}]) + .nodes[0], + ); + + describeNode( + 'with ExpressionProps', + () => new ArgumentList([{value: {text: 'bar'}}]).nodes[0], + ); + }); + + describeNode( + 'with ExpressionProps', + () => new ArgumentList({nodes: [{text: 'bar'}]}).nodes[0], + ); + }); + }); + + describe('with a name', () => { + function describeNode(description: string, create: () => Argument): void { + describe(description, () => { + beforeEach(() => (node = create())); + + it('has a sassType', () => + expect(node.sassType.toString()).toBe('argument')); + + it('has a name', () => expect(node.name).toBe('foo')); + + it('has value', () => + expect(node).toHaveStringExpression('value', 'bar')); + }); + } + + describeNode( + 'parsed as SCSS', + () => + (scss.parse('@include a($foo: "bar")').nodes[0] as IncludeRule) + .arguments.nodes[0], + ); + + describeNode( + 'parsed as Sass', + () => + (sass.parse('@include a($foo: "bar")').nodes[0] as IncludeRule) + .arguments.nodes[0], + ); + + describe('constructed manually', () => { + describe('with an array', () => { + describeNode( + 'with an expression', + () => new Argument(['foo', new StringExpression({text: 'bar'})]), + ); + + describeNode( + 'with ExpressionProps', + () => new Argument(['foo', {text: 'bar'}]), + ); + + describe('with ArgumentProps', () => { + describeNode( + 'with an expression', + () => + new Argument([ + 'foo', + { + value: new StringExpression({text: 'bar'}), + }, + ]), + ); + + describeNode( + 'with ExpressionProps', + () => new Argument(['foo', {value: {text: 'bar'}}]), + ); + }); + }); + + describe('with an object', () => { + describeNode( + 'with an expression', + () => + new Argument({ + name: 'foo', + value: new StringExpression({text: 'bar'}), + }), + ); + + describeNode( + 'with ExpressionProps', + () => new Argument({name: 'foo', value: {text: 'bar'}}), + ); + }); + }); + + describe('constructed from properties', () => { + describe('an array', () => { + describeNode( + 'with ExpressionProps', + () => + new ArgumentList({ + nodes: [['foo', {text: 'bar'}]], + }).nodes[0], + ); + + describeNode( + 'with an Expression', + () => + new ArgumentList({ + nodes: [['foo', new StringExpression({text: 'bar'})]], + }).nodes[0], + ); + + describeNode( + 'with ArgumentObjectProps', + () => + new ArgumentList({ + nodes: [['foo', {value: {text: 'bar'}}]], + }).nodes[0], + ); + }); + + describe('an object', () => { + describeNode( + 'with ExpressionProps', + () => + new ArgumentList({ + nodes: [{name: 'foo', value: {text: 'bar'}}], + }).nodes[0], + ); + + describeNode( + 'with an Expression', + () => + new ArgumentList({ + nodes: [ + { + name: 'foo', + value: new StringExpression({text: 'bar'}), + }, + ], + }).nodes[0], + ); + }); + }); + }); + + describe('as a rest argument', () => { + function describeNode(description: string, create: () => Argument): void { + describe(description, () => { + beforeEach(() => (node = create())); + + it('has a sassType', () => + expect(node.sassType.toString()).toBe('argument')); + + it('has no name', () => expect(node.name).toBeUndefined()); + + it('has a value', () => + expect(node).toHaveStringExpression('value', 'bar')); + + it('is a rest argument', () => expect(node.rest).toBe(true)); + }); + } + + describeNode( + 'parsed as SCSS', + () => + (scss.parse('@include a(bar...)').nodes[0] as IncludeRule).arguments + .nodes[0], + ); + + describeNode( + 'parsed as Sass', + () => + (sass.parse('@include a(bar...)').nodes[0] as IncludeRule).arguments + .nodes[0], + ); + + describeNode( + 'constructed manually', + () => new Argument({value: {text: 'bar'}, rest: true}), + ); + + describeNode( + 'constructed from properties', + () => + new ArgumentList({nodes: [{value: {text: 'bar'}, rest: true}]}) + .nodes[0], + ); + }); + + describe('assigned a new name', () => { + it('updates the name', () => { + node.name = 'baz'; + expect(node.name).toBe('baz'); + }); + + it('sets rest to false', () => { + node.rest = true; + node.name = 'baz'; + expect(node.rest).toBe(false); + }); + + it('leaves rest alone if name is undefined', () => { + node.rest = true; + node.name = undefined; + expect(node.rest).toBe(true); + }); + }); + + describe('assigned a new rest', () => { + it('updates the value of rest', () => { + node.rest = true; + expect(node.rest).toBe(true); + }); + + it('sets name to undefined', () => { + node.rest = true; + expect(node.name).toBe(undefined); + }); + + it('leaves defaultValue alone if rest is false', () => { + node.rest = false; + expect(node.name).toBe('foo'); + }); + }); + + it('assigned a new value', () => { + const old = node.value; + node.value = {text: 'baz'}; + expect(old.parent).toBeUndefined(); + expect(node).toHaveStringExpression('value', 'baz'); + }); + + describe('stringifies', () => { + describe('to SCSS', () => { + describe('with default raws', () => { + it('with no name', () => + expect(new Argument({text: 'bar'}).toString()).toBe('bar')); + + it('with a name', () => + expect(new Argument(['foo', {text: 'bar'}]).toString()).toBe( + '$foo: bar', + )); + + it('with rest = true', () => + expect( + new Argument({value: {text: 'bar'}, rest: true}).toString(), + ).toBe('bar...')); + + it('with a non-identifier name', () => + expect(new Argument(['f o', {text: 'bar'}]).toString()).toBe( + '$f\\20o: bar', + )); + }); + + // raws.before is only used as part of a ArgumentList + it('ignores before', () => + expect( + new Argument({ + value: {text: 'bar'}, + raws: {before: '/**/'}, + }).toString(), + ).toBe('bar')); + + it('with matching name', () => + expect( + new Argument({ + name: 'foo', + value: {text: 'bar'}, + raws: {name: {raw: 'f\\6fo', value: 'foo'}}, + }).toString(), + ).toBe('$f\\6fo: bar')); + + it('with non-matching name', () => + expect( + new Argument({ + name: 'foo', + value: {text: 'bar'}, + raws: {name: {raw: 'f\\41o', value: 'fao'}}, + }).toString(), + ).toBe('$foo: bar')); + + it('with between', () => + expect( + new Argument({ + name: 'foo', + value: {text: 'bar'}, + raws: {between: ' : '}, + }).toString(), + ).toBe('$foo : bar')); + + it('ignores between with no name', () => + expect( + new Argument({ + value: {text: 'bar'}, + raws: {between: ' : '}, + }).toString(), + ).toBe('bar')); + + it('with beforeRest', () => + expect( + new Argument({ + value: {text: 'bar'}, + rest: true, + raws: {beforeRest: '/**/'}, + }).toString(), + ).toBe('bar/**/...')); + + it('ignores beforeRest with rest = false', () => + expect( + new Argument({ + value: {text: 'bar'}, + raws: {beforeRest: '/**/'}, + }).toString(), + ).toBe('bar')); + + // raws.before is only used as part of a Configuration + describe('ignores after', () => { + it('with rest = false', () => + expect( + new Argument({ + value: {text: 'bar'}, + raws: {after: '/**/'}, + }).toString(), + ).toBe('bar')); + + it('with rest = true', () => + expect( + new Argument({ + value: {text: 'bar'}, + rest: true, + raws: {after: '/**/'}, + }).toString(), + ).toBe('bar...')); + }); + }); + }); + + describe('clone()', () => { + let original: Argument; + beforeEach(() => { + original = (scss.parse('@include x($foo: bar)').nodes[0] as IncludeRule) + .arguments.nodes[0]; + original.raws.between = ' : '; + }); + + describe('with no overrides', () => { + let clone: Argument; + beforeEach(() => void (clone = original.clone())); + + describe('has the same properties:', () => { + it('name', () => expect(clone.name).toBe('foo')); + + it('value', () => expect(clone).toHaveStringExpression('value', 'bar')); + + it('rest', () => expect(clone.rest).toBe(false)); + }); + + describe('creates a new', () => { + it('self', () => expect(clone).not.toBe(original)); + + for (const attr of ['value', '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).toBeUndefined()); + }); + + describe('rest', () => { + it('defined', () => + expect(original.clone({rest: true}).rest).toBe(true)); + + it('undefined', () => + expect(original.clone({rest: undefined}).rest).toBe(false)); + }); + + describe('value', () => { + it('defined', () => + expect(original.clone({value: {text: 'baz'}})).toHaveStringExpression( + 'value', + 'baz', + )); + + it('undefined', () => + expect(original.clone({value: undefined})).toHaveStringExpression( + 'value', + 'bar', + )); + }); + }); + }); + + describe('toJSON', () => { + it('with a name', () => + expect( + (scss.parse('@include x($baz: qux)').nodes[0] as IncludeRule).arguments + .nodes[0], + ).toMatchSnapshot()); + + it('with no name', () => + expect( + (scss.parse('@include x(qux)').nodes[0] as IncludeRule).arguments + .nodes[0], + ).toMatchSnapshot()); + + it('with rest', () => + expect( + (scss.parse('@include x(qux...)').nodes[0] as IncludeRule).arguments + .nodes[0], + ).toMatchSnapshot()); + }); +}); diff --git a/pkg/sass-parser/lib/src/argument.ts b/pkg/sass-parser/lib/src/argument.ts new file mode 100644 index 000000000..353198ed9 --- /dev/null +++ b/pkg/sass-parser/lib/src/argument.ts @@ -0,0 +1,199 @@ +// 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 {Expression, ExpressionProps} from './expression'; +import {fromProps} from './expression/from-props'; +import {Node} from './node'; +import {ArgumentList} from './argument-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 Argument}. + * + * @category Expression + */ +export interface ArgumentRaws { + /** + * The whitespace before the argument name (if it has one) or value (if it + * doesn't). + */ + before?: string; + + /** + * The argument's name, not including the `$`. + * + * This may be different than {@link Argument.name} if the name contains + * escape codes or underscores. It's ignored unless {@link Argument.name} is + * defined. + */ + name?: RawWithValue; + + /** + * The whitespace and colon between the argument name and its value. This is + * ignored unless the argument {@link Argument.name} is defined. + */ + between?: string; + + /** + * The whitespace between the argument and the `...`, if {@link + * Argument.rest} is true. + */ + beforeRest?: string; + + /** + * The space symbols between the end of the argument value and the comma + * afterwards. Always empty for a argument that doesn't have a trailing comma. + */ + after?: string; +} + +/** + * The initializer properties for {@link Argument} passed as an + * options object. + * + * @category Expression + */ +export type ArgumentObjectProps = { + raws?: ArgumentRaws; + value: Expression | ExpressionProps; +} & ({name?: string; rest?: never} | {name?: never; rest?: boolean}); + +/** + * Properties used to initialize a {@link Argument} without an explicit name. + * This is used when the name is given elsewhere, either in the array form of + * {@link ArgumentProps} or the record form of [@link + * ArgumentDeclarationProps}. + */ +export type ArgumentExpressionProps = + | Expression + | ExpressionProps + | Omit; + +/** + * The initializer properties for {@link Argument}. + * + * @category Expression + */ +export type ArgumentProps = + | ArgumentObjectProps + | Expression + | ExpressionProps + | [string, ArgumentExpressionProps]; + +/** + * A single argument passed to an `@include` or `@content` rule or a function + * invocation. This is always included in a {@link ArgumentList}. + * + * @category Expression + */ +export class Argument extends Node { + readonly sassType = 'argument' as const; + declare raws: ArgumentRaws; + declare parent: ArgumentList | undefined; + + /** + * The argument name, not including `$`. + * + * This is the parsed and normalized value, with underscores converted to + * hyphens and escapes resolved to the characters they represent. + * + * Setting this to a value automatically sets {@link rest} to + * `undefined`. + */ + get name(): string | undefined { + return this._name; + } + set name(name: string | undefined) { + if (name) this._rest = undefined; + this._name = name; + } + private declare _name?: string; + + /** The argument's value. */ + get value(): Expression { + return this._value!; + } + set value(value: Expression | ExpressionProps) { + if (this._value) this._value.parent = undefined; + if (!('sassType' in value)) value = fromProps(value); + if (value) value.parent = this; + this._value = value; + } + private declare _value?: Expression; + + /** + * Whether this is a rest argument (indicated by `...` in Sass). + * + * Setting this to true automatically sets {@link name} to + * `undefined`. + */ + get rest(): boolean { + return this._rest ?? false; + } + set rest(value: boolean) { + if (value) this._name = undefined; + this._rest = value; + } + private declare _rest?: boolean; + + constructor(defaults: ArgumentProps) { + if (Array.isArray(defaults)) { + const [name, props] = defaults; + if ('sassType' in props || !('value' in props)) { + defaults = { + name, + value: props as Expression | ExpressionProps, + }; + } else { + defaults = {name, ...props} as ArgumentObjectProps; + } + } else if ('sassType' in defaults || !('value' in defaults)) { + defaults = { + value: defaults as Expression | ExpressionProps, + }; + } + super(defaults); + this.raws ??= {}; + } + + clone(overrides?: Partial): this { + return utils.cloneNode(this, overrides, [ + 'raws', + {name: 'name', explicitUndefined: true}, + 'value', + 'rest', + ]); + } + + toJSON(): object; + /** @hidden */ + toJSON(_: string, inputs: Map): object; + toJSON(_?: string, inputs?: Map): object { + return utils.toJSON(this, ['name', 'value', 'rest'], inputs); + } + + /** @hidden */ + toString(): string { + return ( + (this.name === undefined + ? '' + : '$' + + (this.raws.name?.value === this.name + ? this.raws.name!.raw + : sassInternal.toCssIdentifier(this.name)) + + (this.raws.between ?? ': ')) + + this.value + + (this.rest ? (this.raws.beforeRest ?? '') + '...' : '') + ); + } + + /** @hidden */ + get nonStatementChildren(): ReadonlyArray { + return [this.value]; + } +} diff --git a/pkg/sass-parser/lib/src/node.d.ts b/pkg/sass-parser/lib/src/node.d.ts index c28b36bec..2c28227c2 100644 --- a/pkg/sass-parser/lib/src/node.d.ts +++ b/pkg/sass-parser/lib/src/node.d.ts @@ -21,9 +21,11 @@ export type AnyNode = AnyStatement | AnyExpression | Interpolation; export type NodeType = | StatementType | ExpressionType - | 'interpolation' + | 'argument' + | 'argument-list' | 'configuration' | 'configured-variable' + | 'interpolation' | 'parameter' | 'parameter-list'; diff --git a/pkg/sass-parser/lib/src/parameter.test.ts b/pkg/sass-parser/lib/src/parameter.test.ts index 5d6df3a09..97c8dbd6f 100644 --- a/pkg/sass-parser/lib/src/parameter.test.ts +++ b/pkg/sass-parser/lib/src/parameter.test.ts @@ -55,6 +55,11 @@ describe('a parameter', () => { describe('constructed manually', () => { describeNode('with a string', () => new Parameter('foo')); + describeNode( + 'with an array', + () => new Parameter(['foo', {raws: {before: '/**/'}}]), + ); + describeNode('with an object', () => new Parameter({name: 'foo'})); }); @@ -253,10 +258,14 @@ describe('a parameter', () => { .nodes[0], ); - describeNode( - 'constructed manually', - () => new Parameter({name: 'foo', rest: true}), - ); + describe('constructed manually', () => { + describeNode('with an array', () => new Parameter(['foo', {rest: true}])); + + describeNode( + 'with an object', + () => new Parameter({name: 'foo', rest: true}), + ); + }); describeNode( 'constructed from properties', @@ -290,7 +299,7 @@ describe('a parameter', () => { }); }); - describe('assigned rest = true', () => { + describe('assigned a new rest', () => { it('updates the value of rest', () => { node.rest = true; expect(node.rest).toBe(true); diff --git a/pkg/sass-parser/lib/src/parameter.ts b/pkg/sass-parser/lib/src/parameter.ts index b40eb6526..bd6ae2f32 100644 --- a/pkg/sass-parser/lib/src/parameter.ts +++ b/pkg/sass-parser/lib/src/parameter.ts @@ -155,17 +155,12 @@ export class Parameter extends Node { defaults = {name: defaults}; } else if (Array.isArray(defaults)) { const [name, props] = defaults; - if ( - 'sassType' in props || - !('defaultValue' in props || 'rest' in props) - ) { - defaults = { - name, - defaultValue: props as Expression | ExpressionProps, - }; - } else { - defaults = {name, ...props} as ParameterObjectProps; - } + defaults = _isParameterObjectProps(props) + ? ({name, ...props} as ParameterObjectProps) + : { + name, + defaultValue: props as Expression | ExpressionProps, + }; } super(defaults); this.raws ??= {}; @@ -214,3 +209,18 @@ export class Parameter extends Node { return this.defaultValue ? [this.defaultValue] : []; } } + +/** Returns whether {@link props} is a {@link ParameterObjectProps}. */ +function _isParameterObjectProps( + props: ParameterExpressionProps, +): props is Omit { + if ('sassType' in props) return false; + if ('defaultValue' in props) return true; + if ('rest' in props) return true; + const length = Object.keys(props).length; + // `raws` can appear in initializers for expressions, so we only treat it as + // a parameter initializer if it's passed alongside `defaultValue` or `rest` + // or on its own. + if ('raws' in props && length === 1) return true; + return false; +} diff --git a/pkg/sass-parser/lib/src/sass-internal.ts b/pkg/sass-parser/lib/src/sass-internal.ts index 205dcb55d..5ff29808f 100644 --- a/pkg/sass-parser/lib/src/sass-internal.ts +++ b/pkg/sass-parser/lib/src/sass-internal.ts @@ -32,6 +32,14 @@ export interface DartSet { _unique: 'DartSet'; } +export interface DartMap { + _keyType: K; + _valueType: V; + + // A brand to make this function as a nominal type. + _unique: 'DartMap'; +} + // There may be a better way to declare this, but I can't figure it out. // eslint-disable-next-line @typescript-eslint/no-namespace declare namespace SassInternal { @@ -46,6 +54,8 @@ declare namespace SassInternal { function setToJS(set: DartSet): Set; + function mapToRecord(set: DartMap): Record; + class StatementVisitor { private _fakePropertyToMakeThisAUniqueType1: T; } @@ -66,6 +76,13 @@ declare namespace SassInternal { readonly span: FileSpan; } + class ArgumentList extends SassNode { + readonly positional: Expression[]; + readonly named: DartMap; + readonly rest?: Expression; + readonly keywordRest?: Expression; + } + class Interpolation extends SassNode { contents: (string | Expression)[]; get asPlain(): string | undefined; @@ -99,6 +116,11 @@ declare namespace SassInternal { readonly value?: Interpolation; } + class ContentBlock extends ParentStatement { + readonly name: string; + readonly parameters: ParameterList; + } + class DebugRule extends Statement { readonly expression: Expression; } @@ -139,6 +161,13 @@ declare namespace SassInternal { readonly parameters: ParameterList; } + class IncludeRule extends Statement { + readonly namespace: string | null; + readonly name: string; + readonly arguments: ArgumentList; + readonly content: ContentBlock | null; + } + class LoudComment extends Statement { readonly text: Interpolation; } @@ -283,8 +312,10 @@ export type SassNode = SassInternal.SassNode; export type Statement = SassInternal.Statement; export type ParentStatement = SassInternal.ParentStatement; +export type ArgumentList = SassInternal.ArgumentList; export type AtRootRule = SassInternal.AtRootRule; export type AtRule = SassInternal.AtRule; +export type ContentBlock = SassInternal.ContentBlock; export type DebugRule = SassInternal.DebugRule; export type EachRule = SassInternal.EachRule; export type ErrorRule = SassInternal.ErrorRule; @@ -292,6 +323,7 @@ export type ExtendRule = SassInternal.ExtendRule; export type ForRule = SassInternal.ForRule; export type ForwardRule = SassInternal.ForwardRule; export type FunctionRule = SassInternal.FunctionRule; +export type IncludeRule = SassInternal.IncludeRule; export type LoudComment = SassInternal.LoudComment; export type MediaRule = SassInternal.MediaRule; export type MixinRule = SassInternal.MixinRule; @@ -324,6 +356,7 @@ export interface StatementVisitorObject { visitForRule(node: ForRule): T; visitForwardRule(node: ForwardRule): T; visitFunctionRule(node: FunctionRule): T; + visitIncludeRule(node: IncludeRule): T; visitLoudComment(node: LoudComment): T; visitMediaRule(node: MediaRule): T; visitMixinRule(node: MixinRule): T; @@ -344,9 +377,10 @@ export interface ExpressionVisitorObject { visitNumberExpression(node: NumberExpression): T; } +export const createExpressionVisitor = sassInternal.createExpressionVisitor; +export const createStatementVisitor = sassInternal.createStatementVisitor; +export const mapToRecord = sassInternal.mapToRecord; export const parse = sassInternal.parse; export const parseIdentifier = sassInternal.parseIdentifier; -export const toCssIdentifier = sassInternal.toCssIdentifier; -export const createStatementVisitor = sassInternal.createStatementVisitor; -export const createExpressionVisitor = sassInternal.createExpressionVisitor; export const setToJS = sassInternal.setToJS; +export const toCssIdentifier = sassInternal.toCssIdentifier; diff --git a/pkg/sass-parser/lib/src/statement/__snapshots__/include-rule.test.ts.snap b/pkg/sass-parser/lib/src/statement/__snapshots__/include-rule.test.ts.snap new file mode 100644 index 000000000..672e8a29a --- /dev/null +++ b/pkg/sass-parser/lib/src/statement/__snapshots__/include-rule.test.ts.snap @@ -0,0 +1,68 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`a @include rule toJSON with a child 1`] = ` +{ + "arguments": <(bar)>, + "includeName": "foo", + "inputs": [ + { + "css": "@include foo(bar) {@qux}", + "hasBOM": false, + "id": "", + }, + ], + "name": "include", + "nodes": [ + <@qux;>, + ], + "params": "foo(bar)", + "raws": {}, + "sassType": "include-rule", + "source": <1:1-1:25 in 0>, + "type": "atrule", +} +`; + +exports[`a @include rule toJSON with no children 1`] = ` +{ + "arguments": <(bar)>, + "includeName": "foo", + "inputs": [ + { + "css": "@include foo(bar)", + "hasBOM": false, + "id": "", + }, + ], + "name": "include", + "params": "foo(bar)", + "raws": {}, + "sassType": "include-rule", + "source": <1:1-1:18 in 0>, + "type": "atrule", +} +`; + +exports[`a @include rule toJSON with using and a child 1`] = ` +{ + "arguments": <(bar)>, + "includeName": "foo", + "inputs": [ + { + "css": "@include foo(bar) using ($baz) {@qux}", + "hasBOM": false, + "id": "", + }, + ], + "name": "include", + "nodes": [ + <@qux;>, + ], + "params": "foo(bar) using ($baz)", + "raws": {}, + "sassType": "include-rule", + "source": <1:1-1:38 in 0>, + "type": "atrule", + "using": <($baz)>, +} +`; diff --git a/pkg/sass-parser/lib/src/statement/at-root-rule.test.ts b/pkg/sass-parser/lib/src/statement/at-root-rule.test.ts index 5f7440c3b..937f0d332 100644 --- a/pkg/sass-parser/lib/src/statement/at-root-rule.test.ts +++ b/pkg/sass-parser/lib/src/statement/at-root-rule.test.ts @@ -71,7 +71,7 @@ describe('an @at-root rule', () => { it('has no params', () => expect(node.params).toBe('')); it('contains a Rule', () => { - const rule = node.nodes[0] as Rule; + const rule = node.nodes![0] as Rule; expect(rule).toHaveInterpolation('selectorInterpolation', '.foo '); expect(rule.parent).toBe(node); }); 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 3b2ce97eb..1110c7092 100644 --- a/pkg/sass-parser/lib/src/statement/function-rule.test.ts +++ b/pkg/sass-parser/lib/src/statement/function-rule.test.ts @@ -15,6 +15,8 @@ describe('a @function rule', () => { describe(description, () => { beforeEach(() => void (node = create())); + it('has a sassType', () => expect(node.sassType).toBe('function-rule')); + it('has a name', () => expect(node.name.toString()).toBe('function')); it('has a function name', () => @@ -57,6 +59,8 @@ describe('a @function rule', () => { describe(description, () => { beforeEach(() => void (node = create())); + it('has a sassType', () => expect(node.sassType).toBe('function-rule')); + it('has a name', () => expect(node.name.toString()).toBe('function')); it('has a function name', () => diff --git a/pkg/sass-parser/lib/src/statement/function-rule.ts b/pkg/sass-parser/lib/src/statement/function-rule.ts index 95b6db714..30ef902f7 100644 --- a/pkg/sass-parser/lib/src/statement/function-rule.ts +++ b/pkg/sass-parser/lib/src/statement/function-rule.ts @@ -32,8 +32,8 @@ 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. + * This may be different than {@link FunctionRule.functionName} if the name + * contains escape codes or underscores. */ functionName?: RawWithValue; } diff --git a/pkg/sass-parser/lib/src/statement/generic-at-rule.test.ts b/pkg/sass-parser/lib/src/statement/generic-at-rule.test.ts index b4572969f..9d6494e52 100644 --- a/pkg/sass-parser/lib/src/statement/generic-at-rule.test.ts +++ b/pkg/sass-parser/lib/src/statement/generic-at-rule.test.ts @@ -229,8 +229,8 @@ describe('a generic @-rule', () => { it('has a child node', () => { expect(node.nodes).toHaveLength(1); - expect(node.nodes[0]).toBeInstanceOf(Rule); - expect(node.nodes[0]).toHaveProperty('selector', '.bar\n'); + expect(node.nodes![0]).toBeInstanceOf(Rule); + expect(node.nodes![0]).toHaveProperty('selector', '.bar\n'); }); }); }); @@ -252,8 +252,8 @@ describe('a generic @-rule', () => { it('has a child node', () => { expect(node.nodes).toHaveLength(1); - expect(node.nodes[0]).toBeInstanceOf(Rule); - expect(node.nodes[0]).toHaveProperty('selector', '.baz\n'); + expect(node.nodes![0]).toBeInstanceOf(Rule); + expect(node.nodes![0]).toHaveProperty('selector', '.baz\n'); }); }); } @@ -593,8 +593,8 @@ describe('a generic @-rule', () => { it('nodes', () => { expect(clone.nodes).toHaveLength(1); - expect(clone.nodes[0]).toBeInstanceOf(Rule); - expect(clone.nodes[0]).toHaveProperty('selector', '.baz '); + expect(clone.nodes![0]).toBeInstanceOf(Rule); + expect(clone.nodes![0]).toHaveProperty('selector', '.baz '); }); }); @@ -612,7 +612,7 @@ describe('a generic @-rule', () => { }); describe('sets parent for', () => { - it('nodes', () => expect(clone.nodes[0].parent).toBe(clone)); + it('nodes', () => expect(clone.nodes![0].parent).toBe(clone)); }); }); diff --git a/pkg/sass-parser/lib/src/statement/include-rule.test.ts b/pkg/sass-parser/lib/src/statement/include-rule.test.ts new file mode 100644 index 000000000..724df29d5 --- /dev/null +++ b/pkg/sass-parser/lib/src/statement/include-rule.test.ts @@ -0,0 +1,581 @@ +// 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, + GenericAtRule, + IncludeRule, + ParameterList, + sass, + scss, +} from '../..'; +import * as utils from '../../../test/utils'; + +describe('a @include rule', () => { + let node: IncludeRule; + describe('with no block', () => { + function describeNode( + description: string, + create: () => IncludeRule, + ): void { + describe(description, () => { + beforeEach(() => void (node = create())); + + it('has a sassType', () => expect(node.sassType).toBe('include-rule')); + + it('has a name', () => expect(node.name.toString()).toBe('include')); + + it('has an include name', () => + expect(node.includeName.toString()).toBe('foo')); + + it('has an argument', () => + expect(node.arguments.nodes[0]).toHaveStringExpression( + 'value', + 'bar', + )); + + it('has no using', () => expect(node.using).toBe(undefined)); + + it('has matching params', () => expect(node.params).toBe('foo(bar)')); + + it('has no nodes', () => expect(node.nodes).toBeUndefined()); + }); + } + + describeNode( + 'parsed as SCSS', + () => scss.parse('@include foo(bar)').nodes[0] as IncludeRule, + ); + + describeNode( + 'parsed as Sass', + () => sass.parse('@include foo(bar)').nodes[0] as IncludeRule, + ); + + describeNode( + 'constructed manually', + () => new IncludeRule({includeName: 'foo', arguments: [{text: 'bar'}]}), + ); + + describeNode('constructed from ChildProps', () => + utils.fromChildProps({includeName: 'foo', arguments: [{text: 'bar'}]}), + ); + }); + + describe('with a child', () => { + function describeNode( + description: string, + create: () => IncludeRule, + ): void { + describe(description, () => { + beforeEach(() => void (node = create())); + + it('has a sassType', () => expect(node.sassType).toBe('include-rule')); + + it('has a name', () => expect(node.name.toString()).toBe('include')); + + it('has a include name', () => + expect(node.includeName.toString()).toBe('foo')); + + it('has an argument', () => + expect(node.arguments.nodes[0]).toHaveStringExpression( + 'value', + 'bar', + )); + + it('has no using', () => expect(node.using).toBe(undefined)); + + 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('@include foo(bar) {@baz}').nodes[0] as IncludeRule, + ); + + describeNode( + 'parsed as Sass', + () => sass.parse('@include foo(bar)\n @baz').nodes[0] as IncludeRule, + ); + + describeNode( + 'constructed manually', + () => + new IncludeRule({ + includeName: 'foo', + arguments: [{text: 'bar'}], + nodes: [{name: 'baz'}], + }), + ); + + describeNode('constructed from ChildProps', () => + utils.fromChildProps({ + includeName: 'foo', + arguments: [{text: 'bar'}], + nodes: [{name: 'baz'}], + }), + ); + }); + + describe('with using', () => { + function describeNode( + description: string, + create: () => IncludeRule, + ): void { + describe(description, () => { + beforeEach(() => void (node = create())); + + it('has a sassType', () => expect(node.sassType).toBe('include-rule')); + + it('has a name', () => expect(node.name.toString()).toBe('include')); + + it('has a include name', () => + expect(node.includeName.toString()).toBe('foo')); + + it('has an argument', () => + expect(node.arguments.nodes[0]).toHaveStringExpression( + 'value', + 'bar', + )); + + it('has a using parameter', () => + expect(node.using!.nodes[0].name).toBe('baz')); + + it('has matching params', () => + expect(node.params).toBe('foo(bar) using ($baz)')); + + it('has a child node', () => { + expect(node.nodes).toHaveLength(1); + expect(node.nodes![0]).toBeInstanceOf(GenericAtRule); + expect(node.nodes![0]).toHaveInterpolation( + 'nameInterpolation', + 'qux', + ); + }); + }); + } + + describeNode( + 'parsed as SCSS', + () => + scss.parse('@include foo(bar) using ($baz) {@qux}') + .nodes[0] as IncludeRule, + ); + + describeNode( + 'parsed as Sass', + () => + sass.parse('@include foo(bar) using ($baz)\n @qux') + .nodes[0] as IncludeRule, + ); + + describeNode( + 'constructed manually', + () => + new IncludeRule({ + includeName: 'foo', + arguments: [{text: 'bar'}], + using: ['baz'], + nodes: [{name: 'qux'}], + }), + ); + + describeNode('constructed from ChildProps', () => + utils.fromChildProps({ + includeName: 'foo', + arguments: [{text: 'bar'}], + using: ['baz'], + nodes: [{name: 'qux'}], + }), + ); + }); + + describe('throws an error when assigned a new', () => { + beforeEach( + () => + void (node = scss.parse('@include foo(bar)').nodes[0] as IncludeRule), + ); + + it('name', () => expect(() => (node.name = 'qux')).toThrow()); + + it('params', () => expect(() => (node.params = 'zip(zap)')).toThrow()); + }); + + describe('assigned new arguments', () => { + beforeEach( + () => + void (node = scss.parse('@include foo(bar)').nodes[0] as IncludeRule), + ); + + 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('assigned new using', () => { + beforeEach( + () => + void (node = scss.parse('@include foo(bar) using ($baz) {}') + .nodes[0] as IncludeRule), + ); + + it("removes the old using' parent", () => { + const oldUsing = node.using!; + node.using = ['qux']; + expect(oldUsing.parent).toBeUndefined(); + }); + + it("assigns the new using' parent", () => { + const using = new ParameterList(['qux']); + node.using = using; + expect(using.parent).toBe(node); + }); + + it('assigns the using explicitly', () => { + const using = new ParameterList(['qux']); + node.using = using; + expect(node.using).toBe(using); + }); + + it('assigns the expression as ParameterProps', () => { + node.using = ['qux']; + expect(node.using!.nodes[0].name).toBe('qux'); + expect(node.using!.parent).toBe(node); + }); + }); + + describe('stringifies', () => { + describe('to SCSS', () => { + describe('with default raws', () => { + it('with no arguments', () => + expect(new IncludeRule({includeName: 'foo'}).toString()).toBe( + '@include foo;', + )); + + it('with an argument', () => + expect( + new IncludeRule({ + includeName: 'foo', + arguments: [{text: 'bar'}], + }).toString(), + ).toBe('@include foo(bar);')); + + it('with empty using', () => + expect( + new IncludeRule({ + includeName: 'foo', + using: [], + nodes: [], + }).toString(), + ).toBe('@include foo using () {}')); + + it('with a using parameter', () => + expect( + new IncludeRule({ + includeName: 'foo', + using: ['bar'], + nodes: [], + }).toString(), + ).toBe('@include foo using ($bar) {}')); + + it('with a non-identifier name', () => + expect( + new IncludeRule({ + includeName: 'f o', + arguments: [{text: 'bar'}], + }).toString(), + ).toBe('@include f\\20o(bar);')); + }); + + it('with afterName', () => + expect( + new IncludeRule({ + includeName: 'foo', + raws: {afterName: '/**/'}, + }).toString(), + ).toBe('@include/**/foo;')); + + it('with matching includeName', () => + expect( + new IncludeRule({ + includeName: 'foo', + raws: {includeName: {value: 'foo', raw: 'f\\6fo'}}, + }).toString(), + ).toBe('@include f\\6fo;')); + + it('with non-matching includeName', () => + expect( + new IncludeRule({ + includeName: 'foo', + raws: {includeName: {value: 'fao', raw: 'f\\41o'}}, + }).toString(), + ).toBe('@include foo;')); + + it('with showArguments = true', () => + expect( + new IncludeRule({ + includeName: 'foo', + raws: {showArguments: true}, + }).toString(), + ).toBe('@include foo();')); + + it('ignores showArguments with an argument', () => + expect( + new IncludeRule({ + includeName: 'foo', + arguments: [{text: 'bar'}], + raws: {showArguments: true}, + }).toString(), + ).toBe('@include foo(bar);')); + + describe('with afterArguments', () => { + it('with no using', () => + expect( + new IncludeRule({ + includeName: 'foo', + arguments: [{text: 'bar'}], + raws: {afterArguments: '/**/'}, + }).toString(), + ).toBe('@include foo(bar);')); + + it('with using', () => + expect( + new IncludeRule({ + includeName: 'foo', + arguments: [{text: 'bar'}], + using: ['baz'], + nodes: [], + raws: {afterArguments: '/**/'}, + }).toString(), + ).toBe('@include foo(bar)/**/using ($baz) {}')); + + it('with no arguments', () => + expect( + new IncludeRule({ + includeName: 'foo', + using: ['baz'], + raws: {afterArguments: '/**/'}, + }).toString(), + ).toBe('@include foo/**/using ($baz);')); + }); + + describe('with afterUsing', () => { + it('with no using', () => + expect( + new IncludeRule({ + includeName: 'foo', + arguments: [{text: 'bar'}], + raws: {afterUsing: '/**/'}, + }).toString(), + ).toBe('@include foo(bar);')); + + it('with using', () => + expect( + new IncludeRule({ + includeName: 'foo', + using: ['baz'], + raws: {afterUsing: '/**/'}, + }).toString(), + ).toBe('@include foo using/**/($baz);')); + }); + }); + }); + + describe('clone', () => { + let original: IncludeRule; + beforeEach(() => { + original = scss.parse('@include foo(bar) using ($baz) {}') + .nodes[0] as IncludeRule; + // TODO: remove this once raws are properly parsed + original.raws.between = ' '; + }); + + describe('with no overrides', () => { + let clone: IncludeRule; + beforeEach(() => void (clone = original.clone())); + + describe('has the same properties:', () => { + it('params', () => expect(clone.params).toBe('foo(bar) using ($baz)')); + + it('includeName', () => expect(clone.includeName).toBe('foo')); + + it('arguments', () => { + expect(clone.arguments.nodes[0]).toHaveStringExpression( + 'value', + 'bar', + ); + expect(clone.arguments.parent).toBe(clone); + }); + + it('using', () => { + expect(clone.using!.nodes[0].name).toBe('baz'); + expect(clone.using!.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 ['arguments', 'using', '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('includeName', () => { + describe('defined', () => { + let clone: IncludeRule; + beforeEach(() => { + clone = original.clone({includeName: 'qux'}); + }); + + it('changes params', () => + expect(clone.params).toBe('qux(bar) using ($baz)')); + + it('changes includeName', () => + expect(clone.includeName).toEqual('qux')); + }); + + describe('undefined', () => { + let clone: IncludeRule; + beforeEach(() => { + clone = original.clone({includeName: undefined}); + }); + + it('preserves params', () => + expect(clone.params).toBe('foo(bar) using ($baz)')); + + it('preserves includeName', () => + expect(clone.includeName).toEqual('foo')); + }); + }); + + describe('arguments', () => { + describe('defined', () => { + let clone: IncludeRule; + beforeEach(() => { + clone = original.clone({arguments: [{text: 'qux'}]}); + }); + + it('changes params', () => + expect(clone.params).toBe('foo(qux) using ($baz)')); + + it('changes arguments', () => { + expect(clone.arguments.nodes[0]).toHaveStringExpression( + 'value', + 'qux', + ); + expect(clone.arguments.parent).toBe(clone); + }); + }); + + describe('undefined', () => { + let clone: IncludeRule; + beforeEach(() => { + clone = original.clone({arguments: undefined}); + }); + + it('preserves params', () => + expect(clone.params).toBe('foo(bar) using ($baz)')); + + it('preserves arguments', () => + expect(clone.arguments.nodes[0]).toHaveStringExpression( + 'value', + 'bar', + )); + }); + }); + + describe('using', () => { + describe('defined', () => { + let clone: IncludeRule; + beforeEach(() => { + clone = original.clone({using: ['qux']}); + }); + + it('changes params', () => + expect(clone.params).toBe('foo(bar) using ($qux)')); + + it('changes arguments', () => { + expect(clone.using!.nodes[0].name).toBe('qux'); + expect(clone.using!.parent).toBe(clone); + }); + }); + + describe('undefined', () => { + let clone: IncludeRule; + beforeEach(() => { + clone = original.clone({using: undefined}); + }); + + it('changes params', () => expect(clone.params).toBe('foo(bar)')); + + it('changes using', () => expect(clone.using).toBeUndefined()); + }); + }); + }); + }); + + describe('toJSON', () => { + it('with no children', () => + expect(scss.parse('@include foo(bar)').nodes[0]).toMatchSnapshot()); + + it('with a child', () => + expect( + scss.parse('@include foo(bar) {@qux}').nodes[0], + ).toMatchSnapshot()); + + it('with using and a child', () => + expect( + scss.parse('@include foo(bar) using ($baz) {@qux}').nodes[0], + ).toMatchSnapshot()); + }); +}); diff --git a/pkg/sass-parser/lib/src/statement/include-rule.ts b/pkg/sass-parser/lib/src/statement/include-rule.ts new file mode 100644 index 000000000..74290fcf6 --- /dev/null +++ b/pkg/sass-parser/lib/src/statement/include-rule.ts @@ -0,0 +1,251 @@ +// 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 {ArgumentList, ArgumentListProps} from '../argument-list'; +import {LazySource} from '../lazy-source'; +import {Node} from '../node'; +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 IncludeRule}. + * + * @category Statement + */ +export interface IncludeRuleRaws extends Omit { + /** + * The mixin's namespace. + * + * This may be different than {@link IncludeRule.namespace} if the name + * contains escape codes or underscores. + */ + namespace?: RawWithValue; + + /** + * The mixin's name. + * + * This may be different than {@link IncludeRule.includeName} if the name + * contains escape codes or underscores. + */ + includeName?: RawWithValue; + + /** + * Whether to include an empty argument list. If the argument list isn't + * empty, this is ignored. + */ + showArguments?: boolean; + + /** + * The whitespace between the argument list and the `using` identifier. + * + * This is ignored if {@link IncludeRule.usingParameters} isn't defined. + */ + afterArguments?: string; + + /** + * The whitespace between the `using` identifier and the using parameters. + * + * This is ignored if {@link IncludeRule.usingParameters} isn't defined. + */ + afterUsing?: string; +} + +/** + * The initializer properties for {@link IncludeRule}. + * + * @category Statement + */ +export type IncludeRuleProps = ContainerProps & { + raws?: IncludeRuleRaws; + includeName: string; + arguments?: ArgumentList | ArgumentListProps; + using?: ParameterList | ParameterListProps; +}; + +/** + * An `@include` rule. Extends [`postcss.AtRule`]. + * + * [`postcss.AtRule`]: https://postcss.org/api/#atrule + * + * @category Statement + */ +export class IncludeRule + extends _AtRule> + implements Statement +{ + readonly sassType = 'include-rule' as const; + declare parent: StatementWithChildren | undefined; + declare raws: IncludeRuleRaws; + declare nodes: ChildNode[] | undefined; + + /** + * The mixin's namespace. + * + * This is the parsed value, with escapes resolved to the characters they + * represent. + */ + declare namespace: string | undefined; + + /** + * The name of the mixin being included. + * + * This is the parsed and normalized value, with underscores converted to + * hyphens and escapes resolved to the characters they represent. + */ + declare includeName: string; + + /** The arguments to pass to the mixin. */ + get arguments(): ArgumentList { + return this._arguments!; + } + set arguments(args: ArgumentList | ArgumentListProps) { + if (this._arguments) { + this._arguments.parent = undefined; + } + this._arguments = 'sassType' in args ? args : new ArgumentList(args); + this._arguments.parent = this; + } + private declare _arguments: ArgumentList; + + /** The parameters that the `@content` block takes. */ + get using(): ParameterList | undefined { + return this._using; + } + set using(parameters: ParameterList | ParameterListProps | undefined) { + if (this._using) { + this._using.parent = undefined; + } + if (parameters) { + this._using = + 'sassType' in parameters ? parameters : new ParameterList(parameters); + this._using.parent = this; + } else { + this._using = undefined; + } + } + private declare _using?: ParameterList; + + get name(): string { + return 'include'; + } + set name(value: string) { + throw new Error("IncludeRule.name can't be overwritten."); + } + + get params(): string { + return ( + (this.namespace + ? (this.raws.namespace?.value === this.namespace + ? this.raws.namespace.raw + : sassInternal.toCssIdentifier(this.namespace)) + '.' + : '') + + (this.raws.includeName?.value === this.includeName + ? this.raws.includeName!.raw + : sassInternal.toCssIdentifier(this.includeName)) + + (!this.raws.showArguments && this.arguments.nodes.length === 0 + ? '' + : this.arguments) + + (this.using + ? (this.raws.afterArguments ?? ' ') + + 'using' + + (this.raws.afterUsing ?? ' ') + + this.using + : '') + ); + } + set params(value: string | number | undefined) { + throw new Error("IncludeRule.params can't be overwritten."); + } + + constructor(defaults: IncludeRuleProps); + /** @hidden */ + constructor(_: undefined, inner: sassInternal.IncludeRule); + constructor(defaults?: IncludeRuleProps, inner?: sassInternal.IncludeRule) { + super(defaults as unknown as postcss.AtRuleProps); + + if (inner) { + this.source = new LazySource(inner); + this.namespace = inner.namespace ?? undefined; + this.includeName = inner.name; + this.arguments = new ArgumentList(undefined, inner.arguments); + if (inner.content) { + if (inner.content.parameters.parameters.length > 0) { + this.using = new ParameterList(undefined, inner.content.parameters); + } + this.nodes = []; + appendInternalChildren(this, inner.content.children); + } + } + this._arguments ??= new ArgumentList(); + } + + clone(overrides?: Partial): this { + return utils.cloneNode(this, overrides, [ + 'raws', + {name: 'namespace', explicitUndefined: true}, + 'includeName', + 'arguments', + {name: 'using', explicitUndefined: true}, + ]); + } + + toJSON(): object; + /** @hidden */ + toJSON(_: string, inputs: Map): object; + toJSON(_?: string, inputs?: Map): object { + return utils.toJSON( + this, + [ + 'name', + 'params', + 'namespace', + 'includeName', + 'arguments', + 'using', + 'nodes', + ], + inputs, + ); + } + + /** @hidden */ + toString( + stringifier: postcss.Stringifier | postcss.Syntax = sassParser.scss + .stringify, + ): string { + return super.toString(stringifier); + } + + /** @hidden */ + get nonStatementChildren(): ReadonlyArray { + const result: Node[] = [this.arguments]; + if (this.using) result.push(this.using); + return result; + } + + /** @hidden */ + normalize(node: NewNode, sample?: postcss.Node): ChildNode[] { + this.nodes ??= []; + return normalize(this as StatementWithChildren, node, sample); + } +} + +interceptIsClean(IncludeRule); diff --git a/pkg/sass-parser/lib/src/statement/index.ts b/pkg/sass-parser/lib/src/statement/index.ts index b5bf47e4a..92dd22f48 100644 --- a/pkg/sass-parser/lib/src/statement/index.ts +++ b/pkg/sass-parser/lib/src/statement/index.ts @@ -18,6 +18,7 @@ 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 {IncludeRule, IncludeRuleProps} from './include-rule'; import {MixinRule, MixinRuleProps} from './mixin-rule'; import {ReturnRule, ReturnRuleProps} from './return-rule'; import {Root} from './root'; @@ -61,6 +62,7 @@ export type StatementType = | 'for-rule' | 'forward-rule' | 'function-rule' + | 'include-rule' | 'mixin-rule' | 'return-rule' | 'sass-comment' @@ -82,6 +84,7 @@ export type AtRule = | ForwardRule | FunctionRule | GenericAtRule + | IncludeRule | MixinRule | ReturnRule | UseRule @@ -121,6 +124,7 @@ export type ChildProps = | ForwardRuleProps | FunctionRuleProps | GenericAtRuleProps + | IncludeRuleProps | MixinRuleProps | ReturnRuleProps | RuleProps @@ -184,6 +188,7 @@ const visitor = sassInternal.createStatementVisitor({ visitForRule: inner => new ForRule(undefined, inner), visitForwardRule: inner => new ForwardRule(undefined, inner), visitFunctionRule: inner => new FunctionRule(undefined, inner), + visitIncludeRule: inner => new IncludeRule(undefined, inner), visitExtendRule: inner => { const paramsInterpolation = new Interpolation(undefined, inner.selector); if (inner.isOptional) paramsInterpolation.append('!optional'); @@ -331,6 +336,8 @@ export function normalize( result.push(new EachRule(node)); } else if ('errorExpression' in node) { result.push(new ErrorRule(node)); + } else if ('includeName' in node) { + result.push(new IncludeRule(node)); } else if ('fromExpression' in node) { result.push(new ForRule(node)); } else if ('forwardUrl' in node) { diff --git a/pkg/sass-parser/lib/src/statement/variable-declaration.ts b/pkg/sass-parser/lib/src/statement/variable-declaration.ts index 6f378153e..6525abf26 100644 --- a/pkg/sass-parser/lib/src/statement/variable-declaration.ts +++ b/pkg/sass-parser/lib/src/statement/variable-declaration.ts @@ -81,7 +81,7 @@ export class VariableDeclaration declare raws: VariableDeclarationRaws; /** - * The variable name, not including `$`. + * The variable's namespace. * * This is the parsed value, with escapes resolved to the characters they * represent. diff --git a/pkg/sass-parser/lib/src/stringifier.ts b/pkg/sass-parser/lib/src/stringifier.ts index 4107180af..a2862ccaa 100644 --- a/pkg/sass-parser/lib/src/stringifier.ts +++ b/pkg/sass-parser/lib/src/stringifier.ts @@ -103,6 +103,10 @@ export class Stringifier extends PostCssStringifier { this.sassAtRule(node, semicolon); } + private ['include-rule'](node: FunctionRule, semicolon: boolean): void { + this.sassAtRule(node, semicolon); + } + private ['mixin-rule'](node: MixinRule, semicolon: boolean): void { this.sassAtRule(node, semicolon); }