diff --git a/pkg/sass-parser/CHANGELOG.md b/pkg/sass-parser/CHANGELOG.md index 4ce6ba2e1..c1303f36f 100644 --- a/pkg/sass-parser/CHANGELOG.md +++ b/pkg/sass-parser/CHANGELOG.md @@ -4,6 +4,8 @@ * Add support for parsing declarations. +* Add support for parsing the `@if` and `@else` rules. + ## 0.4.8 * Add support for parsing the `@mixin` rule. diff --git a/pkg/sass-parser/lib/index.ts b/pkg/sass-parser/lib/index.ts index 590a7c186..4f7cf2ff1 100644 --- a/pkg/sass-parser/lib/index.ts +++ b/pkg/sass-parser/lib/index.ts @@ -105,6 +105,7 @@ export { DeclarationRaws, } from './src/statement/declaration'; export {EachRule, EachRuleProps, EachRuleRaws} from './src/statement/each-rule'; +export {ElseRule, ElseRuleProps, ElseRuleRaws} from './src/statement/else-rule'; export { ErrorRule, ErrorRuleProps, @@ -128,6 +129,7 @@ export { GenericAtRuleProps, GenericAtRuleRaws, } from './src/statement/generic-at-rule'; +export {IfRule, IfRuleProps, IfRuleRaws} from './src/statement/if-rule'; export { MixinRule, MixinRuleProps, diff --git a/pkg/sass-parser/lib/src/configured-variable.ts b/pkg/sass-parser/lib/src/configured-variable.ts index 85b7ff53e..06ceb1fc3 100644 --- a/pkg/sass-parser/lib/src/configured-variable.ts +++ b/pkg/sass-parser/lib/src/configured-variable.ts @@ -102,7 +102,7 @@ export class ConfiguredVariable extends Node { */ declare name: string; - /** The expresison whose value the variable is assigned. */ + /** The expression whose value the variable is assigned. */ get expression(): Expression { return this._expression!; } diff --git a/pkg/sass-parser/lib/src/sass-internal.ts b/pkg/sass-parser/lib/src/sass-internal.ts index d9023b480..e8a1acdfe 100644 --- a/pkg/sass-parser/lib/src/sass-internal.ts +++ b/pkg/sass-parser/lib/src/sass-internal.ts @@ -166,6 +166,20 @@ declare namespace SassInternal { readonly parameters: ParameterList; } + class IfRule extends Statement { + readonly clauses: IfClause[]; + readonly lastClause: ElseClause | null; + } + + class IfClause { + readonly expression: Expression; + readonly children: Statement[]; + } + + class ElseClause { + readonly children: Statement[]; + } + class IncludeRule extends Statement { readonly namespace: string | null; readonly name: string; @@ -329,6 +343,9 @@ export type ExtendRule = SassInternal.ExtendRule; export type ForRule = SassInternal.ForRule; export type ForwardRule = SassInternal.ForwardRule; export type FunctionRule = SassInternal.FunctionRule; +export type IfRule = SassInternal.IfRule; +export type IfClause = SassInternal.IfClause; +export type ElseClause = SassInternal.ElseClause; export type IncludeRule = SassInternal.IncludeRule; export type LoudComment = SassInternal.LoudComment; export type MediaRule = SassInternal.MediaRule; @@ -363,6 +380,7 @@ export interface StatementVisitorObject { visitForRule(node: ForRule): T; visitForwardRule(node: ForwardRule): T; visitFunctionRule(node: FunctionRule): T; + visitIfRule(node: IfRule): T; visitIncludeRule(node: IncludeRule): T; visitLoudComment(node: LoudComment): T; visitMediaRule(node: MediaRule): T; diff --git a/pkg/sass-parser/lib/src/statement/__snapshots__/declaration.test.ts.snap b/pkg/sass-parser/lib/src/statement/__snapshots__/declaration.test.ts.snap index 6bc83ab7b..5a198d734 100644 --- a/pkg/sass-parser/lib/src/statement/__snapshots__/declaration.test.ts.snap +++ b/pkg/sass-parser/lib/src/statement/__snapshots__/declaration.test.ts.snap @@ -10,11 +10,13 @@ exports[`a property declaration toJSON with expression and no nodes 1`] = ` "id": "", }, ], + "prop": "foo", "propInterpolation": , "raws": {}, "sassType": "decl", "source": <1:4-1:12 in 0>, "type": "decl", + "value": "bar", } `; @@ -31,11 +33,13 @@ exports[`a property declaration toJSON with expression and nodes 1`] = ` "nodes": [ , ], + "prop": "foo", "propInterpolation": , "raws": {}, "sassType": "decl", "source": <1:4-1:24 in 0>, "type": "decl", + "value": "bar", } `; @@ -51,10 +55,12 @@ exports[`a property declaration toJSON with no expression and nodes 1`] = ` "nodes": [ , ], + "prop": "foo", "propInterpolation": , "raws": {}, "sassType": "decl", "source": <1:4-1:20 in 0>, "type": "decl", + "value": "", } `; diff --git a/pkg/sass-parser/lib/src/statement/__snapshots__/else-rule.test.ts.snap b/pkg/sass-parser/lib/src/statement/__snapshots__/else-rule.test.ts.snap new file mode 100644 index 000000000..79e74f6b8 --- /dev/null +++ b/pkg/sass-parser/lib/src/statement/__snapshots__/else-rule.test.ts.snap @@ -0,0 +1,40 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`an @else rule toJSON with an expression 1`] = ` +{ + "elseCondition": , + "inputs": [ + { + "css": "@if foo {} @else if bar {}", + "hasBOM": false, + "id": "", + }, + ], + "name": "else", + "nodes": [], + "params": "if bar", + "raws": {}, + "sassType": "else-rule", + "source": <1:1-1:27 in 0>, + "type": "atrule", +} +`; + +exports[`an @else rule toJSON with no expression 1`] = ` +{ + "inputs": [ + { + "css": "@if foo {} @else {}", + "hasBOM": false, + "id": "", + }, + ], + "name": "else", + "nodes": [], + "params": "", + "raws": {}, + "sassType": "else-rule", + "source": <1:1-1:20 in 0>, + "type": "atrule", +} +`; diff --git a/pkg/sass-parser/lib/src/statement/__snapshots__/function-rule.test.ts.snap b/pkg/sass-parser/lib/src/statement/__snapshots__/function-rule.test.ts.snap index c482b974f..fa1ae47d1 100644 --- a/pkg/sass-parser/lib/src/statement/__snapshots__/function-rule.test.ts.snap +++ b/pkg/sass-parser/lib/src/statement/__snapshots__/function-rule.test.ts.snap @@ -13,6 +13,7 @@ exports[`a @function rule toJSON 1`] = ` "name": "function", "nodes": [], "parameters": <($bar)>, + "params": "foo($bar)", "raws": {}, "sassType": "function-rule", "source": <1:1-1:23 in 0>, diff --git a/pkg/sass-parser/lib/src/statement/__snapshots__/if-rule.test.ts.snap b/pkg/sass-parser/lib/src/statement/__snapshots__/if-rule.test.ts.snap new file mode 100644 index 000000000..cbb1fc044 --- /dev/null +++ b/pkg/sass-parser/lib/src/statement/__snapshots__/if-rule.test.ts.snap @@ -0,0 +1,21 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`an @if rule toJSON 1`] = ` +{ + "ifCondition": , + "inputs": [ + { + "css": "@if foo {}", + "hasBOM": false, + "id": "", + }, + ], + "name": "if", + "nodes": [], + "params": "foo", + "raws": {}, + "sassType": "if-rule", + "source": <1:1-1:11 in 0>, + "type": "atrule", +} +`; diff --git a/pkg/sass-parser/lib/src/statement/__snapshots__/mixin-rule.test.ts.snap b/pkg/sass-parser/lib/src/statement/__snapshots__/mixin-rule.test.ts.snap index 553da4b9e..916294fda 100644 --- a/pkg/sass-parser/lib/src/statement/__snapshots__/mixin-rule.test.ts.snap +++ b/pkg/sass-parser/lib/src/statement/__snapshots__/mixin-rule.test.ts.snap @@ -13,6 +13,7 @@ exports[`a @mixin rule toJSON 1`] = ` "name": "mixin", "nodes": [], "parameters": <($bar)>, + "params": "foo($bar)", "raws": {}, "sassType": "mixin-rule", "source": <1:1-1:20 in 0>, diff --git a/pkg/sass-parser/lib/src/statement/__snapshots__/variable-declaration.test.ts.snap b/pkg/sass-parser/lib/src/statement/__snapshots__/variable-declaration.test.ts.snap index b2b5e0501..3c700a383 100644 --- a/pkg/sass-parser/lib/src/statement/__snapshots__/variable-declaration.test.ts.snap +++ b/pkg/sass-parser/lib/src/statement/__snapshots__/variable-declaration.test.ts.snap @@ -13,10 +13,12 @@ exports[`a variable declaration toJSON 1`] = ` }, ], "namespace": "baz", + "prop": "baz.$foo", "raws": {}, "sassType": "variable-declaration", "source": <1:1-1:16 in 0>, "type": "decl", + "value": ""bar"", "variableName": "foo", } `; diff --git a/pkg/sass-parser/lib/src/statement/debug-rule.ts b/pkg/sass-parser/lib/src/statement/debug-rule.ts index e76f74488..124af81d1 100644 --- a/pkg/sass-parser/lib/src/statement/debug-rule.ts +++ b/pkg/sass-parser/lib/src/statement/debug-rule.ts @@ -66,7 +66,7 @@ export class DebugRule this.debugExpression = {text: value?.toString() ?? ''}; } - /** The expresison whose value is emitted when the debug rule is executed. */ + /** The expression whose value is emitted when the debug rule is executed. */ get debugExpression(): Expression { return this._debugExpression!; } diff --git a/pkg/sass-parser/lib/src/statement/declaration.ts b/pkg/sass-parser/lib/src/statement/declaration.ts index dbf021aa7..35b1f8814 100644 --- a/pkg/sass-parser/lib/src/statement/declaration.ts +++ b/pkg/sass-parser/lib/src/statement/declaration.ts @@ -222,7 +222,7 @@ export class Declaration toJSON(_?: string, inputs?: Map): object { return utils.toJSON( this, - ['propInterpolation', 'expression', 'nodes'], + ['prop', 'value', 'propInterpolation', 'expression', 'nodes'], inputs, ); } diff --git a/pkg/sass-parser/lib/src/statement/each-rule.ts b/pkg/sass-parser/lib/src/statement/each-rule.ts index 486eb56d1..53c8e25e8 100644 --- a/pkg/sass-parser/lib/src/statement/each-rule.ts +++ b/pkg/sass-parser/lib/src/statement/each-rule.ts @@ -95,7 +95,7 @@ export class EachRule throw new Error("EachRule.params can't be overwritten."); } - /** The expresison whose value is iterated over. */ + /** The expression whose value is iterated over. */ get eachExpression(): Expression { return this._eachExpression!; } diff --git a/pkg/sass-parser/lib/src/statement/else-rule.test.ts b/pkg/sass-parser/lib/src/statement/else-rule.test.ts new file mode 100644 index 000000000..49eb6ad6b --- /dev/null +++ b/pkg/sass-parser/lib/src/statement/else-rule.test.ts @@ -0,0 +1,300 @@ +// 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 {ElseRule, GenericAtRule, StringExpression, sass, scss} from '../..'; +import * as utils from '../../../test/utils'; + +describe('an @else rule', () => { + let node: ElseRule; + describe('with no expression and empty children', () => { + function describeNode(description: string, create: () => ElseRule): void { + describe(description, () => { + beforeEach(() => void (node = create())); + + it('has a name', () => expect(node.name.toString()).toBe('else')); + + it('has no expression', () => + expect(node.elseCondition).toBeUndefined()); + + it('has empty params', () => expect(node.params).toBe('')); + + it('has empty nodes', () => expect(node.nodes).toEqual([])); + }); + } + + describeNode( + 'parsed as SCSS', + () => scss.parse('@if foo {} @else {}').nodes[1] as ElseRule, + ); + + describeNode( + 'parsed as Sass', + () => sass.parse('@if foo\n@else').nodes[1] as ElseRule, + ); + + describeNode('constructed manually', () => new ElseRule()); + + describeNode('constructed from ChildProps', () => + utils.fromChildProps({elseCondition: undefined}), + ); + }); + + describe('with an expression and empty children', () => { + function describeNode(description: string, create: () => ElseRule): void { + describe(description, () => { + beforeEach(() => void (node = create())); + + it('has a name', () => expect(node.name.toString()).toBe('else')); + + it('has an expression', () => + expect(node).toHaveStringExpression('elseCondition', 'foo')); + + it('has matching params', () => expect(node.params).toBe('if foo')); + + it('has empty nodes', () => expect(node.nodes).toEqual([])); + }); + } + + describeNode( + 'parsed as SCSS', + () => scss.parse('@if bar {} @else if foo {}').nodes[1] as ElseRule, + ); + + describeNode( + 'parsed as Sass', + () => sass.parse('@if bar\n@else if foo').nodes[1] as ElseRule, + ); + + describeNode( + 'constructed manually', + () => new ElseRule({elseCondition: {text: 'foo'}}), + ); + + describeNode('constructed from ChildProps', () => + utils.fromChildProps({elseCondition: {text: 'foo'}}), + ); + }); + + describe('with no expression and a child', () => { + function describeNode(description: string, create: () => ElseRule): void { + describe(description, () => { + beforeEach(() => void (node = create())); + + it('has a name', () => expect(node.name.toString()).toBe('else')); + + it('has an expression', () => + expect(node.elseCondition).toBeUndefined()); + + it('has empty params', () => expect(node.params).toBe('')); + + 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('@if foo {} @else {@child}').nodes[1] as ElseRule, + ); + + describeNode( + 'parsed as Sass', + () => sass.parse('@if foo\n@else\n @child').nodes[1] as ElseRule, + ); + + describeNode( + 'constructed manually', + () => new ElseRule({nodes: [{name: 'child'}]}), + ); + + describeNode('constructed from ChildProps', () => + utils.fromChildProps({ + elseCondition: undefined, + nodes: [{name: 'child'}], + }), + ); + }); + + describe('throws an error when assigned a new', () => { + beforeEach(() => void (node = new ElseRule())); + + it('name', () => expect(() => (node.name = 'bar')).toThrow()); + + it('params', () => expect(() => (node.params = 'true')).toThrow()); + }); + + describe('assigned a new expression', () => { + beforeEach(() => { + node = scss.parse('@if foo {} @else if bar {}').nodes[1] as ElseRule; + }); + + it("removes the old expression's parent", () => { + const oldExpression = node.elseCondition!; + node.elseCondition = {text: 'bar'}; + expect(oldExpression.parent).toBeUndefined(); + }); + + it("assigns the new expression's parent", () => { + const expression = new StringExpression({text: 'bar'}); + node.elseCondition = expression; + expect(expression.parent).toBe(node); + }); + + it('assigns the expression explicitly', () => { + const expression = new StringExpression({text: 'bar'}); + node.elseCondition = expression; + expect(node.elseCondition).toBe(expression); + }); + + it('assigns the expression as ExpressionProps', () => { + node.elseCondition = {text: 'bar'}; + expect(node).toHaveStringExpression('elseCondition', 'bar'); + }); + }); + + describe('stringifies', () => { + describe('to SCSS', () => { + describe('with no expression', () => { + it('with default raws', () => + expect(new ElseRule().toString()).toBe('@else {}')); + + it('with afterName', () => + expect(new ElseRule({raws: {afterName: '/**/'}}).toString()).toBe( + '@else/**/ {}', + )); + + it('with afterIf', () => + expect(new ElseRule({raws: {afterIf: '/**/'}}).toString()).toBe( + '@else {}', + )); + + it('with between', () => + expect( + new ElseRule({ + raws: {between: '/**/'}, + }).toString(), + ).toBe('@else/**/{}')); + }); + + describe('with an expression', () => { + it('with default raws', () => + expect( + new ElseRule({ + elseCondition: {text: 'foo'}, + }).toString(), + ).toBe('@else if foo {}')); + + it('with afterName', () => + expect( + new ElseRule({ + elseCondition: {text: 'foo'}, + raws: {afterName: '/**/'}, + }).toString(), + ).toBe('@else/**/if foo {}')); + + it('with afterIf', () => + expect( + new ElseRule({ + elseCondition: {text: 'foo'}, + raws: {afterIf: '/**/'}, + }).toString(), + ).toBe('@else if/**/foo {}')); + + it('with between', () => + expect( + new ElseRule({ + elseCondition: {text: 'foo'}, + raws: {between: '/**/'}, + }).toString(), + ).toBe('@else if foo/**/{}')); + }); + }); + }); + + describe('clone', () => { + let original: ElseRule; + beforeEach(() => { + original = scss.parse('@if bar {} @else if foo {}').nodes[1] as ElseRule; + // TODO: remove this once raws are properly parsed + original.raws.between = ' '; + }); + + describe('with no overrides', () => { + let clone: ElseRule; + beforeEach(() => void (clone = original.clone())); + + describe('has the same properties:', () => { + it('params', () => expect(clone.params).toBe('if foo')); + + it('elseCondition', () => + expect(clone).toHaveStringExpression('elseCondition', '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 ['elseCondition', '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('elseCondition', () => { + describe('defined', () => { + let clone: ElseRule; + beforeEach(() => { + clone = original.clone({elseCondition: {text: 'bar'}}); + }); + + it('changes params', () => expect(clone.params).toBe('if bar')); + + it('changes elseCondition', () => + expect(clone).toHaveStringExpression('elseCondition', 'bar')); + }); + + describe('undefined', () => { + let clone: ElseRule; + beforeEach(() => { + clone = original.clone({elseCondition: undefined}); + }); + + it('changes params', () => expect(clone.params).toBe('')); + + it('changes elseCondition', () => + expect(clone.elseCondition).toBeUndefined()); + }); + }); + }); + }); + + describe('toJSON', () => { + it('with no expression', () => + expect(scss.parse('@if foo {} @else {}').nodes[1]).toMatchSnapshot()); + + it('with an expression', () => + expect( + scss.parse('@if foo {} @else if bar {}').nodes[1], + ).toMatchSnapshot()); + }); +}); diff --git a/pkg/sass-parser/lib/src/statement/else-rule.ts b/pkg/sass-parser/lib/src/statement/else-rule.ts new file mode 100644 index 000000000..29873fb0a --- /dev/null +++ b/pkg/sass-parser/lib/src/statement/else-rule.ts @@ -0,0 +1,162 @@ +// 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 ElseRule}. + * + * @category Statement + */ +export interface ElseRuleRaws extends Omit { + /** + * The whitespace between `if` and {@link ElseRule.elseExpression}. Ignored if + * `elseExpression` is undefined. + */ + afterIf?: string; +} + +/** + * The initializer properties for {@link ElseRule}. + * + * @category Statement + */ +export type ElseRuleProps = ContainerProps & { + raws?: ElseRuleRaws; + elseCondition?: Expression | ExpressionProps; +}; + +/** + * A `@else` or `@else if` rule. Extends [`postcss.AtRule`]. + * + * [`postcss.AtRule`]: https://postcss.org/api/#atrule + * + * @category Statement + */ +export class ElseRule + extends _AtRule> + implements Statement +{ + readonly sassType = 'else-rule' as const; + declare parent: StatementWithChildren | undefined; + declare raws: ElseRuleRaws; + declare nodes: ChildNode[]; + + get name(): string { + return 'else'; + } + set name(value: string) { + throw new Error("ElseRule.name can't be overwritten."); + } + + get params(): string { + return this.elseCondition + ? 'if' + (this.raws.afterIf ?? ' ') + this.elseCondition.toString() + : ''; + } + set params(value: string | number | undefined) { + throw new Error("ElseRule.params can't be overwritten."); + } + + /** + * The expression whose value determines whether to evaluate the block. If + * this isn't set, the block is evaluated unconditionally. + */ + get elseCondition(): Expression | undefined { + return this._elseCondition!; + } + set elseCondition(elseCondition: Expression | ExpressionProps | undefined) { + if (this._elseCondition) this._elseCondition.parent = undefined; + if (elseCondition) { + if (!('sassType' in elseCondition)) { + elseCondition = fromProps(elseCondition); + } + elseCondition.parent = this; + } + this._elseCondition = elseCondition; + } + private declare _elseCondition?: Expression; + + constructor(defaults?: ElseRuleProps); + /** @hidden */ + constructor( + _: undefined, + inner: sassInternal.IfRule, + clause: sassInternal.IfClause | sassInternal.ElseClause | null, + ); + constructor( + defaults?: ElseRuleProps, + inner?: sassInternal.IfRule, + clause?: sassInternal.IfClause | sassInternal.ElseClause | null, + ) { + super(defaults as unknown as postcss.AtRuleProps); + this.nodes ??= []; + + if (inner) { + this.source = new LazySource(inner); + if ('expression' in clause!) { + this.elseCondition = convertExpression(clause.expression); + } + appendInternalChildren(this, clause!.children); + } + } + + clone(overrides?: Partial): this { + return utils.cloneNode(this, overrides, [ + 'raws', + {name: 'elseCondition', explicitUndefined: true}, + ]); + } + + toJSON(): object; + /** @hidden */ + toJSON(_: string, inputs: Map): object; + toJSON(_?: string, inputs?: Map): object { + return utils.toJSON( + this, + ['name', 'elseCondition', 'params', 'nodes'], + inputs, + ); + } + + /** @hidden */ + toString( + stringifier: postcss.Stringifier | postcss.Syntax = sassParser.scss + .stringify, + ): string { + return super.toString(stringifier); + } + + /** @hidden */ + get nonStatementChildren(): ReadonlyArray { + return this.elseCondition ? [this.elseCondition] : []; + } + + /** @hidden */ + normalize(node: NewNode, sample?: postcss.Node): ChildNode[] { + return normalize(this, node, sample); + } +} + +interceptIsClean(ElseRule); diff --git a/pkg/sass-parser/lib/src/statement/error-rule.ts b/pkg/sass-parser/lib/src/statement/error-rule.ts index 2883d2bec..82f67b509 100644 --- a/pkg/sass-parser/lib/src/statement/error-rule.ts +++ b/pkg/sass-parser/lib/src/statement/error-rule.ts @@ -66,7 +66,7 @@ export class ErrorRule this.errorExpression = {text: value?.toString() ?? ''}; } - /** The expresison whose value is thrown when the error rule is executed. */ + /** The expression whose value is thrown when the error rule is executed. */ get errorExpression(): Expression { return this._errorExpression!; } diff --git a/pkg/sass-parser/lib/src/statement/for-rule.ts b/pkg/sass-parser/lib/src/statement/for-rule.ts index 186116f26..9ff3edb97 100644 --- a/pkg/sass-parser/lib/src/statement/for-rule.ts +++ b/pkg/sass-parser/lib/src/statement/for-rule.ts @@ -102,7 +102,7 @@ export class ForRule throw new Error("ForRule.params can't be overwritten."); } - /** The expresison whose value is the starting point of the iteration. */ + /** The expression whose value is the starting point of the iteration. */ get fromExpression(): Expression { return this._fromExpression!; } @@ -116,7 +116,7 @@ export class ForRule } private declare _fromExpression?: Expression; - /** The expresison whose value is the ending point of the iteration. */ + /** The expression whose value is the ending point of the iteration. */ get toExpression(): Expression { return this._toExpression!; } diff --git a/pkg/sass-parser/lib/src/statement/function-rule.ts b/pkg/sass-parser/lib/src/statement/function-rule.ts index 30ef902f7..5a7be868c 100644 --- a/pkg/sass-parser/lib/src/statement/function-rule.ts +++ b/pkg/sass-parser/lib/src/statement/function-rule.ts @@ -134,7 +134,7 @@ export class FunctionRule toJSON(_?: string, inputs?: Map): object { return utils.toJSON( this, - ['name', 'functionName', 'parameters', 'nodes'], + ['name', 'params', 'functionName', 'parameters', 'nodes'], inputs, ); } diff --git a/pkg/sass-parser/lib/src/statement/if-rule.test.ts b/pkg/sass-parser/lib/src/statement/if-rule.test.ts new file mode 100644 index 000000000..a996b4a21 --- /dev/null +++ b/pkg/sass-parser/lib/src/statement/if-rule.test.ts @@ -0,0 +1,232 @@ +// 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, IfRule, StringExpression, sass, scss} from '../..'; +import * as utils from '../../../test/utils'; + +describe('an @if rule', () => { + let node: IfRule; + describe('with empty children', () => { + function describeNode(description: string, create: () => IfRule): void { + describe(description, () => { + beforeEach(() => void (node = create())); + + it('has a name', () => expect(node.name.toString()).toBe('if')); + + it('has an expression', () => + expect(node).toHaveStringExpression('ifCondition', 'foo')); + + it('has matching params', () => expect(node.params).toBe('foo')); + + it('has empty nodes', () => expect(node.nodes).toEqual([])); + }); + } + + describeNode( + 'parsed as SCSS', + () => scss.parse('@if foo {}').nodes[0] as IfRule, + ); + + describeNode( + 'parsed as Sass', + () => sass.parse('@if foo').nodes[0] as IfRule, + ); + + describeNode( + 'constructed manually', + () => new IfRule({ifCondition: {text: 'foo'}}), + ); + + describeNode('constructed from ChildProps', () => + utils.fromChildProps({ifCondition: {text: 'foo'}}), + ); + }); + + describe('with a child', () => { + function describeNode(description: string, create: () => IfRule): void { + describe(description, () => { + beforeEach(() => void (node = create())); + + it('has a name', () => expect(node.name.toString()).toBe('if')); + + it('has an expression', () => + expect(node).toHaveStringExpression('ifCondition', '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('@if foo {@child}').nodes[0] as IfRule, + ); + + describeNode( + 'parsed as Sass', + () => sass.parse('@if foo\n @child').nodes[0] as IfRule, + ); + + describeNode( + 'constructed manually', + () => + new IfRule({ + ifCondition: {text: 'foo'}, + nodes: [{name: 'child'}], + }), + ); + + describeNode('constructed from ChildProps', () => + utils.fromChildProps({ + ifCondition: {text: 'foo'}, + nodes: [{name: 'child'}], + }), + ); + }); + + describe('throws an error when assigned a new', () => { + beforeEach(() => void (node = new IfRule({ifCondition: {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('@if foo {}').nodes[0] as IfRule; + }); + + it("removes the old expression's parent", () => { + const oldExpression = node.ifCondition; + node.ifCondition = {text: 'bar'}; + expect(oldExpression.parent).toBeUndefined(); + }); + + it("assigns the new expression's parent", () => { + const expression = new StringExpression({text: 'bar'}); + node.ifCondition = expression; + expect(expression.parent).toBe(node); + }); + + it('assigns the expression explicitly', () => { + const expression = new StringExpression({text: 'bar'}); + node.ifCondition = expression; + expect(node.ifCondition).toBe(expression); + }); + + it('assigns the expression as ExpressionProps', () => { + node.ifCondition = {text: 'bar'}; + expect(node).toHaveStringExpression('ifCondition', 'bar'); + }); + }); + + describe('stringifies', () => { + describe('to SCSS', () => { + it('with default raws', () => + expect( + new IfRule({ + ifCondition: {text: 'foo'}, + }).toString(), + ).toBe('@if foo {}')); + + it('with afterName', () => + expect( + new IfRule({ + ifCondition: {text: 'foo'}, + raws: {afterName: '/**/'}, + }).toString(), + ).toBe('@if/**/foo {}')); + + it('with between', () => + expect( + new IfRule({ + ifCondition: {text: 'foo'}, + raws: {between: '/**/'}, + }).toString(), + ).toBe('@if foo/**/{}')); + }); + }); + + describe('clone', () => { + let original: IfRule; + beforeEach(() => { + original = scss.parse('@if foo {}').nodes[0] as IfRule; + // TODO: remove this once raws are properly parsed + original.raws.between = ' '; + }); + + describe('with no overrides', () => { + let clone: IfRule; + beforeEach(() => void (clone = original.clone())); + + describe('has the same properties:', () => { + it('params', () => expect(clone.params).toBe('foo')); + + it('ifCondition', () => + expect(clone).toHaveStringExpression('ifCondition', '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 ['ifCondition', '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('ifCondition', () => { + describe('defined', () => { + let clone: IfRule; + beforeEach(() => { + clone = original.clone({ifCondition: {text: 'bar'}}); + }); + + it('changes params', () => expect(clone.params).toBe('bar')); + + it('changes ifCondition', () => + expect(clone).toHaveStringExpression('ifCondition', 'bar')); + }); + + describe('undefined', () => { + let clone: IfRule; + beforeEach(() => { + clone = original.clone({ifCondition: undefined}); + }); + + it('preserves params', () => expect(clone.params).toBe('foo')); + + it('preserves ifCondition', () => + expect(clone).toHaveStringExpression('ifCondition', 'foo')); + }); + }); + }); + }); + + it('toJSON', () => + expect(scss.parse('@if foo {}').nodes[0]).toMatchSnapshot()); +}); diff --git a/pkg/sass-parser/lib/src/statement/if-rule.ts b/pkg/sass-parser/lib/src/statement/if-rule.ts new file mode 100644 index 000000000..4d1f51726 --- /dev/null +++ b/pkg/sass-parser/lib/src/statement/if-rule.ts @@ -0,0 +1,133 @@ +// 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 IfRule}. + * + * @category Statement + */ +export type IfRuleRaws = Omit; + +/** + * The initializer properties for {@link IfRule}. + * + * @category Statement + */ +export type IfRuleProps = ContainerProps & { + raws?: IfRuleRaws; + ifCondition: Expression | ExpressionProps; +}; + +/** + * A `@if` rule. Extends [`postcss.AtRule`]. + * + * [`postcss.AtRule`]: https://postcss.org/api/#atrule + * + * @category Statement + */ +export class IfRule extends _AtRule> implements Statement { + readonly sassType = 'if-rule' as const; + declare parent: StatementWithChildren | undefined; + declare raws: IfRuleRaws; + declare nodes: ChildNode[]; + + get name(): string { + return 'if'; + } + set name(value: string) { + throw new Error("IfRule.name can't be overwritten."); + } + + get params(): string { + return this.ifCondition.toString(); + } + set params(value: string | number | undefined) { + throw new Error("IfRule.params can't be overwritten."); + } + + /** The expression whose value determines whether to execute this block. */ + get ifCondition(): Expression { + return this._ifCondition!; + } + set ifCondition(ifCondition: Expression | ExpressionProps) { + if (this._ifCondition) this._ifCondition.parent = undefined; + if (!('sassType' in ifCondition)) { + ifCondition = fromProps(ifCondition); + } + ifCondition.parent = this; + this._ifCondition = ifCondition; + } + private declare _ifCondition?: Expression; + + constructor(defaults: IfRuleProps); + /** @hidden */ + constructor(_: undefined, inner: sassInternal.IfRule); + constructor(defaults?: IfRuleProps, inner?: sassInternal.IfRule) { + super(defaults as unknown as postcss.AtRuleProps); + this.nodes ??= []; + + if (inner) { + this.source = new LazySource(inner); + this.ifCondition = convertExpression(inner.clauses[0].expression); + appendInternalChildren(this, inner.clauses[0].children); + } + } + + clone(overrides?: Partial): this { + return utils.cloneNode(this, overrides, ['raws', 'ifCondition']); + } + + toJSON(): object; + /** @hidden */ + toJSON(_: string, inputs: Map): object; + toJSON(_?: string, inputs?: Map): object { + return utils.toJSON( + this, + ['name', 'ifCondition', 'params', 'nodes'], + inputs, + ); + } + + /** @hidden */ + toString( + stringifier: postcss.Stringifier | postcss.Syntax = sassParser.scss + .stringify, + ): string { + return super.toString(stringifier); + } + + /** @hidden */ + get nonStatementChildren(): ReadonlyArray { + return [this.ifCondition]; + } + + /** @hidden */ + normalize(node: NewNode, sample?: postcss.Node): ChildNode[] { + return normalize(this, node, sample); + } +} + +interceptIsClean(IfRule); diff --git a/pkg/sass-parser/lib/src/statement/index.ts b/pkg/sass-parser/lib/src/statement/index.ts index efaaa0ef0..3a42244d9 100644 --- a/pkg/sass-parser/lib/src/statement/index.ts +++ b/pkg/sass-parser/lib/src/statement/index.ts @@ -15,10 +15,12 @@ import {GenericAtRule, GenericAtRuleProps} from './generic-at-rule'; import {DebugRule, DebugRuleProps} from './debug-rule'; import {Declaration, DeclarationProps} from './declaration'; import {EachRule, EachRuleProps} from './each-rule'; +import {ElseRule, ElseRuleProps} from './else-rule'; import {ErrorRule, ErrorRuleProps} from './error-rule'; import {ForRule, ForRuleProps} from './for-rule'; import {ForwardRule, ForwardRuleProps} from './forward-rule'; import {FunctionRule, FunctionRuleProps} from './function-rule'; +import {IfRule, IfRuleProps} from './if-rule'; import {IncludeRule, IncludeRuleProps} from './include-rule'; import {MixinRule, MixinRuleProps} from './mixin-rule'; import {ReturnRule, ReturnRuleProps} from './return-rule'; @@ -56,10 +58,12 @@ export type StatementType = | 'decl' | 'debug-rule' | 'each-rule' + | 'else-rule' | 'error-rule' | 'for-rule' | 'forward-rule' | 'function-rule' + | 'if-rule' | 'include-rule' | 'mixin-rule' | 'return-rule' @@ -77,11 +81,13 @@ export type StatementType = export type AtRule = | DebugRule | EachRule + | ElseRule | ErrorRule | ForRule | ForwardRule | FunctionRule | GenericAtRule + | IfRule | IncludeRule | MixinRule | ReturnRule @@ -125,11 +131,16 @@ export type ChildProps = | DebugRuleProps | DeclarationProps | EachRuleProps + // In a ChildProps context, `ElseRuleProps` requires an explicit + // `elseCondition: undefined` so that an empty object isn't a valid + // `ChildProps`. + | (ElseRuleProps & {elseCondition: ElseRuleProps['elseCondition']}) | ErrorRuleProps | ForRuleProps | ForwardRuleProps | FunctionRuleProps | GenericAtRuleProps + | IfRuleProps | IncludeRuleProps | MixinRuleProps | ReturnRuleProps @@ -175,7 +186,7 @@ export interface Statement extends postcss.Node, Node { } /** The visitor to use to convert internal Sass nodes to JS. */ -const visitor = sassInternal.createStatementVisitor({ +const visitor = sassInternal.createStatementVisitor({ visitAtRootRule: inner => { const rule = new GenericAtRule({ name: 'at-root', @@ -195,6 +206,18 @@ const visitor = sassInternal.createStatementVisitor({ visitForRule: inner => new ForRule(undefined, inner), visitForwardRule: inner => new ForwardRule(undefined, inner), visitFunctionRule: inner => new FunctionRule(undefined, inner), + visitIfRule: inner => { + const rules: Statement[] = [new IfRule(undefined, inner)]; + + // Skip `inner.clauses[0]` because it's already used by `new IfRule()`. + for (let i = 1; i < inner.clauses.length; i++) { + rules.push(new ElseRule(undefined, inner, inner.clauses[i])); + } + if (inner.lastClause) { + rules.push(new ElseRule(undefined, inner, inner.lastClause)); + } + return rules; + }, visitIncludeRule: inner => new IncludeRule(undefined, inner), visitExtendRule: inner => { const paramsInterpolation = new Interpolation(undefined, inner.selector); @@ -343,8 +366,12 @@ export function normalize( result.push(new DebugRule(node)); } else if ('eachExpression' in node) { result.push(new EachRule(node)); + } else if ('elseCondition' in node) { + result.push(new ElseRule(node)); } else if ('errorExpression' in node) { result.push(new ErrorRule(node)); + } else if ('ifCondition' in node) { + result.push(new IfRule(node)); } else if ('includeName' in node) { result.push(new IncludeRule(node)); } else if ('fromExpression' in node) { diff --git a/pkg/sass-parser/lib/src/statement/mixin-rule.ts b/pkg/sass-parser/lib/src/statement/mixin-rule.ts index 90712d090..4e6292737 100644 --- a/pkg/sass-parser/lib/src/statement/mixin-rule.ts +++ b/pkg/sass-parser/lib/src/statement/mixin-rule.ts @@ -134,7 +134,7 @@ export class MixinRule toJSON(_?: string, inputs?: Map): object { return utils.toJSON( this, - ['name', 'mixinName', 'parameters', 'nodes'], + ['name', 'params', 'mixinName', 'parameters', 'nodes'], inputs, ); } diff --git a/pkg/sass-parser/lib/src/statement/return-rule.ts b/pkg/sass-parser/lib/src/statement/return-rule.ts index cc5c5aaec..2f3807759 100644 --- a/pkg/sass-parser/lib/src/statement/return-rule.ts +++ b/pkg/sass-parser/lib/src/statement/return-rule.ts @@ -66,7 +66,7 @@ export class ReturnRule this.returnExpression = {text: value?.toString() ?? ''}; } - /** The expresison whose value is emitted when the return rule is executed. */ + /** The expression whose value is emitted when the return rule is executed. */ get returnExpression(): Expression { return this._returnExpression!; } diff --git a/pkg/sass-parser/lib/src/statement/sass-comment.ts b/pkg/sass-parser/lib/src/statement/sass-comment.ts index 6aa9e4aa6..20713d7a1 100644 --- a/pkg/sass-parser/lib/src/statement/sass-comment.ts +++ b/pkg/sass-parser/lib/src/statement/sass-comment.ts @@ -162,7 +162,7 @@ export class SassComment /** @hidden */ toJSON(_: string, inputs: Map): object; toJSON(_?: string, inputs?: Map): object { - return utils.toJSON(this, ['text', 'text'], inputs); + return utils.toJSON(this, ['text'], inputs); } /** @hidden */ diff --git a/pkg/sass-parser/lib/src/statement/variable-declaration.ts b/pkg/sass-parser/lib/src/statement/variable-declaration.ts index c1f3d1885..5e7b39e33 100644 --- a/pkg/sass-parser/lib/src/statement/variable-declaration.ts +++ b/pkg/sass-parser/lib/src/statement/variable-declaration.ts @@ -207,7 +207,15 @@ export class VariableDeclaration toJSON(_?: string, inputs?: Map): object { return utils.toJSON( this, - ['namespace', 'variableName', 'expression', 'guarded', 'global'], + [ + 'prop', + 'value', + 'namespace', + 'variableName', + 'expression', + 'guarded', + 'global', + ], inputs, ); } diff --git a/pkg/sass-parser/lib/src/statement/warn-rule.ts b/pkg/sass-parser/lib/src/statement/warn-rule.ts index ff6e93f82..04f71b762 100644 --- a/pkg/sass-parser/lib/src/statement/warn-rule.ts +++ b/pkg/sass-parser/lib/src/statement/warn-rule.ts @@ -66,7 +66,7 @@ export class WarnRule this.warnExpression = {text: value?.toString() ?? ''}; } - /** The expresison whose value is emitted when the warn rule is executed. */ + /** The expression whose value is emitted when the warn rule is executed. */ get warnExpression(): Expression { return this._warnExpression!; } diff --git a/pkg/sass-parser/lib/src/statement/while-rule.ts b/pkg/sass-parser/lib/src/statement/while-rule.ts index e652d7c80..f898d697f 100644 --- a/pkg/sass-parser/lib/src/statement/while-rule.ts +++ b/pkg/sass-parser/lib/src/statement/while-rule.ts @@ -71,7 +71,7 @@ export class WhileRule throw new Error("WhileRule.params can't be overwritten."); } - /** The expresison whose value is emitted when the while rule is executed. */ + /** The expression whose value determines whether to continue looping. */ get whileCondition(): Expression { return this._whileCondition!; } @@ -80,7 +80,7 @@ export class WhileRule if (!('sassType' in whileCondition)) { whileCondition = fromProps(whileCondition); } - if (whileCondition) whileCondition.parent = this; + whileCondition.parent = this; this._whileCondition = whileCondition; } private declare _whileCondition?: Expression; diff --git a/pkg/sass-parser/lib/src/stringifier.ts b/pkg/sass-parser/lib/src/stringifier.ts index 08cfb68aa..c0a2a3e3e 100644 --- a/pkg/sass-parser/lib/src/stringifier.ts +++ b/pkg/sass-parser/lib/src/stringifier.ts @@ -32,11 +32,14 @@ import {AnyStatement} from './statement'; import {DebugRule} from './statement/debug-rule'; import {Declaration} from './statement/declaration'; import {EachRule} from './statement/each-rule'; +import {ElseRule} from './statement/else-rule'; import {ErrorRule} from './statement/error-rule'; import {ForRule} from './statement/for-rule'; import {ForwardRule} from './statement/forward-rule'; import {FunctionRule} from './statement/function-rule'; import {GenericAtRule} from './statement/generic-at-rule'; +import {IfRule} from './statement/if-rule'; +import {IncludeRule} from './statement/include-rule'; import {MixinRule} from './statement/mixin-rule'; import {ReturnRule} from './statement/return-rule'; import {Rule} from './statement/rule'; @@ -121,6 +124,10 @@ export class Stringifier extends PostCssStringifier { this.sassAtRule(node); } + private ['else-rule'](node: ElseRule): void { + this.sassAtRule(node); + } + private ['error-rule'](node: ErrorRule, semicolon: boolean): void { this.sassAtRule(node, semicolon); } @@ -137,7 +144,11 @@ export class Stringifier extends PostCssStringifier { this.sassAtRule(node, semicolon); } - private ['include-rule'](node: FunctionRule, semicolon: boolean): void { + private ['if-rule'](node: IfRule): void { + this.sassAtRule(node); + } + + private ['include-rule'](node: IncludeRule, semicolon: boolean): void { this.sassAtRule(node, semicolon); } @@ -251,7 +262,11 @@ export class Stringifier extends PostCssStringifier { /** 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; + const start = + '@' + + node.name + + (node.raws.afterName ?? (node.params === '' ? '' : ' ')) + + node.params; if (node.nodes) { this.block(node, start); } else {