diff --git a/pkg/sass-parser/CHANGELOG.md b/pkg/sass-parser/CHANGELOG.md index c32ae7595..318bf6151 100644 --- a/pkg/sass-parser/CHANGELOG.md +++ b/pkg/sass-parser/CHANGELOG.md @@ -1,6 +1,6 @@ ## 0.4.3-dev -* No user-visible changes. +* Add support for parsing the `@while` rule. ## 0.4.2 diff --git a/pkg/sass-parser/lib/index.ts b/pkg/sass-parser/lib/index.ts index 6e006484b..f9acab292 100644 --- a/pkg/sass-parser/lib/index.ts +++ b/pkg/sass-parser/lib/index.ts @@ -102,6 +102,11 @@ export { VariableDeclarationRaws, } from './src/statement/variable-declaration'; export {WarnRule, WarnRuleProps, WarnRuleRaws} from './src/statement/warn-rule'; +export { + WhileRule, + WhileRuleProps, + WhileRuleRaws, +} from './src/statement/while-rule'; /** Options that can be passed to the Sass parsers to control their behavior. */ export type SassParserOptions = Pick; diff --git a/pkg/sass-parser/lib/src/sass-internal.ts b/pkg/sass-parser/lib/src/sass-internal.ts index 1913eb647..e4031d367 100644 --- a/pkg/sass-parser/lib/src/sass-internal.ts +++ b/pkg/sass-parser/lib/src/sass-internal.ts @@ -192,6 +192,10 @@ declare namespace SassInternal { readonly expression: Expression; } + class WhileRule extends ParentStatement { + readonly condition: Expression; + } + class ConfiguredVariable extends SassNode { readonly name: string; readonly expression: Expression; @@ -252,6 +256,7 @@ export type SupportsRule = SassInternal.SupportsRule; export type UseRule = SassInternal.UseRule; export type VariableDeclaration = SassInternal.VariableDeclaration; export type WarnRule = SassInternal.WarnRule; +export type WhileRule = SassInternal.WhileRule; export type ConfiguredVariable = SassInternal.ConfiguredVariable; export type Interpolation = SassInternal.Interpolation; export type Expression = SassInternal.Expression; @@ -276,6 +281,7 @@ export interface StatementVisitorObject { visitUseRule(node: UseRule): T; visitVariableDeclaration(node: VariableDeclaration): T; visitWarnRule(node: WarnRule): T; + visitWhileRule(node: WhileRule): T; } export interface ExpressionVisitorObject { diff --git a/pkg/sass-parser/lib/src/statement/__snapshots__/while-rule.test.ts.snap b/pkg/sass-parser/lib/src/statement/__snapshots__/while-rule.test.ts.snap new file mode 100644 index 000000000..792686aa3 --- /dev/null +++ b/pkg/sass-parser/lib/src/statement/__snapshots__/while-rule.test.ts.snap @@ -0,0 +1,21 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`a @while rule toJSON 1`] = ` +{ + "inputs": [ + { + "css": "@while foo {}", + "hasBOM": false, + "id": "", + }, + ], + "name": "while", + "nodes": [], + "params": "foo", + "raws": {}, + "sassType": "while-rule", + "source": <1:1-1:14 in 0>, + "type": "atrule", + "whileCondition": , +} +`; diff --git a/pkg/sass-parser/lib/src/statement/index.ts b/pkg/sass-parser/lib/src/statement/index.ts index 0cec9b4a8..3be667fd3 100644 --- a/pkg/sass-parser/lib/src/statement/index.ts +++ b/pkg/sass-parser/lib/src/statement/index.ts @@ -23,6 +23,7 @@ import { VariableDeclarationProps, } from './variable-declaration'; import {WarnRule, WarnRuleProps} from './warn-rule'; +import {WhileRule, WhileRuleProps} from './while-rule'; // TODO: Replace this with the corresponding Sass types once they're // implemented. @@ -56,7 +57,8 @@ export type StatementType = | 'use-rule' | 'sass-comment' | 'variable-declaration' - | 'warn-rule'; + | 'warn-rule' + | 'while-rule'; /** * All Sass statements that are also at-rules. @@ -70,7 +72,8 @@ export type AtRule = | ForRule | GenericAtRule | UseRule - | WarnRule; + | WarnRule + | WhileRule; /** * All Sass statements that are comments. @@ -107,7 +110,8 @@ export type ChildProps = | SassCommentChildProps | UseRuleProps | VariableDeclarationProps - | WarnRuleProps; + | WarnRuleProps + | WhileRuleProps; /** * The Sass eqivalent of PostCSS's `ContainerProps`. @@ -197,6 +201,7 @@ const visitor = sassInternal.createStatementVisitor({ visitUseRule: inner => new UseRule(undefined, inner), visitVariableDeclaration: inner => new VariableDeclaration(undefined, inner), visitWarnRule: inner => new WarnRule(undefined, inner), + visitWhileRule: inner => new WhileRule(undefined, inner), }); /** Appends parsed versions of `internal`'s children to `container`. */ @@ -317,6 +322,8 @@ export function normalize( result.push(new VariableDeclaration(node)); } else if ('warnExpression' in node) { result.push(new WarnRule(node)); + } else if ('whileCondition' in node) { + result.push(new WhileRule(node)); } else { result.push(...postcssNormalizeAndConvertToSass(self, node, sample)); } diff --git a/pkg/sass-parser/lib/src/statement/while-rule.test.ts b/pkg/sass-parser/lib/src/statement/while-rule.test.ts new file mode 100644 index 000000000..473534dee --- /dev/null +++ b/pkg/sass-parser/lib/src/statement/while-rule.test.ts @@ -0,0 +1,239 @@ +// Copyright 2024 Google Inc. Use of this source code is governed by an +// MIT-style license that can be found in the LICENSE file or at +// https://opensource.org/licenses/MIT. + +import {GenericAtRule, StringExpression, WhileRule, sass, scss} from '../..'; +import * as utils from '../../../test/utils'; + +describe('a @while rule', () => { + let node: WhileRule; + describe('with empty children', () => { + function describeNode(description: string, create: () => WhileRule): void { + describe(description, () => { + beforeEach(() => void (node = create())); + + it('has a name', () => expect(node.name.toString()).toBe('while')); + + it('has an expression', () => + expect(node).toHaveStringExpression('whileCondition', 'foo')); + + it('has matching params', () => expect(node.params).toBe('foo')); + + it('has empty nodes', () => expect(node.nodes).toEqual([])); + }); + } + + describeNode( + 'parsed as SCSS', + () => scss.parse('@while foo {}').nodes[0] as WhileRule, + ); + + describeNode( + 'parsed as Sass', + () => sass.parse('@while foo').nodes[0] as WhileRule, + ); + + describeNode( + 'constructed manually', + () => + new WhileRule({ + whileCondition: {text: 'foo'}, + }), + ); + + describeNode('constructed from ChildProps', () => + utils.fromChildProps({ + whileCondition: {text: 'foo'}, + }), + ); + }); + + describe('with a child', () => { + function describeNode(description: string, create: () => WhileRule): void { + describe(description, () => { + beforeEach(() => void (node = create())); + + it('has a name', () => expect(node.name.toString()).toBe('while')); + + it('has an expression', () => + expect(node).toHaveStringExpression('whileCondition', 'foo')); + + it('has matching params', () => expect(node.params).toBe('foo')); + + it('has a child node', () => { + expect(node.nodes).toHaveLength(1); + expect(node.nodes[0]).toBeInstanceOf(GenericAtRule); + expect(node.nodes[0]).toHaveProperty('name', 'child'); + }); + }); + } + + describeNode( + 'parsed as SCSS', + () => scss.parse('@while foo {@child}').nodes[0] as WhileRule, + ); + + describeNode( + 'parsed as Sass', + () => sass.parse('@while foo\n @child').nodes[0] as WhileRule, + ); + + describeNode( + 'constructed manually', + () => + new WhileRule({ + whileCondition: {text: 'foo'}, + nodes: [{name: 'child'}], + }), + ); + + describeNode('constructed from ChildProps', () => + utils.fromChildProps({ + whileCondition: {text: 'foo'}, + nodes: [{name: 'child'}], + }), + ); + }); + + describe('throws an error when assigned a new', () => { + beforeEach( + () => void (node = new WhileRule({whileCondition: {text: 'foo'}})), + ); + + it('name', () => expect(() => (node.name = 'bar')).toThrow()); + + it('params', () => expect(() => (node.params = 'true')).toThrow()); + }); + + describe('assigned a new expression', () => { + beforeEach(() => { + node = scss.parse('@while foo {}').nodes[0] as WhileRule; + }); + + it("removes the old expression's parent", () => { + const oldExpression = node.whileCondition; + node.whileCondition = {text: 'bar'}; + expect(oldExpression.parent).toBeUndefined(); + }); + + it("assigns the new expression's parent", () => { + const expression = new StringExpression({text: 'bar'}); + node.whileCondition = expression; + expect(expression.parent).toBe(node); + }); + + it('assigns the expression explicitly', () => { + const expression = new StringExpression({text: 'bar'}); + node.whileCondition = expression; + expect(node.whileCondition).toBe(expression); + }); + + it('assigns the expression as ExpressionProps', () => { + node.whileCondition = {text: 'bar'}; + expect(node).toHaveStringExpression('whileCondition', 'bar'); + }); + }); + + describe('stringifies', () => { + describe('to SCSS', () => { + it('with default raws', () => + expect( + new WhileRule({ + whileCondition: {text: 'foo'}, + }).toString(), + ).toBe('@while foo {}')); + + it('with afterName', () => + expect( + new WhileRule({ + whileCondition: {text: 'foo'}, + raws: {afterName: '/**/'}, + }).toString(), + ).toBe('@while/**/foo {}')); + + it('with between', () => + expect( + new WhileRule({ + whileCondition: {text: 'foo'}, + raws: {between: '/**/'}, + }).toString(), + ).toBe('@while foo/**/{}')); + }); + }); + + describe('clone', () => { + let original: WhileRule; + beforeEach(() => { + original = scss.parse('@while foo {}').nodes[0] as WhileRule; + // TODO: remove this once raws are properly parsed + original.raws.between = ' '; + }); + + describe('with no overrides', () => { + let clone: WhileRule; + beforeEach(() => void (clone = original.clone())); + + describe('has the same properties:', () => { + it('params', () => expect(clone.params).toBe('foo')); + + it('whileCondition', () => + expect(clone).toHaveStringExpression('whileCondition', 'foo')); + + it('raws', () => expect(clone.raws).toEqual({between: ' '})); + + it('source', () => expect(clone.source).toBe(original.source)); + }); + + describe('creates a new', () => { + it('self', () => expect(clone).not.toBe(original)); + + for (const attr of ['whileCondition', '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('whileCondition', () => { + describe('defined', () => { + let clone: WhileRule; + beforeEach(() => { + clone = original.clone({whileCondition: {text: 'bar'}}); + }); + + it('changes params', () => expect(clone.params).toBe('bar')); + + it('changes whileCondition', () => + expect(clone).toHaveStringExpression('whileCondition', 'bar')); + }); + + describe('undefined', () => { + let clone: WhileRule; + beforeEach(() => { + clone = original.clone({whileCondition: undefined}); + }); + + it('preserves params', () => expect(clone.params).toBe('foo')); + + it('preserves whileCondition', () => + expect(clone).toHaveStringExpression('whileCondition', 'foo')); + }); + }); + }); + }); + + it('toJSON', () => + expect(scss.parse('@while foo {}').nodes[0]).toMatchSnapshot()); +}); diff --git a/pkg/sass-parser/lib/src/statement/while-rule.ts b/pkg/sass-parser/lib/src/statement/while-rule.ts new file mode 100644 index 000000000..9284454d4 --- /dev/null +++ b/pkg/sass-parser/lib/src/statement/while-rule.ts @@ -0,0 +1,136 @@ +// 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 {convertExpression} from '../expression/convert'; +import {Expression, ExpressionProps} from '../expression'; +import {fromProps} from '../expression/from-props'; +import {LazySource} from '../lazy-source'; +import type * as sassInternal from '../sass-internal'; +import * as utils from '../utils'; +import { + 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 WhileRule}. + * + * @category Statement + */ +export type WhileRuleRaws = Omit; + +/** + * The initializer properties for {@link WhileRule}. + * + * @category Statement + */ +export type WhileRuleProps = ContainerProps & { + raws?: WhileRuleRaws; + whileCondition: Expression | ExpressionProps; +}; + +/** + * A `@while` rule. Extends [`postcss.AtRule`]. + * + * [`postcss.AtRule`]: https://postcss.org/api/#atrule + * + * @category Statement + */ +export class WhileRule + extends _AtRule> + implements Statement +{ + readonly sassType = 'while-rule' as const; + declare parent: StatementWithChildren | undefined; + declare raws: WhileRuleRaws; + declare nodes: ChildNode[]; + + get name(): string { + return 'while'; + } + set name(value: string) { + throw new Error("WhileRule.name can't be overwritten."); + } + + get params(): string { + return this.whileCondition.toString(); + } + set params(value: string | number | undefined) { + throw new Error("WhileRule.params can't be overwritten."); + } + + /** The expresison whose value is emitted when the while rule is executed. */ + get whileCondition(): Expression { + return this._whileCondition!; + } + set whileCondition(whileCondition: Expression | ExpressionProps) { + if (this._whileCondition) this._whileCondition.parent = undefined; + if (!('sassType' in whileCondition)) { + whileCondition = fromProps(whileCondition); + } + if (whileCondition) whileCondition.parent = this; + this._whileCondition = whileCondition; + } + private _whileCondition?: Expression; + + constructor(defaults: WhileRuleProps); + /** @hidden */ + constructor(_: undefined, inner: sassInternal.WhileRule); + constructor(defaults?: WhileRuleProps, inner?: sassInternal.WhileRule) { + super(defaults as unknown as postcss.AtRuleProps); + this.nodes ??= []; + + if (inner) { + this.source = new LazySource(inner); + this.whileCondition = convertExpression(inner.condition); + appendInternalChildren(this, inner.children); + } + } + + clone(overrides?: Partial): this { + return utils.cloneNode(this, overrides, ['raws', 'whileCondition']); + } + + toJSON(): object; + /** @hidden */ + toJSON(_: string, inputs: Map): object; + toJSON(_?: string, inputs?: Map): object { + return utils.toJSON( + this, + ['name', 'whileCondition', 'params', 'nodes'], + inputs, + ); + } + + /** @hidden */ + toString( + stringifier: postcss.Stringifier | postcss.Syntax = sassParser.scss + .stringify, + ): string { + return super.toString(stringifier); + } + + /** @hidden */ + get nonStatementChildren(): ReadonlyArray { + return [this.whileCondition]; + } + + /** @hidden */ + normalize(node: NewNode, sample?: postcss.Node): ChildNode[] { + return normalize(this, node, sample); + } +} + +interceptIsClean(WhileRule); diff --git a/pkg/sass-parser/lib/src/stringifier.ts b/pkg/sass-parser/lib/src/stringifier.ts index 99f302a59..f2d0e9fcc 100644 --- a/pkg/sass-parser/lib/src/stringifier.ts +++ b/pkg/sass-parser/lib/src/stringifier.ts @@ -37,6 +37,7 @@ import {Rule} from './statement/rule'; import {SassComment} from './statement/sass-comment'; import {UseRule} from './statement/use-rule'; import {WarnRule} from './statement/warn-rule'; +import {WhileRule} from './statement/while-rule'; const PostCssStringifier = require('postcss/lib/stringifier'); @@ -165,18 +166,20 @@ export class Stringifier extends PostCssStringifier { this.sassAtRule(node, semicolon); } + private ['while-rule'](node: WhileRule): void { + this.sassAtRule(node); + } + /** Helper method for non-generic Sass at-rules. */ private sassAtRule(node: postcss.AtRule, semicolon?: boolean): void { - const start = - '@' + - node.name + - (node.raws.afterName ?? ' ') + - node.params + - (node.raws.between ?? ''); + const start = '@' + node.name + (node.raws.afterName ?? ' ') + node.params; if (node.nodes) { this.block(node, start); } else { - this.builder(start + (semicolon ? ';' : ''), node); + this.builder( + start + (node.raws.between ?? '') + (semicolon ? ';' : ''), + node, + ); } } }