diff --git a/lib/src/parse/sass.dart b/lib/src/parse/sass.dart index 78dea3179..689131bf1 100644 --- a/lib/src/parse/sass.dart +++ b/lib/src/parse/sass.dart @@ -268,7 +268,6 @@ class SassParser extends StylesheetParser { _readIndentation(); } - if (!buffer.trailingString.trimRight().endsWith("*/")) buffer.write(" */"); return LoudComment(buffer.interpolation(scanner.spanFrom(start))); } diff --git a/lib/src/parse/stylesheet.dart b/lib/src/parse/stylesheet.dart index e6b6d197c..2ebdf412a 100644 --- a/lib/src/parse/stylesheet.dart +++ b/lib/src/parse/stylesheet.dart @@ -748,12 +748,12 @@ abstract class StylesheetParser extends Parser { buffer.writeCharCode($lparen); whitespace(); - buffer.add(_expression()); + _addOrInject(buffer, _expression()); if (scanner.scanChar($colon)) { whitespace(); buffer.writeCharCode($colon); buffer.writeCharCode($space); - buffer.add(_expression()); + _addOrInject(buffer, _expression()); } scanner.expectChar($rparen); @@ -3519,6 +3519,16 @@ abstract class StylesheetParser extends Parser { span()); } + /// Adds [expression] to [buffer], or if it's an unquoted string adds the + /// interpolation it contains instead. + void _addOrInject(InterpolationBuffer buffer, Expression expression) { + if (expression is StringExpression && !expression.hasQuotes) { + buffer.addInterpolation(expression.text); + } else { + buffer.add(expression); + } + } + // ## Abstract Methods /// Whether this is parsing the indented syntax. diff --git a/lib/src/visitor/async_evaluate.dart b/lib/src/visitor/async_evaluate.dart index e4a65d12e..1c838b8ac 100644 --- a/lib/src/visitor/async_evaluate.dart +++ b/lib/src/visitor/async_evaluate.dart @@ -1911,8 +1911,10 @@ final class _EvaluateVisitor _endOfImports++; } - _parent.addChild(ModifiableCssComment( - await _performInterpolation(node.text), node.span)); + var text = await _performInterpolation(node.text); + // Indented syntax doesn't require */ + if (!text.endsWith("*/")) text += " */"; + _parent.addChild(ModifiableCssComment(text, node.span)); return null; } diff --git a/lib/src/visitor/evaluate.dart b/lib/src/visitor/evaluate.dart index cc2458bab..e3552506c 100644 --- a/lib/src/visitor/evaluate.dart +++ b/lib/src/visitor/evaluate.dart @@ -5,7 +5,7 @@ // DO NOT EDIT. This file was generated from async_evaluate.dart. // See tool/grind/synchronize.dart for details. // -// Checksum: ebf292c26dcfdd7f61fd70ce3dc9e0be2b6708b3 +// Checksum: 2ab69d23a3b34cb54ddd74e2e854614dda582174 // // ignore_for_file: unused_import @@ -1903,8 +1903,10 @@ final class _EvaluateVisitor _endOfImports++; } - _parent.addChild( - ModifiableCssComment(_performInterpolation(node.text), node.span)); + var text = _performInterpolation(node.text); + // Indented syntax doesn't require */ + if (!text.endsWith("*/")) text += " */"; + _parent.addChild(ModifiableCssComment(text, node.span)); return null; } diff --git a/pkg/sass-parser/README.md b/pkg/sass-parser/README.md index db6e98f7e..89908a492 100644 --- a/pkg/sass-parser/README.md +++ b/pkg/sass-parser/README.md @@ -246,3 +246,14 @@ const sassParser = require('sass-parser'); const root = new sassParser.Root(); root.append('content: "hello, world!"'); ``` + +### Known Incompatibilities + +There are a few cases where an operation that's valid in PostCSS won't work with +`sass-parser`: + +* Trying to convert a Sass-specific at-rule like `@if` or `@mixin` into a + different at-rule by changing its name is not supported. + +* Trying to add child nodes to a Sass statement that doesn't support children + like `@use` or `@error` is not supported. diff --git a/pkg/sass-parser/jest.config.ts b/pkg/sass-parser/jest.config.ts index bdf7ad067..d7cc13f80 100644 --- a/pkg/sass-parser/jest.config.ts +++ b/pkg/sass-parser/jest.config.ts @@ -3,6 +3,7 @@ const config = { roots: ['lib'], testEnvironment: 'node', setupFilesAfterEnv: ['jest-extended/all', '/test/setup.ts'], + verbose: false, }; export default config; diff --git a/pkg/sass-parser/lib/index.ts b/pkg/sass-parser/lib/index.ts index 7201833d7..a0c3b6451 100644 --- a/pkg/sass-parser/lib/index.ts +++ b/pkg/sass-parser/lib/index.ts @@ -33,6 +33,23 @@ export { InterpolationRaws, NewNodeForInterpolation, } from './src/interpolation'; +export { + CssComment, + CssCommentProps, + CssCommentRaws, +} from './src/statement/css-comment'; +export { + DebugRule, + DebugRuleProps, + DebugRuleRaws, +} from './src/statement/debug-rule'; +export {EachRule, EachRuleProps, EachRuleRaws} from './src/statement/each-rule'; +export { + ErrorRule, + ErrorRuleProps, + ErrorRuleRaws, +} from './src/statement/error-rule'; +export {ForRule, ForRuleProps, ForRuleRaws} from './src/statement/for-rule'; export { GenericAtRule, GenericAtRuleProps, diff --git a/pkg/sass-parser/lib/src/interpolation.ts b/pkg/sass-parser/lib/src/interpolation.ts index 387127e85..8ffbb94a2 100644 --- a/pkg/sass-parser/lib/src/interpolation.ts +++ b/pkg/sass-parser/lib/src/interpolation.ts @@ -112,9 +112,8 @@ export class Interpolation extends Node { */ get asPlain(): string | null { if (this.nodes.length === 0) return ''; - if (this.nodes.length !== 1) return null; - if (typeof this.nodes[0] !== 'string') return null; - return this.nodes[0] as string; + if (this.nodes.some(node => typeof node !== 'string')) return null; + return this.nodes.join(''); } /** diff --git a/pkg/sass-parser/lib/src/sass-internal.ts b/pkg/sass-parser/lib/src/sass-internal.ts index eba4b41cc..ed9987141 100644 --- a/pkg/sass-parser/lib/src/sass-internal.ts +++ b/pkg/sass-parser/lib/src/sass-internal.ts @@ -66,11 +66,45 @@ declare namespace SassInternal { readonly children: T; } + class AtRootRule extends ParentStatement { + readonly name: Interpolation; + readonly query?: Interpolation; + } + class AtRule extends ParentStatement { readonly name: Interpolation; readonly value?: Interpolation; } + class DebugRule extends Statement { + readonly expression: Expression; + } + + class EachRule extends ParentStatement { + readonly variables: string[]; + readonly list: Expression; + } + + class ErrorRule extends Statement { + readonly expression: Expression; + } + + class ExtendRule extends Statement { + readonly selector: Interpolation; + readonly isOptional: boolean; + } + + class ForRule extends ParentStatement { + readonly variable: string; + readonly from: Expression; + readonly to: Expression; + readonly isExclusive: boolean; + } + + class LoudComment extends Statement { + readonly text: Interpolation; + } + class Stylesheet extends ParentStatement {} class StyleRule extends ParentStatement { @@ -106,7 +140,14 @@ export type SassNode = SassInternal.SassNode; export type Statement = SassInternal.Statement; export type ParentStatement = SassInternal.ParentStatement; +export type AtRootRule = SassInternal.AtRootRule; export type AtRule = SassInternal.AtRule; +export type DebugRule = SassInternal.DebugRule; +export type EachRule = SassInternal.EachRule; +export type ErrorRule = SassInternal.ErrorRule; +export type ExtendRule = SassInternal.ExtendRule; +export type ForRule = SassInternal.ForRule; +export type LoudComment = SassInternal.LoudComment; export type Stylesheet = SassInternal.Stylesheet; export type StyleRule = SassInternal.StyleRule; export type Interpolation = SassInternal.Interpolation; @@ -115,7 +156,14 @@ export type BinaryOperationExpression = SassInternal.BinaryOperationExpression; export type StringExpression = SassInternal.StringExpression; export interface StatementVisitorObject { + visitAtRootRule(node: AtRootRule): T; visitAtRule(node: AtRule): T; + visitDebugRule(node: DebugRule): T; + visitEachRule(node: EachRule): T; + visitErrorRule(node: ErrorRule): T; + visitExtendRule(node: ExtendRule): T; + visitForRule(node: ForRule): T; + visitLoudComment(node: LoudComment): T; visitStyleRule(node: StyleRule): T; } diff --git a/pkg/sass-parser/lib/src/statement/__snapshots__/css-comment.test.ts.snap b/pkg/sass-parser/lib/src/statement/__snapshots__/css-comment.test.ts.snap new file mode 100644 index 000000000..1e19f31d6 --- /dev/null +++ b/pkg/sass-parser/lib/src/statement/__snapshots__/css-comment.test.ts.snap @@ -0,0 +1,23 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`a CSS-style comment toJSON 1`] = ` +{ + "inputs": [ + { + "css": "/* foo */", + "hasBOM": false, + "id": "", + }, + ], + "raws": { + "closed": true, + "left": " ", + "right": " ", + }, + "sassType": "comment", + "source": <1:1-1:10 in 0>, + "text": "foo", + "textInterpolation": , + "type": "comment", +} +`; diff --git a/pkg/sass-parser/lib/src/statement/__snapshots__/debug-rule.test.ts.snap b/pkg/sass-parser/lib/src/statement/__snapshots__/debug-rule.test.ts.snap new file mode 100644 index 000000000..621628a23 --- /dev/null +++ b/pkg/sass-parser/lib/src/statement/__snapshots__/debug-rule.test.ts.snap @@ -0,0 +1,20 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`a @debug rule toJSON 1`] = ` +{ + "debugExpression": , + "inputs": [ + { + "css": "@debug foo", + "hasBOM": false, + "id": "", + }, + ], + "name": "debug", + "params": "foo", + "raws": {}, + "sassType": "debug-rule", + "source": <1:1-1:11 in 0>, + "type": "atrule", +} +`; diff --git a/pkg/sass-parser/lib/src/statement/__snapshots__/each-rule.test.ts.snap b/pkg/sass-parser/lib/src/statement/__snapshots__/each-rule.test.ts.snap new file mode 100644 index 000000000..75dd15404 --- /dev/null +++ b/pkg/sass-parser/lib/src/statement/__snapshots__/each-rule.test.ts.snap @@ -0,0 +1,25 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`an @each rule toJSON 1`] = ` +{ + "eachExpression": , + "inputs": [ + { + "css": "@each $foo, $bar in baz {}", + "hasBOM": false, + "id": "", + }, + ], + "name": "each", + "nodes": [], + "params": "$foo, $bar in baz", + "raws": {}, + "sassType": "each-rule", + "source": <1:1-1:27 in 0>, + "type": "atrule", + "variables": [ + "foo", + "bar", + ], +} +`; diff --git a/pkg/sass-parser/lib/src/statement/__snapshots__/error-rule.test.ts.snap b/pkg/sass-parser/lib/src/statement/__snapshots__/error-rule.test.ts.snap new file mode 100644 index 000000000..9ed3f5667 --- /dev/null +++ b/pkg/sass-parser/lib/src/statement/__snapshots__/error-rule.test.ts.snap @@ -0,0 +1,20 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`a @error rule toJSON 1`] = ` +{ + "errorExpression": , + "inputs": [ + { + "css": "@error foo", + "hasBOM": false, + "id": "", + }, + ], + "name": "error", + "params": "foo", + "raws": {}, + "sassType": "error-rule", + "source": <1:1-1:11 in 0>, + "type": "atrule", +} +`; diff --git a/pkg/sass-parser/lib/src/statement/__snapshots__/for-rule.test.ts.snap b/pkg/sass-parser/lib/src/statement/__snapshots__/for-rule.test.ts.snap new file mode 100644 index 000000000..f96b0007f --- /dev/null +++ b/pkg/sass-parser/lib/src/statement/__snapshots__/for-rule.test.ts.snap @@ -0,0 +1,24 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`an @for rule toJSON 1`] = ` +{ + "fromExpression": , + "inputs": [ + { + "css": "@for $foo from bar to baz {}", + "hasBOM": false, + "id": "", + }, + ], + "name": "for", + "nodes": [], + "params": "$foo from bar to baz", + "raws": {}, + "sassType": "for-rule", + "source": <1:1-1:29 in 0>, + "to": "to", + "toExpression": , + "type": "atrule", + "variable": "foo", +} +`; 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 new file mode 100644 index 000000000..b5c7a17f7 --- /dev/null +++ b/pkg/sass-parser/lib/src/statement/at-root-rule.test.ts @@ -0,0 +1,140 @@ +// 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, Rule, scss} from '../..'; + +describe('an @at-root rule', () => { + let node: GenericAtRule; + + describe('with no params', () => { + beforeEach( + () => void (node = scss.parse('@at-root {}').nodes[0] as GenericAtRule) + ); + + it('has a name', () => expect(node.name).toBe('at-root')); + + it('has no paramsInterpolation', () => + expect(node.paramsInterpolation).toBeUndefined()); + + it('has no params', () => expect(node.params).toBe('')); + }); + + describe('with no interpolation', () => { + beforeEach( + () => + void (node = scss.parse('@at-root (with: rule) {}') + .nodes[0] as GenericAtRule) + ); + + it('has a name', () => expect(node.name).toBe('at-root')); + + it('has a paramsInterpolation', () => + expect(node).toHaveInterpolation('paramsInterpolation', '(with: rule)')); + + it('has matching params', () => expect(node.params).toBe('(with: rule)')); + }); + + // TODO: test a variable used directly without interpolation + + describe('with interpolation', () => { + beforeEach( + () => + void (node = scss.parse('@at-root (with: #{rule}) {}') + .nodes[0] as GenericAtRule) + ); + + it('has a name', () => expect(node.name).toBe('at-root')); + + it('has a paramsInterpolation', () => { + const params = node.paramsInterpolation!; + expect(params.nodes[0]).toBe('(with: '); + expect(params).toHaveStringExpression(1, 'rule'); + expect(params.nodes[2]).toBe(')'); + }); + + it('has matching params', () => + expect(node.params).toBe('(with: #{rule})')); + }); + + describe('with style rule shorthand', () => { + beforeEach( + () => + void (node = scss.parse('@at-root .foo {}').nodes[0] as GenericAtRule) + ); + + it('has a name', () => expect(node.name).toBe('at-root')); + + it('has no paramsInterpolation', () => + expect(node.paramsInterpolation).toBeUndefined()); + + it('has no params', () => expect(node.params).toBe('')); + + it('contains a Rule', () => { + const rule = node.nodes[0] as Rule; + expect(rule).toHaveInterpolation('selectorInterpolation', '.foo '); + expect(rule.parent).toBe(node); + }); + }); + + describe('stringifies', () => { + describe('to SCSS', () => { + it('with atRootShorthand: false', () => + expect( + new GenericAtRule({ + name: 'at-root', + nodes: [{selector: '.foo'}], + raws: {atRootShorthand: false}, + }).toString() + ).toBe('@at-root {\n .foo {}\n}')); + + describe('with atRootShorthand: true', () => { + it('with no params and only a style rule child', () => + expect( + new GenericAtRule({ + name: 'at-root', + nodes: [{selector: '.foo'}], + raws: {atRootShorthand: true}, + }).toString() + ).toBe('@at-root .foo {}')); + + it('with no params and multiple children', () => + expect( + new GenericAtRule({ + name: 'at-root', + nodes: [{selector: '.foo'}, {selector: '.bar'}], + raws: {atRootShorthand: true}, + }).toString() + ).toBe('@at-root {\n .foo {}\n .bar {}\n}')); + + it('with no params and a non-style-rule child', () => + expect( + new GenericAtRule({ + name: 'at-root', + nodes: [{name: 'foo'}], + raws: {atRootShorthand: true}, + }).toString() + ).toBe('@at-root {\n @foo\n}')); + + it('with params and only a style rule child', () => + expect( + new GenericAtRule({ + name: 'at-root', + params: '(with: rule)', + nodes: [{selector: '.foo'}], + raws: {atRootShorthand: true}, + }).toString() + ).toBe('@at-root (with: rule) {\n .foo {}\n}')); + + it("that's not @at-root", () => + expect( + new GenericAtRule({ + name: 'at-wrong', + nodes: [{selector: '.foo'}], + raws: {atRootShorthand: true}, + }).toString() + ).toBe('@at-wrong {\n .foo {}\n}')); + }); + }); + }); +}); diff --git a/pkg/sass-parser/lib/src/statement/at-rule-internal.d.ts b/pkg/sass-parser/lib/src/statement/at-rule-internal.d.ts index 35c4f158e..7228613f8 100644 --- a/pkg/sass-parser/lib/src/statement/at-rule-internal.d.ts +++ b/pkg/sass-parser/lib/src/statement/at-rule-internal.d.ts @@ -15,8 +15,6 @@ import {AtRule, ChildNode, Comment, Declaration, NewNode} from '.'; * @hidden */ export class _AtRule extends postcss.AtRule { - declare nodes: ChildNode[]; - // Override the PostCSS container types to constrain them to Sass types only. // Unfortunately, there's no way to abstract this out, because anything // mixin-like returns an intersection type which doesn't actually override diff --git a/pkg/sass-parser/lib/src/statement/comment-internal.d.ts b/pkg/sass-parser/lib/src/statement/comment-internal.d.ts new file mode 100644 index 000000000..eb49874bf --- /dev/null +++ b/pkg/sass-parser/lib/src/statement/comment-internal.d.ts @@ -0,0 +1,31 @@ +// 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 {Root} from './root'; +import {ChildNode, NewNode} from '.'; + +/** + * A fake intermediate class to convince TypeScript to use Sass types for + * various upstream methods. + * + * @hidden + */ +export class _Comment extends postcss.Comment { + // Override the PostCSS types to constrain them to Sass types only. + // Unfortunately, there's no way to abstract this out, because anything + // mixin-like returns an intersection type which doesn't actually override + // parent methods. See microsoft/TypeScript#59394. + + after(newNode: NewNode): this; + assign(overrides: Partial): this; + before(newNode: NewNode): this; + cloneAfter(overrides?: Partial): this; + cloneBefore(overrides?: Partial): this; + next(): ChildNode | undefined; + prev(): ChildNode | undefined; + replaceWith(...nodes: NewNode[]): this; + root(): Root; +} diff --git a/pkg/sass-parser/lib/src/statement/comment-internal.js b/pkg/sass-parser/lib/src/statement/comment-internal.js new file mode 100644 index 000000000..3304da6b3 --- /dev/null +++ b/pkg/sass-parser/lib/src/statement/comment-internal.js @@ -0,0 +1,5 @@ +// 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. + +exports._Comment = require('postcss').Comment; diff --git a/pkg/sass-parser/lib/src/statement/css-comment.test.ts b/pkg/sass-parser/lib/src/statement/css-comment.test.ts new file mode 100644 index 000000000..ae1e5bc65 --- /dev/null +++ b/pkg/sass-parser/lib/src/statement/css-comment.test.ts @@ -0,0 +1,325 @@ +// 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 {CssComment, Interpolation, Root, css, sass, scss} from '../..'; +import * as utils from '../../../test/utils'; + +describe('a CSS-style comment', () => { + let node: CssComment; + function describeNode(description: string, create: () => CssComment): void { + describe(description, () => { + beforeEach(() => void (node = create())); + + it('has type comment', () => expect(node.type).toBe('comment')); + + it('has sassType comment', () => expect(node.sassType).toBe('comment')); + + it('has matching textInterpolation', () => + expect(node).toHaveInterpolation('textInterpolation', 'foo')); + + it('has matching text', () => expect(node.text).toBe('foo')); + }); + } + + describeNode( + 'parsed as SCSS', + () => scss.parse('/* foo */').nodes[0] as CssComment + ); + + describeNode( + 'parsed as CSS', + () => css.parse('/* foo */').nodes[0] as CssComment + ); + + describeNode( + 'parsed as Sass', + () => sass.parse('/* foo').nodes[0] as CssComment + ); + + describe('constructed manually', () => { + describeNode( + 'with an interpolation', + () => + new CssComment({ + textInterpolation: new Interpolation({nodes: ['foo']}), + }) + ); + + describeNode('with a text string', () => new CssComment({text: 'foo'})); + }); + + describe('constructed from ChildProps', () => { + describeNode('with an interpolation', () => + utils.fromChildProps({ + textInterpolation: new Interpolation({nodes: ['foo']}), + }) + ); + + describeNode('with a text string', () => + utils.fromChildProps({text: 'foo'}) + ); + }); + + describe('parses raws', () => { + describe('in SCSS', () => { + it('with whitespace before and after text', () => + expect((scss.parse('/* foo */').nodes[0] as CssComment).raws).toEqual({ + left: ' ', + right: ' ', + closed: true, + })); + + it('with whitespace before and after interpolation', () => + expect( + (scss.parse('/* #{foo} */').nodes[0] as CssComment).raws + ).toEqual({left: ' ', right: ' ', closed: true})); + + it('without whitespace before and after text', () => + expect((scss.parse('/*foo*/').nodes[0] as CssComment).raws).toEqual({ + left: '', + right: '', + closed: true, + })); + + it('without whitespace before and after interpolation', () => + expect((scss.parse('/*#{foo}*/').nodes[0] as CssComment).raws).toEqual({ + left: '', + right: '', + closed: true, + })); + + it('with whitespace and no text', () => + expect((scss.parse('/* */').nodes[0] as CssComment).raws).toEqual({ + left: ' ', + right: '', + closed: true, + })); + + it('with no whitespace and no text', () => + expect((scss.parse('/**/').nodes[0] as CssComment).raws).toEqual({ + left: '', + right: '', + closed: true, + })); + }); + + describe('in Sass', () => { + // TODO: Test explicit whitespace after text and interpolation once we + // properly parse raws from somewhere other than the original text. + + it('with whitespace before text', () => + expect((sass.parse('/* foo').nodes[0] as CssComment).raws).toEqual({ + left: ' ', + right: '', + closed: false, + })); + + it('with whitespace before interpolation', () => + expect((sass.parse('/* #{foo}').nodes[0] as CssComment).raws).toEqual({ + left: ' ', + right: '', + closed: false, + })); + + it('without whitespace before and after text', () => + expect((sass.parse('/*foo').nodes[0] as CssComment).raws).toEqual({ + left: '', + right: '', + closed: false, + })); + + it('without whitespace before and after interpolation', () => + expect((sass.parse('/*#{foo}').nodes[0] as CssComment).raws).toEqual({ + left: '', + right: '', + closed: false, + })); + + it('with no whitespace and no text', () => + expect((sass.parse('/*').nodes[0] as CssComment).raws).toEqual({ + left: '', + right: '', + closed: false, + })); + + it('with a trailing */', () => + expect((sass.parse('/* foo */').nodes[0] as CssComment).raws).toEqual({ + left: ' ', + right: ' ', + closed: true, + })); + }); + }); + + describe('stringifies', () => { + describe('to SCSS', () => { + it('with default raws', () => + expect(new CssComment({text: 'foo'}).toString()).toBe('/* foo */')); + + it('with left', () => + expect( + new CssComment({ + text: 'foo', + raws: {left: '\n'}, + }).toString() + ).toBe('/*\nfoo */')); + + it('with right', () => + expect( + new CssComment({ + text: 'foo', + raws: {right: '\n'}, + }).toString() + ).toBe('/* foo\n*/')); + + it('with before', () => + expect( + new Root({ + nodes: [new CssComment({text: 'foo', raws: {before: '/**/'}})], + }).toString() + ).toBe('/**//* foo */')); + }); + }); + + describe('assigned new text', () => { + beforeEach(() => { + node = scss.parse('/* foo */').nodes[0] as CssComment; + }); + + it("removes the old text's parent", () => { + const oldText = node.textInterpolation!; + node.textInterpolation = 'bar'; + expect(oldText.parent).toBeUndefined(); + }); + + it("assigns the new interpolation's parent", () => { + const interpolation = new Interpolation({nodes: ['bar']}); + node.textInterpolation = interpolation; + expect(interpolation.parent).toBe(node); + }); + + it('assigns the interpolation explicitly', () => { + const interpolation = new Interpolation({nodes: ['bar']}); + node.textInterpolation = interpolation; + expect(node.textInterpolation).toBe(interpolation); + }); + + it('assigns the interpolation as a string', () => { + node.textInterpolation = 'bar'; + expect(node).toHaveInterpolation('textInterpolation', 'bar'); + }); + + it('assigns the interpolation as text', () => { + node.text = 'bar'; + expect(node).toHaveInterpolation('textInterpolation', 'bar'); + }); + }); + + describe('clone', () => { + let original: CssComment; + beforeEach( + () => void (original = scss.parse('/* foo */').nodes[0] as CssComment) + ); + + describe('with no overrides', () => { + let clone: CssComment; + beforeEach(() => { + clone = original.clone(); + }); + + describe('has the same properties:', () => { + it('textInterpolation', () => + expect(clone).toHaveInterpolation('textInterpolation', 'foo')); + + it('text', () => expect(clone.text).toBe('foo')); + + it('raws', () => + expect(clone.raws).toEqual({left: ' ', right: ' ', closed: true})); + + it('source', () => expect(clone.source).toBe(original.source)); + }); + + describe('creates a new', () => { + it('self', () => expect(clone).not.toBe(original)); + + for (const attr of ['textInterpolation', 'raws'] as const) { + it(attr, () => expect(clone[attr]).not.toBe(original[attr])); + } + }); + }); + + describe('overrides', () => { + describe('text', () => { + describe('defined', () => { + let clone: CssComment; + beforeEach(() => { + clone = original.clone({text: 'bar'}); + }); + + it('changes text', () => expect(clone.text).toBe('bar')); + + it('changes textInterpolation', () => + expect(clone).toHaveInterpolation('textInterpolation', 'bar')); + }); + + describe('undefined', () => { + let clone: CssComment; + beforeEach(() => { + clone = original.clone({text: undefined}); + }); + + it('preserves text', () => expect(clone.text).toBe('foo')); + + it('preserves textInterpolation', () => + expect(clone).toHaveInterpolation('textInterpolation', 'foo')); + }); + }); + + describe('textInterpolation', () => { + describe('defined', () => { + let clone: CssComment; + beforeEach(() => { + clone = original.clone({ + textInterpolation: new Interpolation({nodes: ['baz']}), + }); + }); + + it('changes text', () => expect(clone.text).toBe('baz')); + + it('changes textInterpolation', () => + expect(clone).toHaveInterpolation('textInterpolation', 'baz')); + }); + + describe('undefined', () => { + let clone: CssComment; + beforeEach(() => { + clone = original.clone({textInterpolation: undefined}); + }); + + it('preserves text', () => expect(clone.text).toBe('foo')); + + it('preserves textInterpolation', () => + expect(clone).toHaveInterpolation('textInterpolation', 'foo')); + }); + }); + + describe('raws', () => { + it('defined', () => + expect(original.clone({raws: {right: ' '}}).raws).toEqual({ + right: ' ', + })); + + it('undefined', () => + expect(original.clone({raws: undefined}).raws).toEqual({ + left: ' ', + right: ' ', + closed: true, + })); + }); + }); + }); + + it('toJSON', () => + expect(scss.parse('/* foo */').nodes[0]).toMatchSnapshot()); +}); diff --git a/pkg/sass-parser/lib/src/statement/css-comment.ts b/pkg/sass-parser/lib/src/statement/css-comment.ts new file mode 100644 index 000000000..f2565caa0 --- /dev/null +++ b/pkg/sass-parser/lib/src/statement/css-comment.ts @@ -0,0 +1,164 @@ +// 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 {CommentRaws} from 'postcss/lib/comment'; + +import {convertExpression} from '../expression/convert'; +import {LazySource} from '../lazy-source'; +import type * as sassInternal from '../sass-internal'; +import {Interpolation} from '../interpolation'; +import * as utils from '../utils'; +import {ContainerProps, Statement, StatementWithChildren} from '.'; +import {_Comment} from './comment-internal'; +import {interceptIsClean} from './intercept-is-clean'; +import * as sassParser from '../..'; + +/** + * The set of raws supported by {@link CssComment}. + * + * @category Statement + */ +export interface CssCommentRaws extends CommentRaws { + /** + * In the indented syntax, this indicates whether a comment is explicitly + * closed with a `*\/`. It's ignored in other syntaxes. + * + * It defaults to false. + */ + closed?: boolean; +} + +/** + * The initializer properties for {@link CssComment}. + * + * @category Statement + */ +export type CssCommentProps = ContainerProps & { + raws?: CssCommentRaws; +} & ({text: string} | {textInterpolation: Interpolation | string}); + +/** + * A CSS-style "loud" comment. Extends [`postcss.Comment`]. + * + * [`postcss.Comment`]: https://postcss.org/api/#comment + * + * @category Statement + */ +export class CssComment + extends _Comment> + implements Statement +{ + readonly sassType = 'comment' as const; + declare parent: StatementWithChildren | undefined; + declare raws: CssCommentRaws; + + get text(): string { + return this.textInterpolation.toString(); + } + set text(value: string) { + this.textInterpolation = value; + } + + /** The interpolation that represents this selector's contents. */ + get textInterpolation(): Interpolation { + return this._textInterpolation!; + } + set textInterpolation(textInterpolation: Interpolation | string) { + // TODO - postcss/postcss#1957: Mark this as dirty + if (this._textInterpolation) { + this._textInterpolation.parent = undefined; + } + if (typeof textInterpolation === 'string') { + textInterpolation = new Interpolation({ + nodes: [textInterpolation], + }); + } + textInterpolation.parent = this; + this._textInterpolation = textInterpolation; + } + private _textInterpolation?: Interpolation; + + constructor(defaults: CssCommentProps); + /** @hidden */ + constructor(_: undefined, inner: sassInternal.LoudComment); + constructor(defaults?: CssCommentProps, inner?: sassInternal.LoudComment) { + super(defaults as unknown as postcss.CommentProps); + + if (inner) { + this.source = new LazySource(inner); + const nodes = [...inner.text.contents]; + + // The interpolation's contents are guaranteed to begin with a string, + // because Sass includes the `/*`. + let first = nodes[0] as string; + const firstMatch = first.match(/^\/\*([ \t\n\r\f]*)/)!; + this.raws.left ??= firstMatch[1]; + first = first.substring(firstMatch[0].length); + if (first.length === 0) { + nodes.shift(); + } else { + nodes[0] = first; + } + + // The interpolation will end with `*/` in SCSS, but not necessarily in + // the indented syntax. + let last = nodes.at(-1); + if (typeof last === 'string') { + const lastMatch = last.match(/([ \t\n\r\f]*)\*\/$/); + this.raws.right ??= lastMatch?.[1] ?? ''; + this.raws.closed = !!lastMatch; + if (lastMatch) { + last = last.substring(0, last.length - lastMatch[0].length); + if (last.length === 0) { + nodes.pop(); + } else { + nodes[0] = last; + } + } + } else { + this.raws.right ??= ''; + this.raws.closed = false; + } + + this.textInterpolation = new Interpolation(); + for (const child of nodes) { + this.textInterpolation.append( + typeof child === 'string' ? child : convertExpression(child) + ); + } + } + } + + clone(overrides?: Partial): this { + return utils.cloneNode( + this, + overrides, + ['raws', 'textInterpolation'], + ['text'] + ); + } + + toJSON(): object; + /** @hidden */ + toJSON(_: string, inputs: Map): object; + toJSON(_?: string, inputs?: Map): object { + return utils.toJSON(this, ['text', 'textInterpolation'], inputs); + } + + /** @hidden */ + toString( + stringifier: postcss.Stringifier | postcss.Syntax = sassParser.scss + .stringify + ): string { + return super.toString(stringifier); + } + + /** @hidden */ + get nonStatementChildren(): ReadonlyArray { + return [this.textInterpolation]; + } +} + +interceptIsClean(CssComment); diff --git a/pkg/sass-parser/lib/src/statement/debug-rule.test.ts b/pkg/sass-parser/lib/src/statement/debug-rule.test.ts new file mode 100644 index 000000000..2ac421dbc --- /dev/null +++ b/pkg/sass-parser/lib/src/statement/debug-rule.test.ts @@ -0,0 +1,205 @@ +// 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 {DebugRule, StringExpression, sass, scss} from '../..'; +import * as utils from '../../../test/utils'; + +describe('a @debug rule', () => { + let node: DebugRule; + function describeNode(description: string, create: () => DebugRule): void { + describe(description, () => { + beforeEach(() => void (node = create())); + + it('has a name', () => expect(node.name.toString()).toBe('debug')); + + it('has an expression', () => + expect(node).toHaveStringExpression('debugExpression', 'foo')); + + it('has matching params', () => expect(node.params).toBe('foo')); + + it('has undefined nodes', () => expect(node.nodes).toBeUndefined()); + }); + } + + describeNode( + 'parsed as SCSS', + () => scss.parse('@debug foo').nodes[0] as DebugRule + ); + + describeNode( + 'parsed as Sass', + () => sass.parse('@debug foo').nodes[0] as DebugRule + ); + + describeNode( + 'constructed manually', + () => + new DebugRule({ + debugExpression: {text: 'foo'}, + }) + ); + + describeNode('constructed from ChildProps', () => + utils.fromChildProps({ + debugExpression: {text: 'foo'}, + }) + ); + + it('throws an error when assigned a new name', () => + expect( + () => + (new DebugRule({ + debugExpression: {text: 'foo'}, + }).name = 'bar') + ).toThrow()); + + describe('assigned a new expression', () => { + beforeEach(() => { + node = scss.parse('@debug foo').nodes[0] as DebugRule; + }); + + it('sets an empty string expression as undefined params', () => { + node.params = undefined; + expect(node.params).toBe(''); + expect(node).toHaveStringExpression('debugExpression', ''); + }); + + it('sets an empty string expression as empty string params', () => { + node.params = ''; + expect(node.params).toBe(''); + expect(node).toHaveStringExpression('debugExpression', ''); + }); + + it("removes the old expression's parent", () => { + const oldExpression = node.debugExpression; + node.debugExpression = {text: 'bar'}; + expect(oldExpression.parent).toBeUndefined(); + }); + + it("assigns the new expression's parent", () => { + const expression = new StringExpression({text: 'bar'}); + node.debugExpression = expression; + expect(expression.parent).toBe(node); + }); + + it('assigns the expression explicitly', () => { + const expression = new StringExpression({text: 'bar'}); + node.debugExpression = expression; + expect(node.debugExpression).toBe(expression); + }); + + it('assigns the expression as ExpressionProps', () => { + node.debugExpression = {text: 'bar'}; + expect(node).toHaveStringExpression('debugExpression', 'bar'); + }); + + it('assigns the expression as params', () => { + node.params = 'bar'; + expect(node).toHaveStringExpression('debugExpression', 'bar'); + }); + }); + + describe('stringifies', () => { + describe('to SCSS', () => { + it('with default raws', () => + expect( + new DebugRule({ + debugExpression: {text: 'foo'}, + }).toString() + ).toBe('@debug foo;')); + + it('with afterName', () => + expect( + new DebugRule({ + debugExpression: {text: 'foo'}, + raws: {afterName: '/**/'}, + }).toString() + ).toBe('@debug/**/foo;')); + + it('with between', () => + expect( + new DebugRule({ + debugExpression: {text: 'foo'}, + raws: {between: '/**/'}, + }).toString() + ).toBe('@debug foo/**/;')); + }); + }); + + describe('clone', () => { + let original: DebugRule; + beforeEach(() => { + original = scss.parse('@debug foo').nodes[0] as DebugRule; + // TODO: remove this once raws are properly parsed + original.raws.between = ' '; + }); + + describe('with no overrides', () => { + let clone: DebugRule; + beforeEach(() => void (clone = original.clone())); + + describe('has the same properties:', () => { + it('params', () => expect(clone.params).toBe('foo')); + + it('debugExpression', () => + expect(clone).toHaveStringExpression('debugExpression', '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 ['debugExpression', '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('debugExpression', () => { + describe('defined', () => { + let clone: DebugRule; + beforeEach(() => { + clone = original.clone({debugExpression: {text: 'bar'}}); + }); + + it('changes params', () => expect(clone.params).toBe('bar')); + + it('changes debugExpression', () => + expect(clone).toHaveStringExpression('debugExpression', 'bar')); + }); + + describe('undefined', () => { + let clone: DebugRule; + beforeEach(() => { + clone = original.clone({debugExpression: undefined}); + }); + + it('preserves params', () => expect(clone.params).toBe('foo')); + + it('preserves debugExpression', () => + expect(clone).toHaveStringExpression('debugExpression', 'foo')); + }); + }); + }); + }); + + it('toJSON', () => + expect(scss.parse('@debug foo').nodes[0]).toMatchSnapshot()); +}); diff --git a/pkg/sass-parser/lib/src/statement/debug-rule.ts b/pkg/sass-parser/lib/src/statement/debug-rule.ts new file mode 100644 index 000000000..d0030d81d --- /dev/null +++ b/pkg/sass-parser/lib/src/statement/debug-rule.ts @@ -0,0 +1,129 @@ +// Copyright 2024 Google Inc. Use of this source code is governed by an +// MIT-style license that can be found in the LICENSE file or at +// https://opensource.org/licenses/MIT. + +import * as postcss from 'postcss'; +import type {AtRuleRaws as PostcssAtRuleRaws} from 'postcss/lib/at-rule'; + +import {convertExpression} from '../expression/convert'; +import {Expression, ExpressionProps} from '../expression'; +import {fromProps} from '../expression/from-props'; +import {LazySource} from '../lazy-source'; +import type * as sassInternal from '../sass-internal'; +import * as utils from '../utils'; +import {Statement, StatementWithChildren} from '.'; +import {_AtRule} from './at-rule-internal'; +import {interceptIsClean} from './intercept-is-clean'; +import * as sassParser from '../..'; + +/** + * The set of raws supported by {@link DebugRule}. + * + * @category Statement + */ +export type DebugRuleRaws = Pick< + PostcssAtRuleRaws, + 'afterName' | 'before' | 'between' +>; + +/** + * The initializer properties for {@link DebugRule}. + * + * @category Statement + */ +export type DebugRuleProps = postcss.NodeProps & { + raws?: DebugRuleRaws; + debugExpression: Expression | ExpressionProps; +}; + +/** + * A `@debug` rule. Extends [`postcss.AtRule`]. + * + * [`postcss.AtRule`]: https://postcss.org/api/#atrule + * + * @category Statement + */ +export class DebugRule + extends _AtRule> + implements Statement +{ + readonly sassType = 'debug-rule' as const; + declare parent: StatementWithChildren | undefined; + declare raws: DebugRuleRaws; + declare readonly nodes: undefined; + + get name(): string { + return 'debug'; + } + set name(value: string) { + throw new Error("DebugRule.name can't be overwritten."); + } + + get params(): string { + return this.debugExpression.toString(); + } + set params(value: string | number | undefined) { + this.debugExpression = {text: value?.toString() ?? ''}; + } + + /** The expresison whose value is emitted when the debug rule is executed. */ + get debugExpression(): Expression { + return this._debugExpression!; + } + set debugExpression(debugExpression: Expression | ExpressionProps) { + if (this._debugExpression) this._debugExpression.parent = undefined; + if (!('sassType' in debugExpression)) { + debugExpression = fromProps(debugExpression); + } + if (debugExpression) debugExpression.parent = this; + this._debugExpression = debugExpression; + } + private _debugExpression?: Expression; + + constructor(defaults: DebugRuleProps); + /** @hidden */ + constructor(_: undefined, inner: sassInternal.DebugRule); + constructor(defaults?: DebugRuleProps, inner?: sassInternal.DebugRule) { + super(defaults as unknown as postcss.AtRuleProps); + + if (inner) { + this.source = new LazySource(inner); + this.debugExpression = convertExpression(inner.expression); + } + } + + clone(overrides?: Partial): this { + return utils.cloneNode( + this, + overrides, + ['raws', 'debugExpression'], + [{name: 'params', explicitUndefined: true}] + ); + } + + toJSON(): object; + /** @hidden */ + toJSON(_: string, inputs: Map): object; + toJSON(_?: string, inputs?: Map): object { + return utils.toJSON( + this, + ['name', 'debugExpression', 'params', 'nodes'], + inputs + ); + } + + /** @hidden */ + toString( + stringifier: postcss.Stringifier | postcss.Syntax = sassParser.scss + .stringify + ): string { + return super.toString(stringifier); + } + + /** @hidden */ + get nonStatementChildren(): ReadonlyArray { + return [this.debugExpression]; + } +} + +interceptIsClean(DebugRule); diff --git a/pkg/sass-parser/lib/src/statement/each-rule.test.ts b/pkg/sass-parser/lib/src/statement/each-rule.test.ts new file mode 100644 index 000000000..d70d53992 --- /dev/null +++ b/pkg/sass-parser/lib/src/statement/each-rule.test.ts @@ -0,0 +1,302 @@ +// 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 {EachRule, GenericAtRule, StringExpression, sass, scss} from '../..'; +import * as utils from '../../../test/utils'; + +describe('an @each rule', () => { + let node: EachRule; + describe('with empty children', () => { + function describeNode(description: string, create: () => EachRule): void { + describe(description, () => { + beforeEach(() => void (node = create())); + + it('has a name', () => expect(node.name.toString()).toBe('each')); + + it('has variables', () => + expect(node.variables).toEqual(['foo', 'bar'])); + + it('has an expression', () => + expect(node).toHaveStringExpression('eachExpression', 'baz')); + + it('has matching params', () => + expect(node.params).toBe('$foo, $bar in baz')); + + it('has empty nodes', () => expect(node.nodes).toEqual([])); + }); + } + + describeNode( + 'parsed as SCSS', + () => scss.parse('@each $foo, $bar in baz {}').nodes[0] as EachRule + ); + + describeNode( + 'parsed as Sass', + () => sass.parse('@each $foo, $bar in baz').nodes[0] as EachRule + ); + + describeNode( + 'constructed manually', + () => + new EachRule({ + variables: ['foo', 'bar'], + eachExpression: {text: 'baz'}, + }) + ); + + describeNode('constructed from ChildProps', () => + utils.fromChildProps({ + variables: ['foo', 'bar'], + eachExpression: {text: 'baz'}, + }) + ); + }); + + describe('with a child', () => { + function describeNode(description: string, create: () => EachRule): void { + describe(description, () => { + beforeEach(() => void (node = create())); + + it('has a name', () => expect(node.name.toString()).toBe('each')); + + it('has variables', () => + expect(node.variables).toEqual(['foo', 'bar'])); + + it('has an expression', () => + expect(node).toHaveStringExpression('eachExpression', 'baz')); + + it('has matching params', () => + expect(node.params).toBe('$foo, $bar in baz')); + + 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('@each $foo, $bar in baz {@child}').nodes[0] as EachRule + ); + + describeNode( + 'parsed as Sass', + () => sass.parse('@each $foo, $bar in baz\n @child').nodes[0] as EachRule + ); + + describeNode( + 'constructed manually', + () => + new EachRule({ + variables: ['foo', 'bar'], + eachExpression: {text: 'baz'}, + nodes: [{name: 'child'}], + }) + ); + + describeNode('constructed from ChildProps', () => + utils.fromChildProps({ + variables: ['foo', 'bar'], + eachExpression: {text: 'baz'}, + nodes: [{name: 'child'}], + }) + ); + }); + + describe('throws an error when assigned a new', () => { + beforeEach( + () => + void (node = new EachRule({ + variables: ['foo', 'bar'], + eachExpression: {text: 'baz'}, + })) + ); + + it('name', () => expect(() => (node.name = 'qux')).toThrow()); + + it('params', () => + expect(() => (node.params = '$zip, $zap in qux')).toThrow()); + }); + + describe('assigned a new expression', () => { + beforeEach(() => { + node = scss.parse('@each $foo, $bar in baz {}').nodes[0] as EachRule; + }); + + it("removes the old expression's parent", () => { + const oldExpression = node.eachExpression; + node.eachExpression = {text: 'qux'}; + expect(oldExpression.parent).toBeUndefined(); + }); + + it("assigns the new expression's parent", () => { + const expression = new StringExpression({text: 'qux'}); + node.eachExpression = expression; + expect(expression.parent).toBe(node); + }); + + it('assigns the expression explicitly', () => { + const expression = new StringExpression({text: 'qux'}); + node.eachExpression = expression; + expect(node.eachExpression).toBe(expression); + }); + + it('assigns the expression as ExpressionProps', () => { + node.eachExpression = {text: 'qux'}; + expect(node).toHaveStringExpression('eachExpression', 'qux'); + }); + }); + + describe('stringifies', () => { + describe('to SCSS', () => { + it('with default raws', () => + expect( + new EachRule({ + variables: ['foo', 'bar'], + eachExpression: {text: 'baz'}, + }).toString() + ).toBe('@each $foo, $bar in baz {}')); + + it('with afterName', () => + expect( + new EachRule({ + variables: ['foo', 'bar'], + eachExpression: {text: 'baz'}, + raws: {afterName: '/**/'}, + }).toString() + ).toBe('@each/**/$foo, $bar in baz {}')); + + it('with afterVariables', () => + expect( + new EachRule({ + variables: ['foo', 'bar'], + eachExpression: {text: 'baz'}, + raws: {afterVariables: ['/**/,', '/* */']}, + }).toString() + ).toBe('@each $foo/**/,$bar/* */in baz {}')); + + it('with afterIn', () => + expect( + new EachRule({ + variables: ['foo', 'bar'], + eachExpression: {text: 'baz'}, + raws: {afterIn: '/**/'}, + }).toString() + ).toBe('@each $foo, $bar in/**/baz {}')); + }); + }); + + describe('clone', () => { + let original: EachRule; + beforeEach(() => { + original = scss.parse('@each $foo, $bar in baz {}').nodes[0] as EachRule; + // TODO: remove this once raws are properly parsed + original.raws.between = ' '; + }); + + describe('with no overrides', () => { + let clone: EachRule; + beforeEach(() => void (clone = original.clone())); + + describe('has the same properties:', () => { + it('params', () => expect(clone.params).toBe('$foo, $bar in baz')); + + it('variables', () => expect(clone.variables).toEqual(['foo', 'bar'])); + + it('eachExpression', () => + expect(clone).toHaveStringExpression('eachExpression', 'baz')); + + 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 ['variables', 'eachExpression', '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('variables', () => { + describe('defined', () => { + let clone: EachRule; + beforeEach(() => { + clone = original.clone({variables: ['zip', 'zap']}); + }); + + it('changes params', () => + expect(clone.params).toBe('$zip, $zap in baz')); + + it('changes variables', () => + expect(clone.variables).toEqual(['zip', 'zap'])); + }); + + describe('undefined', () => { + let clone: EachRule; + beforeEach(() => { + clone = original.clone({variables: undefined}); + }); + + it('preserves params', () => + expect(clone.params).toBe('$foo, $bar in baz')); + + it('preserves variables', () => + expect(clone.variables).toEqual(['foo', 'bar'])); + }); + }); + + describe('eachExpression', () => { + describe('defined', () => { + let clone: EachRule; + beforeEach(() => { + clone = original.clone({eachExpression: {text: 'qux'}}); + }); + + it('changes params', () => + expect(clone.params).toBe('$foo, $bar in qux')); + + it('changes eachExpression', () => + expect(clone).toHaveStringExpression('eachExpression', 'qux')); + }); + + describe('undefined', () => { + let clone: EachRule; + beforeEach(() => { + clone = original.clone({eachExpression: undefined}); + }); + + it('preserves params', () => + expect(clone.params).toBe('$foo, $bar in baz')); + + it('preserves eachExpression', () => + expect(clone).toHaveStringExpression('eachExpression', 'baz')); + }); + }); + }); + }); + + it('toJSON', () => + expect( + scss.parse('@each $foo, $bar in baz {}').nodes[0] + ).toMatchSnapshot()); +}); diff --git a/pkg/sass-parser/lib/src/statement/each-rule.ts b/pkg/sass-parser/lib/src/statement/each-rule.ts new file mode 100644 index 000000000..e0339f961 --- /dev/null +++ b/pkg/sass-parser/lib/src/statement/each-rule.ts @@ -0,0 +1,165 @@ +// 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 EachRule}. + * + * @category Statement + */ +export interface EachRuleRaws extends Omit { + /** + * The whitespace and commas after each variable in + * {@link EachRule.variables}. + * + * The element at index `i` is included after the variable at index `i`. Any + * elements beyond `variables.length` are ignored. + */ + afterVariables?: string[]; + + /** The whitespace between `in` and {@link EachRule.eachExpression}. */ + afterIn?: string; +} + +/** + * The initializer properties for {@link EachRule}. + * + * @category Statement + */ +export type EachRuleProps = ContainerProps & { + raws?: EachRuleRaws; + variables: string[]; + eachExpression: Expression | ExpressionProps; +}; + +/** + * An `@each` rule. Extends [`postcss.AtRule`]. + * + * [`postcss.AtRule`]: https://postcss.org/api/#atrule + * + * @category Statement + */ +export class EachRule + extends _AtRule> + implements Statement +{ + readonly sassType = 'each-rule' as const; + declare parent: StatementWithChildren | undefined; + declare raws: EachRuleRaws; + declare nodes: ChildNode[]; + + /** The variable names assigned for each iteration, without `"$"`. */ + declare variables: string[]; + + get name(): string { + return 'each'; + } + set name(value: string) { + throw new Error("EachRule.name can't be overwritten."); + } + + get params(): string { + let result = ''; + for (let i = 0; i < this.variables.length; i++) { + result += + '$' + + this.variables[i] + + (this.raws?.afterVariables?.[i] ?? + (i === this.variables.length - 1 ? ' ' : ', ')); + } + return `${result}in${this.raws.afterIn ?? ' '}${this.eachExpression}`; + } + set params(value: string | number | undefined) { + throw new Error("EachRule.params can't be overwritten."); + } + + /** The expresison whose value is iterated over. */ + get eachExpression(): Expression { + return this._eachExpression!; + } + set eachExpression(eachExpression: Expression | ExpressionProps) { + if (this._eachExpression) this._eachExpression.parent = undefined; + if (!('sassType' in eachExpression)) { + eachExpression = fromProps(eachExpression); + } + if (eachExpression) eachExpression.parent = this; + this._eachExpression = eachExpression; + } + private _eachExpression?: Expression; + + constructor(defaults: EachRuleProps); + /** @hidden */ + constructor(_: undefined, inner: sassInternal.EachRule); + constructor(defaults?: EachRuleProps, inner?: sassInternal.EachRule) { + super(defaults as unknown as postcss.AtRuleProps); + this.nodes ??= []; + + if (inner) { + this.source = new LazySource(inner); + this.variables = [...inner.variables]; + this.eachExpression = convertExpression(inner.list); + appendInternalChildren(this, inner.children); + } + } + + clone(overrides?: Partial): this { + return utils.cloneNode(this, overrides, [ + 'raws', + 'variables', + 'eachExpression', + ]); + } + + toJSON(): object; + /** @hidden */ + toJSON(_: string, inputs: Map): object; + toJSON(_?: string, inputs?: Map): object { + return utils.toJSON( + this, + ['name', 'variables', 'eachExpression', 'params', 'nodes'], + inputs + ); + } + + /** @hidden */ + toString( + stringifier: postcss.Stringifier | postcss.Syntax = sassParser.scss + .stringify + ): string { + return super.toString(stringifier); + } + + /** @hidden */ + get nonStatementChildren(): ReadonlyArray { + return [this.eachExpression]; + } + + /** @hidden */ + normalize(node: NewNode, sample?: postcss.Node): ChildNode[] { + return normalize(this, node, sample); + } +} + +interceptIsClean(EachRule); diff --git a/pkg/sass-parser/lib/src/statement/error-rule.test.ts b/pkg/sass-parser/lib/src/statement/error-rule.test.ts new file mode 100644 index 000000000..0524338bf --- /dev/null +++ b/pkg/sass-parser/lib/src/statement/error-rule.test.ts @@ -0,0 +1,205 @@ +// 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 {ErrorRule, StringExpression, sass, scss} from '../..'; +import * as utils from '../../../test/utils'; + +describe('a @error rule', () => { + let node: ErrorRule; + function describeNode(description: string, create: () => ErrorRule): void { + describe(description, () => { + beforeEach(() => void (node = create())); + + it('has a name', () => expect(node.name.toString()).toBe('error')); + + it('has an expression', () => + expect(node).toHaveStringExpression('errorExpression', 'foo')); + + it('has matching params', () => expect(node.params).toBe('foo')); + + it('has undefined nodes', () => expect(node.nodes).toBeUndefined()); + }); + } + + describeNode( + 'parsed as SCSS', + () => scss.parse('@error foo').nodes[0] as ErrorRule + ); + + describeNode( + 'parsed as Sass', + () => sass.parse('@error foo').nodes[0] as ErrorRule + ); + + describeNode( + 'constructed manually', + () => + new ErrorRule({ + errorExpression: {text: 'foo'}, + }) + ); + + describeNode('constructed from ChildProps', () => + utils.fromChildProps({ + errorExpression: {text: 'foo'}, + }) + ); + + it('throws an error when assigned a new name', () => + expect( + () => + (new ErrorRule({ + errorExpression: {text: 'foo'}, + }).name = 'bar') + ).toThrow()); + + describe('assigned a new expression', () => { + beforeEach(() => { + node = scss.parse('@error foo').nodes[0] as ErrorRule; + }); + + it('sets an empty string expression as undefined params', () => { + node.params = undefined; + expect(node.params).toBe(''); + expect(node).toHaveStringExpression('errorExpression', ''); + }); + + it('sets an empty string expression as empty string params', () => { + node.params = ''; + expect(node.params).toBe(''); + expect(node).toHaveStringExpression('errorExpression', ''); + }); + + it("removes the old expression's parent", () => { + const oldExpression = node.errorExpression; + node.errorExpression = {text: 'bar'}; + expect(oldExpression.parent).toBeUndefined(); + }); + + it("assigns the new expression's parent", () => { + const expression = new StringExpression({text: 'bar'}); + node.errorExpression = expression; + expect(expression.parent).toBe(node); + }); + + it('assigns the expression explicitly', () => { + const expression = new StringExpression({text: 'bar'}); + node.errorExpression = expression; + expect(node.errorExpression).toBe(expression); + }); + + it('assigns the expression as ExpressionProps', () => { + node.errorExpression = {text: 'bar'}; + expect(node).toHaveStringExpression('errorExpression', 'bar'); + }); + + it('assigns the expression as params', () => { + node.params = 'bar'; + expect(node).toHaveStringExpression('errorExpression', 'bar'); + }); + }); + + describe('stringifies', () => { + describe('to SCSS', () => { + it('with default raws', () => + expect( + new ErrorRule({ + errorExpression: {text: 'foo'}, + }).toString() + ).toBe('@error foo;')); + + it('with afterName', () => + expect( + new ErrorRule({ + errorExpression: {text: 'foo'}, + raws: {afterName: '/**/'}, + }).toString() + ).toBe('@error/**/foo;')); + + it('with between', () => + expect( + new ErrorRule({ + errorExpression: {text: 'foo'}, + raws: {between: '/**/'}, + }).toString() + ).toBe('@error foo/**/;')); + }); + }); + + describe('clone', () => { + let original: ErrorRule; + beforeEach(() => { + original = scss.parse('@error foo').nodes[0] as ErrorRule; + // TODO: remove this once raws are properly parsed + original.raws.between = ' '; + }); + + describe('with no overrides', () => { + let clone: ErrorRule; + beforeEach(() => void (clone = original.clone())); + + describe('has the same properties:', () => { + it('params', () => expect(clone.params).toBe('foo')); + + it('errorExpression', () => + expect(clone).toHaveStringExpression('errorExpression', '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 ['errorExpression', '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('errorExpression', () => { + describe('defined', () => { + let clone: ErrorRule; + beforeEach(() => { + clone = original.clone({errorExpression: {text: 'bar'}}); + }); + + it('changes params', () => expect(clone.params).toBe('bar')); + + it('changes errorExpression', () => + expect(clone).toHaveStringExpression('errorExpression', 'bar')); + }); + + describe('undefined', () => { + let clone: ErrorRule; + beforeEach(() => { + clone = original.clone({errorExpression: undefined}); + }); + + it('preserves params', () => expect(clone.params).toBe('foo')); + + it('preserves errorExpression', () => + expect(clone).toHaveStringExpression('errorExpression', 'foo')); + }); + }); + }); + }); + + it('toJSON', () => + expect(scss.parse('@error foo').nodes[0]).toMatchSnapshot()); +}); diff --git a/pkg/sass-parser/lib/src/statement/error-rule.ts b/pkg/sass-parser/lib/src/statement/error-rule.ts new file mode 100644 index 000000000..7b55f4253 --- /dev/null +++ b/pkg/sass-parser/lib/src/statement/error-rule.ts @@ -0,0 +1,129 @@ +// Copyright 2024 Google Inc. Use of this source code is governed by an +// MIT-style license that can be found in the LICENSE file or at +// https://opensource.org/licenses/MIT. + +import * as postcss from 'postcss'; +import type {AtRuleRaws as PostcssAtRuleRaws} from 'postcss/lib/at-rule'; + +import {convertExpression} from '../expression/convert'; +import {Expression, ExpressionProps} from '../expression'; +import {fromProps} from '../expression/from-props'; +import {LazySource} from '../lazy-source'; +import type * as sassInternal from '../sass-internal'; +import * as utils from '../utils'; +import {Statement, StatementWithChildren} from '.'; +import {_AtRule} from './at-rule-internal'; +import {interceptIsClean} from './intercept-is-clean'; +import * as sassParser from '../..'; + +/** + * The set of raws supported by {@link ErrorRule}. + * + * @category Statement + */ +export type ErrorRuleRaws = Pick< + PostcssAtRuleRaws, + 'afterName' | 'before' | 'between' +>; + +/** + * The initializer properties for {@link ErrorRule}. + * + * @category Statement + */ +export type ErrorRuleProps = postcss.NodeProps & { + raws?: ErrorRuleRaws; + errorExpression: Expression | ExpressionProps; +}; + +/** + * An `@error` rule. Extends [`postcss.AtRule`]. + * + * [`postcss.AtRule`]: https://postcss.org/api/#atrule + * + * @category Statement + */ +export class ErrorRule + extends _AtRule> + implements Statement +{ + readonly sassType = 'error-rule' as const; + declare parent: StatementWithChildren | undefined; + declare raws: ErrorRuleRaws; + declare readonly nodes: undefined; + + get name(): string { + return 'error'; + } + set name(value: string) { + throw new Error("ErrorRule.name can't be overwritten."); + } + + get params(): string { + return this.errorExpression.toString(); + } + set params(value: string | number | undefined) { + this.errorExpression = {text: value?.toString() ?? ''}; + } + + /** The expresison whose value is thrown when the error rule is executed. */ + get errorExpression(): Expression { + return this._errorExpression!; + } + set errorExpression(errorExpression: Expression | ExpressionProps) { + if (this._errorExpression) this._errorExpression.parent = undefined; + if (!('sassType' in errorExpression)) { + errorExpression = fromProps(errorExpression); + } + if (errorExpression) errorExpression.parent = this; + this._errorExpression = errorExpression; + } + private _errorExpression?: Expression; + + constructor(defaults: ErrorRuleProps); + /** @hidden */ + constructor(_: undefined, inner: sassInternal.ErrorRule); + constructor(defaults?: ErrorRuleProps, inner?: sassInternal.ErrorRule) { + super(defaults as unknown as postcss.AtRuleProps); + + if (inner) { + this.source = new LazySource(inner); + this.errorExpression = convertExpression(inner.expression); + } + } + + clone(overrides?: Partial): this { + return utils.cloneNode( + this, + overrides, + ['raws', 'errorExpression'], + [{name: 'params', explicitUndefined: true}] + ); + } + + toJSON(): object; + /** @hidden */ + toJSON(_: string, inputs: Map): object; + toJSON(_?: string, inputs?: Map): object { + return utils.toJSON( + this, + ['name', 'errorExpression', 'params', 'nodes'], + inputs + ); + } + + /** @hidden */ + toString( + stringifier: postcss.Stringifier | postcss.Syntax = sassParser.scss + .stringify + ): string { + return super.toString(stringifier); + } + + /** @hidden */ + get nonStatementChildren(): ReadonlyArray { + return [this.errorExpression]; + } +} + +interceptIsClean(ErrorRule); diff --git a/pkg/sass-parser/lib/src/statement/extend-rule.test.ts b/pkg/sass-parser/lib/src/statement/extend-rule.test.ts new file mode 100644 index 000000000..15485b604 --- /dev/null +++ b/pkg/sass-parser/lib/src/statement/extend-rule.test.ts @@ -0,0 +1,61 @@ +// 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, Rule, scss} from '../..'; + +describe('an @extend rule', () => { + let node: GenericAtRule; + + describe('with no interpolation', () => { + beforeEach( + () => + void (node = (scss.parse('.foo {@extend .bar}').nodes[0] as Rule) + .nodes[0] as GenericAtRule) + ); + + it('has a name', () => expect(node.name).toBe('extend')); + + it('has a paramsInterpolation', () => + expect(node).toHaveInterpolation('paramsInterpolation', '.bar')); + + it('has matching params', () => expect(node.params).toBe('.bar')); + }); + + describe('with interpolation', () => { + beforeEach( + () => + void (node = (scss.parse('.foo {@extend .#{bar}}').nodes[0] as Rule) + .nodes[0] as GenericAtRule) + ); + + it('has a name', () => expect(node.name).toBe('extend')); + + it('has a paramsInterpolation', () => { + const params = node.paramsInterpolation!; + expect(params.nodes[0]).toBe('.'); + expect(params).toHaveStringExpression(1, 'bar'); + }); + + it('has matching params', () => expect(node.params).toBe('.#{bar}')); + }); + + describe('with !optional', () => { + beforeEach( + () => + void (node = ( + scss.parse('.foo {@extend .bar !optional}').nodes[0] as Rule + ).nodes[0] as GenericAtRule) + ); + + it('has a name', () => expect(node.name).toBe('extend')); + + it('has a paramsInterpolation', () => + expect(node).toHaveInterpolation( + 'paramsInterpolation', + '.bar !optional' + )); + + it('has matching params', () => expect(node.params).toBe('.bar !optional')); + }); +}); diff --git a/pkg/sass-parser/lib/src/statement/for-rule.test.ts b/pkg/sass-parser/lib/src/statement/for-rule.test.ts new file mode 100644 index 000000000..607dd4217 --- /dev/null +++ b/pkg/sass-parser/lib/src/statement/for-rule.test.ts @@ -0,0 +1,437 @@ +// 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 {ForRule, GenericAtRule, StringExpression, sass, scss} from '../..'; +import * as utils from '../../../test/utils'; + +describe('an @for rule', () => { + let node: ForRule; + describe('with empty children', () => { + function describeNode(description: string, create: () => ForRule): void { + describe(description, () => { + beforeEach(() => void (node = create())); + + it('has a name', () => expect(node.name.toString()).toBe('for')); + + it('has a variable', () => expect(node.variable).toBe('foo')); + + it('has a to', () => expect(node.to).toBe('through')); + + it('has a from expression', () => + expect(node).toHaveStringExpression('fromExpression', 'bar')); + + it('has a to expression', () => + expect(node).toHaveStringExpression('toExpression', 'baz')); + + it('has matching params', () => + expect(node.params).toBe('$foo from bar through baz')); + + it('has empty nodes', () => expect(node.nodes).toEqual([])); + }); + } + + describeNode( + 'parsed as SCSS', + () => scss.parse('@for $foo from bar through baz {}').nodes[0] as ForRule + ); + + describeNode( + 'parsed as Sass', + () => sass.parse('@for $foo from bar through baz').nodes[0] as ForRule + ); + + describeNode( + 'constructed manually', + () => + new ForRule({ + variable: 'foo', + to: 'through', + fromExpression: {text: 'bar'}, + toExpression: {text: 'baz'}, + }) + ); + + describeNode('constructed from ChildProps', () => + utils.fromChildProps({ + variable: 'foo', + to: 'through', + fromExpression: {text: 'bar'}, + toExpression: {text: 'baz'}, + }) + ); + }); + + describe('with a child', () => { + function describeNode(description: string, create: () => ForRule): void { + describe(description, () => { + beforeEach(() => void (node = create())); + + it('has a name', () => expect(node.name.toString()).toBe('for')); + + it('has a variable', () => expect(node.variable).toBe('foo')); + + it('has a to', () => expect(node.to).toBe('through')); + + it('has a from expression', () => + expect(node).toHaveStringExpression('fromExpression', 'bar')); + + it('has a to expression', () => + expect(node).toHaveStringExpression('toExpression', 'baz')); + + it('has matching params', () => + expect(node.params).toBe('$foo from bar through baz')); + + 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('@for $foo from bar through baz {@child}') + .nodes[0] as ForRule + ); + + describeNode( + 'parsed as Sass', + () => + sass.parse('@for $foo from bar through baz\n @child') + .nodes[0] as ForRule + ); + + describeNode( + 'constructed manually', + () => + new ForRule({ + variable: 'foo', + to: 'through', + fromExpression: {text: 'bar'}, + toExpression: {text: 'baz'}, + nodes: [{name: 'child'}], + }) + ); + + describeNode('constructed from ChildProps', () => + utils.fromChildProps({ + variable: 'foo', + to: 'through', + fromExpression: {text: 'bar'}, + toExpression: {text: 'baz'}, + nodes: [{name: 'child'}], + }) + ); + }); + + describe('throws an error when assigned a new', () => { + beforeEach( + () => + void (node = new ForRule({ + variable: 'foo', + fromExpression: {text: 'bar'}, + toExpression: {text: 'baz'}, + })) + ); + + it('name', () => expect(() => (node.name = 'qux')).toThrow()); + + it('params', () => + expect(() => (node.params = '$zip from zap to qux')).toThrow()); + }); + + describe('assigned a new from expression', () => { + beforeEach(() => { + node = scss.parse('@for $foo from bar to baz {}').nodes[0] as ForRule; + }); + + it("removes the old expression's parent", () => { + const oldExpression = node.fromExpression; + node.fromExpression = {text: 'qux'}; + expect(oldExpression.parent).toBeUndefined(); + }); + + it("assigns the new expression's parent", () => { + const expression = new StringExpression({text: 'qux'}); + node.fromExpression = expression; + expect(expression.parent).toBe(node); + }); + + it('assigns the expression explicitly', () => { + const expression = new StringExpression({text: 'qux'}); + node.fromExpression = expression; + expect(node.fromExpression).toBe(expression); + }); + + it('assigns the expression as ExpressionProps', () => { + node.fromExpression = {text: 'qux'}; + expect(node).toHaveStringExpression('fromExpression', 'qux'); + }); + }); + + describe('assigned a new to expression', () => { + beforeEach(() => { + node = scss.parse('@for $foo from bar to baz {}').nodes[0] as ForRule; + }); + + it("removes the old expression's parent", () => { + const oldExpression = node.toExpression; + node.toExpression = {text: 'qux'}; + expect(oldExpression.parent).toBeUndefined(); + }); + + it("assigns the new expression's parent", () => { + const expression = new StringExpression({text: 'qux'}); + node.toExpression = expression; + expect(expression.parent).toBe(node); + }); + + it('assigns the expression explicitly', () => { + const expression = new StringExpression({text: 'qux'}); + node.toExpression = expression; + expect(node.toExpression).toBe(expression); + }); + + it('assigns the expression as ExpressionProps', () => { + node.toExpression = {text: 'qux'}; + expect(node).toHaveStringExpression('toExpression', 'qux'); + }); + }); + + describe('stringifies', () => { + describe('to SCSS', () => { + it('with default raws', () => + expect( + new ForRule({ + variable: 'foo', + fromExpression: {text: 'bar'}, + toExpression: {text: 'baz'}, + }).toString() + ).toBe('@for $foo from bar to baz {}')); + + it('with afterName', () => + expect( + new ForRule({ + variable: 'foo', + fromExpression: {text: 'bar'}, + toExpression: {text: 'baz'}, + raws: {afterName: '/**/'}, + }).toString() + ).toBe('@for/**/$foo from bar to baz {}')); + + it('with afterVariable', () => + expect( + new ForRule({ + variable: 'foo', + fromExpression: {text: 'bar'}, + toExpression: {text: 'baz'}, + raws: {afterVariable: '/**/'}, + }).toString() + ).toBe('@for $foo/**/from bar to baz {}')); + + it('with afterFrom', () => + expect( + new ForRule({ + variable: 'foo', + fromExpression: {text: 'bar'}, + toExpression: {text: 'baz'}, + raws: {afterFrom: '/**/'}, + }).toString() + ).toBe('@for $foo from/**/bar to baz {}')); + + it('with afterFromExpression', () => + expect( + new ForRule({ + variable: 'foo', + fromExpression: {text: 'bar'}, + toExpression: {text: 'baz'}, + raws: {afterFromExpression: '/**/'}, + }).toString() + ).toBe('@for $foo from bar/**/to baz {}')); + + it('with afterTo', () => + expect( + new ForRule({ + variable: 'foo', + fromExpression: {text: 'bar'}, + toExpression: {text: 'baz'}, + raws: {afterTo: '/**/'}, + }).toString() + ).toBe('@for $foo from bar to/**/baz {}')); + }); + }); + + describe('clone', () => { + let original: ForRule; + beforeEach(() => { + original = scss.parse('@for $foo from bar to baz {}').nodes[0] as ForRule; + // TODO: remove this once raws are properly parsed + original.raws.between = ' '; + }); + + describe('with no overrides', () => { + let clone: ForRule; + beforeEach(() => void (clone = original.clone())); + + describe('has the same properties:', () => { + it('params', () => expect(clone.params).toBe('$foo from bar to baz')); + + it('variable', () => expect(clone.variable).toBe('foo')); + + it('to', () => expect(clone.to).toBe('to')); + + it('fromExpression', () => + expect(clone).toHaveStringExpression('fromExpression', 'bar')); + + it('toExpression', () => + expect(clone).toHaveStringExpression('toExpression', 'baz')); + + 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 [ + 'fromExpression', + 'toExpression', + '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('variable', () => { + describe('defined', () => { + let clone: ForRule; + beforeEach(() => { + clone = original.clone({variable: 'zip'}); + }); + + it('changes params', () => + expect(clone.params).toBe('$zip from bar to baz')); + + it('changes variable', () => expect(clone.variable).toBe('zip')); + }); + + describe('undefined', () => { + let clone: ForRule; + beforeEach(() => { + clone = original.clone({variable: undefined}); + }); + + it('preserves params', () => + expect(clone.params).toBe('$foo from bar to baz')); + + it('preserves variable', () => expect(clone.variable).toBe('foo')); + }); + }); + + describe('to', () => { + describe('defined', () => { + let clone: ForRule; + beforeEach(() => { + clone = original.clone({to: 'through'}); + }); + + it('changes params', () => + expect(clone.params).toBe('$foo from bar through baz')); + + it('changes tos', () => expect(clone.to).toBe('through')); + }); + + describe('undefined', () => { + let clone: ForRule; + beforeEach(() => { + clone = original.clone({to: undefined}); + }); + + it('preserves params', () => + expect(clone.params).toBe('$foo from bar to baz')); + + it('preserves tos', () => expect(clone.to).toBe('to')); + }); + }); + + describe('fromExpression', () => { + describe('defined', () => { + let clone: ForRule; + beforeEach(() => { + clone = original.clone({fromExpression: {text: 'qux'}}); + }); + + it('changes params', () => + expect(clone.params).toBe('$foo from qux to baz')); + + it('changes fromExpression', () => + expect(clone).toHaveStringExpression('fromExpression', 'qux')); + }); + + describe('undefined', () => { + let clone: ForRule; + beforeEach(() => { + clone = original.clone({fromExpression: undefined}); + }); + + it('preserves params', () => + expect(clone.params).toBe('$foo from bar to baz')); + + it('preserves fromExpression', () => + expect(clone).toHaveStringExpression('fromExpression', 'bar')); + }); + }); + + describe('toExpression', () => { + describe('defined', () => { + let clone: ForRule; + beforeEach(() => { + clone = original.clone({toExpression: {text: 'qux'}}); + }); + + it('changes params', () => + expect(clone.params).toBe('$foo from bar to qux')); + + it('changes toExpression', () => + expect(clone).toHaveStringExpression('toExpression', 'qux')); + }); + + describe('undefined', () => { + let clone: ForRule; + beforeEach(() => { + clone = original.clone({toExpression: undefined}); + }); + + it('preserves params', () => + expect(clone.params).toBe('$foo from bar to baz')); + + it('preserves toExpression', () => + expect(clone).toHaveStringExpression('toExpression', 'baz')); + }); + }); + }); + }); + + it('toJSON', () => + expect( + scss.parse('@for $foo from bar to baz {}').nodes[0] + ).toMatchSnapshot()); +}); diff --git a/pkg/sass-parser/lib/src/statement/for-rule.ts b/pkg/sass-parser/lib/src/statement/for-rule.ts new file mode 100644 index 000000000..d67fe91f6 --- /dev/null +++ b/pkg/sass-parser/lib/src/statement/for-rule.ts @@ -0,0 +1,200 @@ +// 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 ForRule}. + * + * @category Statement + */ +export interface ForRuleRaws extends Omit { + /** The whitespace after {@link ForRule.variable}. */ + afterVariable?: string; + + /** The whitespace after a {@link ForRule}'s `from` keyword. */ + afterFrom?: string; + + /** The whitespace after {@link ForRule.fromExpression}. */ + afterFromExpression?: string; + + /** The whitespace after a {@link ForRule}'s `to` or `through` keyword. */ + afterTo?: string; +} + +/** + * The initializer properties for {@link ForRule}. + * + * @category Statement + */ +export type ForRuleProps = ContainerProps & { + raws?: ForRuleRaws; + variable: string; + fromExpression: Expression | ExpressionProps; + toExpression: Expression | ExpressionProps; + to?: 'to' | 'through'; +}; + +/** + * A `@for` rule. Extends [`postcss.AtRule`]. + * + * [`postcss.AtRule`]: https://postcss.org/api/#atrule + * + * @category Statement + */ +export class ForRule + extends _AtRule> + implements Statement +{ + readonly sassType = 'for-rule' as const; + declare parent: StatementWithChildren | undefined; + declare raws: ForRuleRaws; + declare nodes: ChildNode[]; + + /** The variabl names assigned for for iteration, without `"$"`. */ + declare variable: string; + + /** + * The keyword that appears before {@link toExpression}. + * + * If this is `"to"`, the loop is exclusive; if it's `"through"`, the loop is + * inclusive. It defaults to `"to"` when creating a new `ForRule`. + */ + declare to: 'to' | 'through'; + + get name(): string { + return 'for'; + } + set name(value: string) { + throw new Error("ForRule.name can't be overwritten."); + } + + get params(): string { + return ( + `$${this.variable}${this.raws.afterVariable ?? ' '}from` + + `${this.raws.afterFrom ?? ' '}${this.fromExpression}` + + `${this.raws.afterFromExpression ?? ' '}${this.to}` + + `${this.raws.afterTo ?? ' '}${this.toExpression}` + ); + } + set params(value: string | number | undefined) { + throw new Error("ForRule.params can't be overwritten."); + } + + /** The expresison whose value is the starting point of the iteration. */ + get fromExpression(): Expression { + return this._fromExpression!; + } + set fromExpression(fromExpression: Expression | ExpressionProps) { + if (this._fromExpression) this._fromExpression.parent = undefined; + if (!('sassType' in fromExpression)) { + fromExpression = fromProps(fromExpression); + } + if (fromExpression) fromExpression.parent = this; + this._fromExpression = fromExpression; + } + private _fromExpression?: Expression; + + /** The expresison whose value is the ending point of the iteration. */ + get toExpression(): Expression { + return this._toExpression!; + } + set toExpression(toExpression: Expression | ExpressionProps) { + if (this._toExpression) this._toExpression.parent = undefined; + if (!('sassType' in toExpression)) { + toExpression = fromProps(toExpression); + } + if (toExpression) toExpression.parent = this; + this._toExpression = toExpression; + } + private _toExpression?: Expression; + + constructor(defaults: ForRuleProps); + /** @hidden */ + constructor(_: undefined, inner: sassInternal.ForRule); + constructor(defaults?: ForRuleProps, inner?: sassInternal.ForRule) { + super(defaults as unknown as postcss.AtRuleProps); + this.nodes ??= []; + + if (inner) { + this.source = new LazySource(inner); + this.variable = inner.variable; + this.to = inner.isExclusive ? 'to' : 'through'; + this.fromExpression = convertExpression(inner.from); + this.toExpression = convertExpression(inner.to); + appendInternalChildren(this, inner.children); + } + + this.to ??= 'to'; + } + + clone(overrides?: Partial): this { + return utils.cloneNode(this, overrides, [ + 'raws', + 'variable', + 'to', + 'fromExpression', + 'toExpression', + ]); + } + + toJSON(): object; + /** @hidden */ + toJSON(_: string, inputs: Map): object; + toJSON(_?: string, inputs?: Map): object { + return utils.toJSON( + this, + [ + 'name', + 'variable', + 'to', + 'fromExpression', + 'toExpression', + 'params', + 'nodes', + ], + inputs + ); + } + + /** @hidden */ + toString( + stringifier: postcss.Stringifier | postcss.Syntax = sassParser.scss + .stringify + ): string { + return super.toString(stringifier); + } + + /** @hidden */ + get nonStatementChildren(): ReadonlyArray { + return [this.fromExpression, this.toExpression]; + } + + /** @hidden */ + normalize(node: NewNode, sample?: postcss.Node): ChildNode[] { + return normalize(this, node, sample); + } +} + +interceptIsClean(ForRule); 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 c8ec3c745..373161f42 100644 --- a/pkg/sass-parser/lib/src/statement/generic-at-rule.ts +++ b/pkg/sass-parser/lib/src/statement/generic-at-rule.ts @@ -26,12 +26,20 @@ import * as sassParser from '../..'; /** * The set of raws supported by {@link GenericAtRule}. * - * Sass doesn't support PostCSS's `params` raws, since the param interpolation - * is lexed and made directly available to the caller. + * Sass doesn't support PostCSS's `params` raws, since + * {@link GenericAtRule.paramInterpolation} has its own raws. * * @category Statement */ -export type GenericAtRuleRaws = Omit; +export interface GenericAtRuleRaws extends Omit { + /** + * Whether to collapse the nesting for an `@at-root` with no params that + * contains only a single style rule. + * + * This is ignored for rules that don't meet all of those criteria. + */ + atRootShorthand?: boolean; +} /** * The initializer properties for {@link GenericAtRule}. @@ -64,6 +72,7 @@ export class GenericAtRule readonly sassType = 'atrule' as const; declare parent: StatementWithChildren | undefined; declare raws: GenericAtRuleRaws; + declare nodes: ChildNode[]; get name(): string { return this.nameInterpolation.toString(); diff --git a/pkg/sass-parser/lib/src/statement/index.ts b/pkg/sass-parser/lib/src/statement/index.ts index cd4824a79..ddef69246 100644 --- a/pkg/sass-parser/lib/src/statement/index.ts +++ b/pkg/sass-parser/lib/src/statement/index.ts @@ -4,22 +4,29 @@ import * as postcss from 'postcss'; +import {Interpolation} from '../interpolation'; +import {LazySource} from '../lazy-source'; import {Node, NodeProps} from '../node'; import * as sassInternal from '../sass-internal'; +import {CssComment, CssCommentProps} from './css-comment'; import {GenericAtRule, GenericAtRuleProps} from './generic-at-rule'; +import {DebugRule, DebugRuleProps} from './debug-rule'; +import {EachRule, EachRuleProps} from './each-rule'; +import {ErrorRule, ErrorRuleProps} from './error-rule'; +import {ForRule, ForRuleProps} from './for-rule'; import {Root} from './root'; import {Rule, RuleProps} from './rule'; // TODO: Replace this with the corresponding Sass types once they're // implemented. -export {Comment, Declaration} from 'postcss'; +export {Declaration} from 'postcss'; /** * The union type of all Sass statements. * * @category Statement */ -export type AnyStatement = Root | Rule | GenericAtRule; +export type AnyStatement = Comment | Root | Rule | GenericAtRule; /** * Sass statement types. @@ -30,14 +37,29 @@ export type AnyStatement = Root | Rule | GenericAtRule; * * @category Statement */ -export type StatementType = 'root' | 'rule' | 'atrule'; +export type StatementType = + | 'root' + | 'rule' + | 'atrule' + | 'comment' + | 'debug-rule' + | 'each-rule' + | 'for-rule' + | 'error-rule'; /** * All Sass statements that are also at-rules. * * @category Statement */ -export type AtRule = GenericAtRule; +export type AtRule = DebugRule | EachRule | ErrorRule | ForRule | GenericAtRule; + +/** + * All Sass statements that are comments. + * + * @category Statement + */ +export type Comment = CssComment; /** * All Sass statements that are valid children of other statements. @@ -46,7 +68,7 @@ export type AtRule = GenericAtRule; * * @category Statement */ -export type ChildNode = Rule | AtRule; +export type ChildNode = Rule | AtRule | Comment; /** * The properties that can be used to construct {@link ChildNode}s. @@ -55,7 +77,15 @@ export type ChildNode = Rule | AtRule; * * @category Statement */ -export type ChildProps = postcss.ChildProps | RuleProps | GenericAtRuleProps; +export type ChildProps = + | postcss.ChildProps + | CssCommentProps + | DebugRuleProps + | EachRuleProps + | ErrorRuleProps + | ForRuleProps + | GenericAtRuleProps + | RuleProps; /** * The Sass eqivalent of PostCSS's `ContainerProps`. @@ -93,7 +123,32 @@ export interface Statement extends postcss.Node, Node { /** The visitor to use to convert internal Sass nodes to JS. */ const visitor = sassInternal.createStatementVisitor({ + visitAtRootRule: inner => { + const rule = new GenericAtRule({ + name: 'at-root', + paramsInterpolation: inner.query + ? new Interpolation(undefined, inner.query) + : undefined, + source: new LazySource(inner), + }); + appendInternalChildren(rule, inner.children); + return rule; + }, visitAtRule: inner => new GenericAtRule(undefined, inner), + visitDebugRule: inner => new DebugRule(undefined, inner), + visitErrorRule: inner => new ErrorRule(undefined, inner), + visitEachRule: inner => new EachRule(undefined, inner), + visitForRule: inner => new ForRule(undefined, inner), + visitExtendRule: inner => { + const paramsInterpolation = new Interpolation(undefined, inner.selector); + if (inner.isOptional) paramsInterpolation.append('!optional'); + return new GenericAtRule({ + name: 'extend', + paramsInterpolation, + source: new LazySource(inner), + }); + }, + visitLoudComment: inner => new CssComment(undefined, inner), visitStyleRule: inner => new Rule(undefined, inner), }); @@ -196,7 +251,17 @@ export function normalize( ) { result.push(new Rule(node)); } else if ('name' in node || 'nameInterpolation' in node) { - result.push(new GenericAtRule(node)); + result.push(new GenericAtRule(node as GenericAtRuleProps)); + } else if ('debugExpression' in node) { + result.push(new DebugRule(node)); + } else if ('eachExpression' in node) { + result.push(new EachRule(node)); + } else if ('fromExpression' in node) { + result.push(new ForRule(node)); + } else if ('errorExpression' in node) { + result.push(new ErrorRule(node)); + } else if ('text' in node || 'textInterpolation' in node) { + result.push(new CssComment(node as CssCommentProps)); } else { result.push(...postcssNormalizeAndConvertToSass(self, node, sample)); } diff --git a/pkg/sass-parser/lib/src/stringifier.ts b/pkg/sass-parser/lib/src/stringifier.ts index c732b8dde..f4bc121a4 100644 --- a/pkg/sass-parser/lib/src/stringifier.ts +++ b/pkg/sass-parser/lib/src/stringifier.ts @@ -29,6 +29,9 @@ import * as postcss from 'postcss'; import {AnyStatement} from './statement'; +import {DebugRule} from './statement/debug-rule'; +import {EachRule} from './statement/each-rule'; +import {ErrorRule} from './statement/error-rule'; import {GenericAtRule} from './statement/generic-at-rule'; import {Rule} from './statement/rule'; @@ -67,7 +70,67 @@ export class Stringifier extends PostCssStringifier { )(statement, semicolon); } + private ['debug-rule'](node: DebugRule, semicolon: boolean): void { + this.builder( + '@debug' + + (node.raws.afterName ?? ' ') + + node.debugExpression + + (node.raws.between ?? '') + + (semicolon ? ';' : ''), + node + ); + } + + private ['each-rule'](node: EachRule): void { + this.block( + node, + '@each' + + (node.raws.afterName ?? ' ') + + node.params + + (node.raws.between ?? '') + ); + } + + private ['error-rule'](node: ErrorRule, semicolon: boolean): void { + this.builder( + '@error' + + (node.raws.afterName ?? ' ') + + node.errorExpression + + (node.raws.between ?? '') + + (semicolon ? ';' : ''), + node + ); + } + + private ['for-rule'](node: EachRule): void { + this.block( + node, + '@for' + + (node.raws.afterName ?? ' ') + + node.params + + (node.raws.between ?? '') + ); + } + private atrule(node: GenericAtRule, semicolon: boolean): void { + // In the @at-root shorthand, stringify `@at-root {.foo {...}}` as + // `@at-root .foo {...}`. + if ( + node.raws.atRootShorthand && + node.name === 'at-root' && + node.paramsInterpolation === undefined && + node.nodes.length === 1 && + node.nodes[0].sassType === 'rule' + ) { + this.block( + node.nodes[0], + '@at-root' + + (node.raws.afterName ?? ' ') + + node.nodes[0].selectorInterpolation + ); + return; + } + const start = `@${node.nameInterpolation}` + (node.raws.afterName ?? (node.paramsInterpolation ? ' ' : '')) + diff --git a/pkg/sass_api/CHANGELOG.md b/pkg/sass_api/CHANGELOG.md index 8284c79ae..4ce4a1375 100644 --- a/pkg/sass_api/CHANGELOG.md +++ b/pkg/sass_api/CHANGELOG.md @@ -2,6 +2,9 @@ * Remove the `CallableDeclaration()` constructor. +* Loud comments in the Sass syntax no longer automatically inject ` */` to the + end when parsed. + ## 10.4.8 * No user-visible changes.