Skip to content

Commit 5dff2e8

Browse files
authoredSep 5, 2024··
Merge pull request #2317 from sass/more-statements
Add more statement parsing for `sass-parser`
2 parents a42925c + 7784231 commit 5dff2e8

33 files changed

+2860
-23
lines changed
 

‎lib/src/parse/sass.dart

-1
Original file line numberDiff line numberDiff line change
@@ -268,7 +268,6 @@ class SassParser extends StylesheetParser {
268268

269269
_readIndentation();
270270
}
271-
if (!buffer.trailingString.trimRight().endsWith("*/")) buffer.write(" */");
272271

273272
return LoudComment(buffer.interpolation(scanner.spanFrom(start)));
274273
}

‎lib/src/parse/stylesheet.dart

+12-2
Original file line numberDiff line numberDiff line change
@@ -748,12 +748,12 @@ abstract class StylesheetParser extends Parser {
748748
buffer.writeCharCode($lparen);
749749
whitespace();
750750

751-
buffer.add(_expression());
751+
_addOrInject(buffer, _expression());
752752
if (scanner.scanChar($colon)) {
753753
whitespace();
754754
buffer.writeCharCode($colon);
755755
buffer.writeCharCode($space);
756-
buffer.add(_expression());
756+
_addOrInject(buffer, _expression());
757757
}
758758

759759
scanner.expectChar($rparen);
@@ -3519,6 +3519,16 @@ abstract class StylesheetParser extends Parser {
35193519
span());
35203520
}
35213521

3522+
/// Adds [expression] to [buffer], or if it's an unquoted string adds the
3523+
/// interpolation it contains instead.
3524+
void _addOrInject(InterpolationBuffer buffer, Expression expression) {
3525+
if (expression is StringExpression && !expression.hasQuotes) {
3526+
buffer.addInterpolation(expression.text);
3527+
} else {
3528+
buffer.add(expression);
3529+
}
3530+
}
3531+
35223532
// ## Abstract Methods
35233533

35243534
/// Whether this is parsing the indented syntax.

‎lib/src/visitor/async_evaluate.dart

+4-2
Original file line numberDiff line numberDiff line change
@@ -1911,8 +1911,10 @@ final class _EvaluateVisitor
19111911
_endOfImports++;
19121912
}
19131913

1914-
_parent.addChild(ModifiableCssComment(
1915-
await _performInterpolation(node.text), node.span));
1914+
var text = await _performInterpolation(node.text);
1915+
// Indented syntax doesn't require */
1916+
if (!text.endsWith("*/")) text += " */";
1917+
_parent.addChild(ModifiableCssComment(text, node.span));
19161918
return null;
19171919
}
19181920

‎lib/src/visitor/evaluate.dart

+5-3
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
// DO NOT EDIT. This file was generated from async_evaluate.dart.
66
// See tool/grind/synchronize.dart for details.
77
//
8-
// Checksum: ebf292c26dcfdd7f61fd70ce3dc9e0be2b6708b3
8+
// Checksum: 2ab69d23a3b34cb54ddd74e2e854614dda582174
99
//
1010
// ignore_for_file: unused_import
1111

@@ -1903,8 +1903,10 @@ final class _EvaluateVisitor
19031903
_endOfImports++;
19041904
}
19051905

1906-
_parent.addChild(
1907-
ModifiableCssComment(_performInterpolation(node.text), node.span));
1906+
var text = _performInterpolation(node.text);
1907+
// Indented syntax doesn't require */
1908+
if (!text.endsWith("*/")) text += " */";
1909+
_parent.addChild(ModifiableCssComment(text, node.span));
19081910
return null;
19091911
}
19101912

‎pkg/sass-parser/README.md

+11
Original file line numberDiff line numberDiff line change
@@ -246,3 +246,14 @@ const sassParser = require('sass-parser');
246246
const root = new sassParser.Root();
247247
root.append('content: "hello, world!"');
248248
```
249+
250+
### Known Incompatibilities
251+
252+
There are a few cases where an operation that's valid in PostCSS won't work with
253+
`sass-parser`:
254+
255+
* Trying to convert a Sass-specific at-rule like `@if` or `@mixin` into a
256+
different at-rule by changing its name is not supported.
257+
258+
* Trying to add child nodes to a Sass statement that doesn't support children
259+
like `@use` or `@error` is not supported.

‎pkg/sass-parser/jest.config.ts

+1
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ const config = {
33
roots: ['lib'],
44
testEnvironment: 'node',
55
setupFilesAfterEnv: ['jest-extended/all', '<rootDir>/test/setup.ts'],
6+
verbose: false,
67
};
78

89
export default config;

‎pkg/sass-parser/lib/index.ts

+17
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,23 @@ export {
3333
InterpolationRaws,
3434
NewNodeForInterpolation,
3535
} from './src/interpolation';
36+
export {
37+
CssComment,
38+
CssCommentProps,
39+
CssCommentRaws,
40+
} from './src/statement/css-comment';
41+
export {
42+
DebugRule,
43+
DebugRuleProps,
44+
DebugRuleRaws,
45+
} from './src/statement/debug-rule';
46+
export {EachRule, EachRuleProps, EachRuleRaws} from './src/statement/each-rule';
47+
export {
48+
ErrorRule,
49+
ErrorRuleProps,
50+
ErrorRuleRaws,
51+
} from './src/statement/error-rule';
52+
export {ForRule, ForRuleProps, ForRuleRaws} from './src/statement/for-rule';
3653
export {
3754
GenericAtRule,
3855
GenericAtRuleProps,

‎pkg/sass-parser/lib/src/interpolation.ts

+2-3
Original file line numberDiff line numberDiff line change
@@ -112,9 +112,8 @@ export class Interpolation extends Node {
112112
*/
113113
get asPlain(): string | null {
114114
if (this.nodes.length === 0) return '';
115-
if (this.nodes.length !== 1) return null;
116-
if (typeof this.nodes[0] !== 'string') return null;
117-
return this.nodes[0] as string;
115+
if (this.nodes.some(node => typeof node !== 'string')) return null;
116+
return this.nodes.join('');
118117
}
119118

120119
/**

‎pkg/sass-parser/lib/src/sass-internal.ts

+48
Original file line numberDiff line numberDiff line change
@@ -66,11 +66,45 @@ declare namespace SassInternal {
6666
readonly children: T;
6767
}
6868

69+
class AtRootRule extends ParentStatement<Statement[]> {
70+
readonly name: Interpolation;
71+
readonly query?: Interpolation;
72+
}
73+
6974
class AtRule extends ParentStatement<Statement[]> {
7075
readonly name: Interpolation;
7176
readonly value?: Interpolation;
7277
}
7378

79+
class DebugRule extends Statement {
80+
readonly expression: Expression;
81+
}
82+
83+
class EachRule extends ParentStatement<Statement[]> {
84+
readonly variables: string[];
85+
readonly list: Expression;
86+
}
87+
88+
class ErrorRule extends Statement {
89+
readonly expression: Expression;
90+
}
91+
92+
class ExtendRule extends Statement {
93+
readonly selector: Interpolation;
94+
readonly isOptional: boolean;
95+
}
96+
97+
class ForRule extends ParentStatement<Statement[]> {
98+
readonly variable: string;
99+
readonly from: Expression;
100+
readonly to: Expression;
101+
readonly isExclusive: boolean;
102+
}
103+
104+
class LoudComment extends Statement {
105+
readonly text: Interpolation;
106+
}
107+
74108
class Stylesheet extends ParentStatement<Statement[]> {}
75109

76110
class StyleRule extends ParentStatement<Statement[]> {
@@ -106,7 +140,14 @@ export type SassNode = SassInternal.SassNode;
106140
export type Statement = SassInternal.Statement;
107141
export type ParentStatement<T extends Statement[] | null> =
108142
SassInternal.ParentStatement<T>;
143+
export type AtRootRule = SassInternal.AtRootRule;
109144
export type AtRule = SassInternal.AtRule;
145+
export type DebugRule = SassInternal.DebugRule;
146+
export type EachRule = SassInternal.EachRule;
147+
export type ErrorRule = SassInternal.ErrorRule;
148+
export type ExtendRule = SassInternal.ExtendRule;
149+
export type ForRule = SassInternal.ForRule;
150+
export type LoudComment = SassInternal.LoudComment;
110151
export type Stylesheet = SassInternal.Stylesheet;
111152
export type StyleRule = SassInternal.StyleRule;
112153
export type Interpolation = SassInternal.Interpolation;
@@ -115,7 +156,14 @@ export type BinaryOperationExpression = SassInternal.BinaryOperationExpression;
115156
export type StringExpression = SassInternal.StringExpression;
116157

117158
export interface StatementVisitorObject<T> {
159+
visitAtRootRule(node: AtRootRule): T;
118160
visitAtRule(node: AtRule): T;
161+
visitDebugRule(node: DebugRule): T;
162+
visitEachRule(node: EachRule): T;
163+
visitErrorRule(node: ErrorRule): T;
164+
visitExtendRule(node: ExtendRule): T;
165+
visitForRule(node: ForRule): T;
166+
visitLoudComment(node: LoudComment): T;
119167
visitStyleRule(node: StyleRule): T;
120168
}
121169

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
// Jest Snapshot v1, https://goo.gl/fbAQLP
2+
3+
exports[`a CSS-style comment toJSON 1`] = `
4+
{
5+
"inputs": [
6+
{
7+
"css": "/* foo */",
8+
"hasBOM": false,
9+
"id": "<input css _____>",
10+
},
11+
],
12+
"raws": {
13+
"closed": true,
14+
"left": " ",
15+
"right": " ",
16+
},
17+
"sassType": "comment",
18+
"source": <1:1-1:10 in 0>,
19+
"text": "foo",
20+
"textInterpolation": <foo>,
21+
"type": "comment",
22+
}
23+
`;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
// Jest Snapshot v1, https://goo.gl/fbAQLP
2+
3+
exports[`a @debug rule toJSON 1`] = `
4+
{
5+
"debugExpression": <foo>,
6+
"inputs": [
7+
{
8+
"css": "@debug foo",
9+
"hasBOM": false,
10+
"id": "<input css _____>",
11+
},
12+
],
13+
"name": "debug",
14+
"params": "foo",
15+
"raws": {},
16+
"sassType": "debug-rule",
17+
"source": <1:1-1:11 in 0>,
18+
"type": "atrule",
19+
}
20+
`;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
// Jest Snapshot v1, https://goo.gl/fbAQLP
2+
3+
exports[`an @each rule toJSON 1`] = `
4+
{
5+
"eachExpression": <baz>,
6+
"inputs": [
7+
{
8+
"css": "@each $foo, $bar in baz {}",
9+
"hasBOM": false,
10+
"id": "<input css _____>",
11+
},
12+
],
13+
"name": "each",
14+
"nodes": [],
15+
"params": "$foo, $bar in baz",
16+
"raws": {},
17+
"sassType": "each-rule",
18+
"source": <1:1-1:27 in 0>,
19+
"type": "atrule",
20+
"variables": [
21+
"foo",
22+
"bar",
23+
],
24+
}
25+
`;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
// Jest Snapshot v1, https://goo.gl/fbAQLP
2+
3+
exports[`a @error rule toJSON 1`] = `
4+
{
5+
"errorExpression": <foo>,
6+
"inputs": [
7+
{
8+
"css": "@error foo",
9+
"hasBOM": false,
10+
"id": "<input css _____>",
11+
},
12+
],
13+
"name": "error",
14+
"params": "foo",
15+
"raws": {},
16+
"sassType": "error-rule",
17+
"source": <1:1-1:11 in 0>,
18+
"type": "atrule",
19+
}
20+
`;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
// Jest Snapshot v1, https://goo.gl/fbAQLP
2+
3+
exports[`an @for rule toJSON 1`] = `
4+
{
5+
"fromExpression": <bar>,
6+
"inputs": [
7+
{
8+
"css": "@for $foo from bar to baz {}",
9+
"hasBOM": false,
10+
"id": "<input css _____>",
11+
},
12+
],
13+
"name": "for",
14+
"nodes": [],
15+
"params": "$foo from bar to baz",
16+
"raws": {},
17+
"sassType": "for-rule",
18+
"source": <1:1-1:29 in 0>,
19+
"to": "to",
20+
"toExpression": <baz>,
21+
"type": "atrule",
22+
"variable": "foo",
23+
}
24+
`;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
// Copyright 2024 Google Inc. Use of this source code is governed by an
2+
// MIT-style license that can be found in the LICENSE file or at
3+
// https://opensource.org/licenses/MIT.
4+
5+
import {GenericAtRule, Rule, scss} from '../..';
6+
7+
describe('an @at-root rule', () => {
8+
let node: GenericAtRule;
9+
10+
describe('with no params', () => {
11+
beforeEach(
12+
() => void (node = scss.parse('@at-root {}').nodes[0] as GenericAtRule)
13+
);
14+
15+
it('has a name', () => expect(node.name).toBe('at-root'));
16+
17+
it('has no paramsInterpolation', () =>
18+
expect(node.paramsInterpolation).toBeUndefined());
19+
20+
it('has no params', () => expect(node.params).toBe(''));
21+
});
22+
23+
describe('with no interpolation', () => {
24+
beforeEach(
25+
() =>
26+
void (node = scss.parse('@at-root (with: rule) {}')
27+
.nodes[0] as GenericAtRule)
28+
);
29+
30+
it('has a name', () => expect(node.name).toBe('at-root'));
31+
32+
it('has a paramsInterpolation', () =>
33+
expect(node).toHaveInterpolation('paramsInterpolation', '(with: rule)'));
34+
35+
it('has matching params', () => expect(node.params).toBe('(with: rule)'));
36+
});
37+
38+
// TODO: test a variable used directly without interpolation
39+
40+
describe('with interpolation', () => {
41+
beforeEach(
42+
() =>
43+
void (node = scss.parse('@at-root (with: #{rule}) {}')
44+
.nodes[0] as GenericAtRule)
45+
);
46+
47+
it('has a name', () => expect(node.name).toBe('at-root'));
48+
49+
it('has a paramsInterpolation', () => {
50+
const params = node.paramsInterpolation!;
51+
expect(params.nodes[0]).toBe('(with: ');
52+
expect(params).toHaveStringExpression(1, 'rule');
53+
expect(params.nodes[2]).toBe(')');
54+
});
55+
56+
it('has matching params', () =>
57+
expect(node.params).toBe('(with: #{rule})'));
58+
});
59+
60+
describe('with style rule shorthand', () => {
61+
beforeEach(
62+
() =>
63+
void (node = scss.parse('@at-root .foo {}').nodes[0] as GenericAtRule)
64+
);
65+
66+
it('has a name', () => expect(node.name).toBe('at-root'));
67+
68+
it('has no paramsInterpolation', () =>
69+
expect(node.paramsInterpolation).toBeUndefined());
70+
71+
it('has no params', () => expect(node.params).toBe(''));
72+
73+
it('contains a Rule', () => {
74+
const rule = node.nodes[0] as Rule;
75+
expect(rule).toHaveInterpolation('selectorInterpolation', '.foo ');
76+
expect(rule.parent).toBe(node);
77+
});
78+
});
79+
80+
describe('stringifies', () => {
81+
describe('to SCSS', () => {
82+
it('with atRootShorthand: false', () =>
83+
expect(
84+
new GenericAtRule({
85+
name: 'at-root',
86+
nodes: [{selector: '.foo'}],
87+
raws: {atRootShorthand: false},
88+
}).toString()
89+
).toBe('@at-root {\n .foo {}\n}'));
90+
91+
describe('with atRootShorthand: true', () => {
92+
it('with no params and only a style rule child', () =>
93+
expect(
94+
new GenericAtRule({
95+
name: 'at-root',
96+
nodes: [{selector: '.foo'}],
97+
raws: {atRootShorthand: true},
98+
}).toString()
99+
).toBe('@at-root .foo {}'));
100+
101+
it('with no params and multiple children', () =>
102+
expect(
103+
new GenericAtRule({
104+
name: 'at-root',
105+
nodes: [{selector: '.foo'}, {selector: '.bar'}],
106+
raws: {atRootShorthand: true},
107+
}).toString()
108+
).toBe('@at-root {\n .foo {}\n .bar {}\n}'));
109+
110+
it('with no params and a non-style-rule child', () =>
111+
expect(
112+
new GenericAtRule({
113+
name: 'at-root',
114+
nodes: [{name: 'foo'}],
115+
raws: {atRootShorthand: true},
116+
}).toString()
117+
).toBe('@at-root {\n @foo\n}'));
118+
119+
it('with params and only a style rule child', () =>
120+
expect(
121+
new GenericAtRule({
122+
name: 'at-root',
123+
params: '(with: rule)',
124+
nodes: [{selector: '.foo'}],
125+
raws: {atRootShorthand: true},
126+
}).toString()
127+
).toBe('@at-root (with: rule) {\n .foo {}\n}'));
128+
129+
it("that's not @at-root", () =>
130+
expect(
131+
new GenericAtRule({
132+
name: 'at-wrong',
133+
nodes: [{selector: '.foo'}],
134+
raws: {atRootShorthand: true},
135+
}).toString()
136+
).toBe('@at-wrong {\n .foo {}\n}'));
137+
});
138+
});
139+
});
140+
});

‎pkg/sass-parser/lib/src/statement/at-rule-internal.d.ts

-2
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,6 @@ import {AtRule, ChildNode, Comment, Declaration, NewNode} from '.';
1515
* @hidden
1616
*/
1717
export class _AtRule<Props> extends postcss.AtRule {
18-
declare nodes: ChildNode[];
19-
2018
// Override the PostCSS container types to constrain them to Sass types only.
2119
// Unfortunately, there's no way to abstract this out, because anything
2220
// mixin-like returns an intersection type which doesn't actually override
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
// Copyright 2024 Google Inc. Use of this source code is governed by an
2+
// MIT-style license that can be found in the LICENSE file or at
3+
// https://opensource.org/licenses/MIT.
4+
5+
import * as postcss from 'postcss';
6+
7+
import {Root} from './root';
8+
import {ChildNode, NewNode} from '.';
9+
10+
/**
11+
* A fake intermediate class to convince TypeScript to use Sass types for
12+
* various upstream methods.
13+
*
14+
* @hidden
15+
*/
16+
export class _Comment<Props> extends postcss.Comment {
17+
// Override the PostCSS types to constrain them to Sass types only.
18+
// Unfortunately, there's no way to abstract this out, because anything
19+
// mixin-like returns an intersection type which doesn't actually override
20+
// parent methods. See microsoft/TypeScript#59394.
21+
22+
after(newNode: NewNode): this;
23+
assign(overrides: Partial<Props>): this;
24+
before(newNode: NewNode): this;
25+
cloneAfter(overrides?: Partial<Props>): this;
26+
cloneBefore(overrides?: Partial<Props>): this;
27+
next(): ChildNode | undefined;
28+
prev(): ChildNode | undefined;
29+
replaceWith(...nodes: NewNode[]): this;
30+
root(): Root;
31+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
// Copyright 2024 Google Inc. Use of this source code is governed by an
2+
// MIT-style license that can be found in the LICENSE file or at
3+
// https://opensource.org/licenses/MIT.
4+
5+
exports._Comment = require('postcss').Comment;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,325 @@
1+
// Copyright 2024 Google Inc. Use of this source code is governed by an
2+
// MIT-style license that can be found in the LICENSE file or at
3+
// https://opensource.org/licenses/MIT.
4+
5+
import {CssComment, Interpolation, Root, css, sass, scss} from '../..';
6+
import * as utils from '../../../test/utils';
7+
8+
describe('a CSS-style comment', () => {
9+
let node: CssComment;
10+
function describeNode(description: string, create: () => CssComment): void {
11+
describe(description, () => {
12+
beforeEach(() => void (node = create()));
13+
14+
it('has type comment', () => expect(node.type).toBe('comment'));
15+
16+
it('has sassType comment', () => expect(node.sassType).toBe('comment'));
17+
18+
it('has matching textInterpolation', () =>
19+
expect(node).toHaveInterpolation('textInterpolation', 'foo'));
20+
21+
it('has matching text', () => expect(node.text).toBe('foo'));
22+
});
23+
}
24+
25+
describeNode(
26+
'parsed as SCSS',
27+
() => scss.parse('/* foo */').nodes[0] as CssComment
28+
);
29+
30+
describeNode(
31+
'parsed as CSS',
32+
() => css.parse('/* foo */').nodes[0] as CssComment
33+
);
34+
35+
describeNode(
36+
'parsed as Sass',
37+
() => sass.parse('/* foo').nodes[0] as CssComment
38+
);
39+
40+
describe('constructed manually', () => {
41+
describeNode(
42+
'with an interpolation',
43+
() =>
44+
new CssComment({
45+
textInterpolation: new Interpolation({nodes: ['foo']}),
46+
})
47+
);
48+
49+
describeNode('with a text string', () => new CssComment({text: 'foo'}));
50+
});
51+
52+
describe('constructed from ChildProps', () => {
53+
describeNode('with an interpolation', () =>
54+
utils.fromChildProps({
55+
textInterpolation: new Interpolation({nodes: ['foo']}),
56+
})
57+
);
58+
59+
describeNode('with a text string', () =>
60+
utils.fromChildProps({text: 'foo'})
61+
);
62+
});
63+
64+
describe('parses raws', () => {
65+
describe('in SCSS', () => {
66+
it('with whitespace before and after text', () =>
67+
expect((scss.parse('/* foo */').nodes[0] as CssComment).raws).toEqual({
68+
left: ' ',
69+
right: ' ',
70+
closed: true,
71+
}));
72+
73+
it('with whitespace before and after interpolation', () =>
74+
expect(
75+
(scss.parse('/* #{foo} */').nodes[0] as CssComment).raws
76+
).toEqual({left: ' ', right: ' ', closed: true}));
77+
78+
it('without whitespace before and after text', () =>
79+
expect((scss.parse('/*foo*/').nodes[0] as CssComment).raws).toEqual({
80+
left: '',
81+
right: '',
82+
closed: true,
83+
}));
84+
85+
it('without whitespace before and after interpolation', () =>
86+
expect((scss.parse('/*#{foo}*/').nodes[0] as CssComment).raws).toEqual({
87+
left: '',
88+
right: '',
89+
closed: true,
90+
}));
91+
92+
it('with whitespace and no text', () =>
93+
expect((scss.parse('/* */').nodes[0] as CssComment).raws).toEqual({
94+
left: ' ',
95+
right: '',
96+
closed: true,
97+
}));
98+
99+
it('with no whitespace and no text', () =>
100+
expect((scss.parse('/**/').nodes[0] as CssComment).raws).toEqual({
101+
left: '',
102+
right: '',
103+
closed: true,
104+
}));
105+
});
106+
107+
describe('in Sass', () => {
108+
// TODO: Test explicit whitespace after text and interpolation once we
109+
// properly parse raws from somewhere other than the original text.
110+
111+
it('with whitespace before text', () =>
112+
expect((sass.parse('/* foo').nodes[0] as CssComment).raws).toEqual({
113+
left: ' ',
114+
right: '',
115+
closed: false,
116+
}));
117+
118+
it('with whitespace before interpolation', () =>
119+
expect((sass.parse('/* #{foo}').nodes[0] as CssComment).raws).toEqual({
120+
left: ' ',
121+
right: '',
122+
closed: false,
123+
}));
124+
125+
it('without whitespace before and after text', () =>
126+
expect((sass.parse('/*foo').nodes[0] as CssComment).raws).toEqual({
127+
left: '',
128+
right: '',
129+
closed: false,
130+
}));
131+
132+
it('without whitespace before and after interpolation', () =>
133+
expect((sass.parse('/*#{foo}').nodes[0] as CssComment).raws).toEqual({
134+
left: '',
135+
right: '',
136+
closed: false,
137+
}));
138+
139+
it('with no whitespace and no text', () =>
140+
expect((sass.parse('/*').nodes[0] as CssComment).raws).toEqual({
141+
left: '',
142+
right: '',
143+
closed: false,
144+
}));
145+
146+
it('with a trailing */', () =>
147+
expect((sass.parse('/* foo */').nodes[0] as CssComment).raws).toEqual({
148+
left: ' ',
149+
right: ' ',
150+
closed: true,
151+
}));
152+
});
153+
});
154+
155+
describe('stringifies', () => {
156+
describe('to SCSS', () => {
157+
it('with default raws', () =>
158+
expect(new CssComment({text: 'foo'}).toString()).toBe('/* foo */'));
159+
160+
it('with left', () =>
161+
expect(
162+
new CssComment({
163+
text: 'foo',
164+
raws: {left: '\n'},
165+
}).toString()
166+
).toBe('/*\nfoo */'));
167+
168+
it('with right', () =>
169+
expect(
170+
new CssComment({
171+
text: 'foo',
172+
raws: {right: '\n'},
173+
}).toString()
174+
).toBe('/* foo\n*/'));
175+
176+
it('with before', () =>
177+
expect(
178+
new Root({
179+
nodes: [new CssComment({text: 'foo', raws: {before: '/**/'}})],
180+
}).toString()
181+
).toBe('/**//* foo */'));
182+
});
183+
});
184+
185+
describe('assigned new text', () => {
186+
beforeEach(() => {
187+
node = scss.parse('/* foo */').nodes[0] as CssComment;
188+
});
189+
190+
it("removes the old text's parent", () => {
191+
const oldText = node.textInterpolation!;
192+
node.textInterpolation = 'bar';
193+
expect(oldText.parent).toBeUndefined();
194+
});
195+
196+
it("assigns the new interpolation's parent", () => {
197+
const interpolation = new Interpolation({nodes: ['bar']});
198+
node.textInterpolation = interpolation;
199+
expect(interpolation.parent).toBe(node);
200+
});
201+
202+
it('assigns the interpolation explicitly', () => {
203+
const interpolation = new Interpolation({nodes: ['bar']});
204+
node.textInterpolation = interpolation;
205+
expect(node.textInterpolation).toBe(interpolation);
206+
});
207+
208+
it('assigns the interpolation as a string', () => {
209+
node.textInterpolation = 'bar';
210+
expect(node).toHaveInterpolation('textInterpolation', 'bar');
211+
});
212+
213+
it('assigns the interpolation as text', () => {
214+
node.text = 'bar';
215+
expect(node).toHaveInterpolation('textInterpolation', 'bar');
216+
});
217+
});
218+
219+
describe('clone', () => {
220+
let original: CssComment;
221+
beforeEach(
222+
() => void (original = scss.parse('/* foo */').nodes[0] as CssComment)
223+
);
224+
225+
describe('with no overrides', () => {
226+
let clone: CssComment;
227+
beforeEach(() => {
228+
clone = original.clone();
229+
});
230+
231+
describe('has the same properties:', () => {
232+
it('textInterpolation', () =>
233+
expect(clone).toHaveInterpolation('textInterpolation', 'foo'));
234+
235+
it('text', () => expect(clone.text).toBe('foo'));
236+
237+
it('raws', () =>
238+
expect(clone.raws).toEqual({left: ' ', right: ' ', closed: true}));
239+
240+
it('source', () => expect(clone.source).toBe(original.source));
241+
});
242+
243+
describe('creates a new', () => {
244+
it('self', () => expect(clone).not.toBe(original));
245+
246+
for (const attr of ['textInterpolation', 'raws'] as const) {
247+
it(attr, () => expect(clone[attr]).not.toBe(original[attr]));
248+
}
249+
});
250+
});
251+
252+
describe('overrides', () => {
253+
describe('text', () => {
254+
describe('defined', () => {
255+
let clone: CssComment;
256+
beforeEach(() => {
257+
clone = original.clone({text: 'bar'});
258+
});
259+
260+
it('changes text', () => expect(clone.text).toBe('bar'));
261+
262+
it('changes textInterpolation', () =>
263+
expect(clone).toHaveInterpolation('textInterpolation', 'bar'));
264+
});
265+
266+
describe('undefined', () => {
267+
let clone: CssComment;
268+
beforeEach(() => {
269+
clone = original.clone({text: undefined});
270+
});
271+
272+
it('preserves text', () => expect(clone.text).toBe('foo'));
273+
274+
it('preserves textInterpolation', () =>
275+
expect(clone).toHaveInterpolation('textInterpolation', 'foo'));
276+
});
277+
});
278+
279+
describe('textInterpolation', () => {
280+
describe('defined', () => {
281+
let clone: CssComment;
282+
beforeEach(() => {
283+
clone = original.clone({
284+
textInterpolation: new Interpolation({nodes: ['baz']}),
285+
});
286+
});
287+
288+
it('changes text', () => expect(clone.text).toBe('baz'));
289+
290+
it('changes textInterpolation', () =>
291+
expect(clone).toHaveInterpolation('textInterpolation', 'baz'));
292+
});
293+
294+
describe('undefined', () => {
295+
let clone: CssComment;
296+
beforeEach(() => {
297+
clone = original.clone({textInterpolation: undefined});
298+
});
299+
300+
it('preserves text', () => expect(clone.text).toBe('foo'));
301+
302+
it('preserves textInterpolation', () =>
303+
expect(clone).toHaveInterpolation('textInterpolation', 'foo'));
304+
});
305+
});
306+
307+
describe('raws', () => {
308+
it('defined', () =>
309+
expect(original.clone({raws: {right: ' '}}).raws).toEqual({
310+
right: ' ',
311+
}));
312+
313+
it('undefined', () =>
314+
expect(original.clone({raws: undefined}).raws).toEqual({
315+
left: ' ',
316+
right: ' ',
317+
closed: true,
318+
}));
319+
});
320+
});
321+
});
322+
323+
it('toJSON', () =>
324+
expect(scss.parse('/* foo */').nodes[0]).toMatchSnapshot());
325+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
// Copyright 2024 Google Inc. Use of this source code is governed by an
2+
// MIT-style license that can be found in the LICENSE file or at
3+
// https://opensource.org/licenses/MIT.
4+
5+
import * as postcss from 'postcss';
6+
import type {CommentRaws} from 'postcss/lib/comment';
7+
8+
import {convertExpression} from '../expression/convert';
9+
import {LazySource} from '../lazy-source';
10+
import type * as sassInternal from '../sass-internal';
11+
import {Interpolation} from '../interpolation';
12+
import * as utils from '../utils';
13+
import {ContainerProps, Statement, StatementWithChildren} from '.';
14+
import {_Comment} from './comment-internal';
15+
import {interceptIsClean} from './intercept-is-clean';
16+
import * as sassParser from '../..';
17+
18+
/**
19+
* The set of raws supported by {@link CssComment}.
20+
*
21+
* @category Statement
22+
*/
23+
export interface CssCommentRaws extends CommentRaws {
24+
/**
25+
* In the indented syntax, this indicates whether a comment is explicitly
26+
* closed with a `*\/`. It's ignored in other syntaxes.
27+
*
28+
* It defaults to false.
29+
*/
30+
closed?: boolean;
31+
}
32+
33+
/**
34+
* The initializer properties for {@link CssComment}.
35+
*
36+
* @category Statement
37+
*/
38+
export type CssCommentProps = ContainerProps & {
39+
raws?: CssCommentRaws;
40+
} & ({text: string} | {textInterpolation: Interpolation | string});
41+
42+
/**
43+
* A CSS-style "loud" comment. Extends [`postcss.Comment`].
44+
*
45+
* [`postcss.Comment`]: https://postcss.org/api/#comment
46+
*
47+
* @category Statement
48+
*/
49+
export class CssComment
50+
extends _Comment<Partial<CssCommentProps>>
51+
implements Statement
52+
{
53+
readonly sassType = 'comment' as const;
54+
declare parent: StatementWithChildren | undefined;
55+
declare raws: CssCommentRaws;
56+
57+
get text(): string {
58+
return this.textInterpolation.toString();
59+
}
60+
set text(value: string) {
61+
this.textInterpolation = value;
62+
}
63+
64+
/** The interpolation that represents this selector's contents. */
65+
get textInterpolation(): Interpolation {
66+
return this._textInterpolation!;
67+
}
68+
set textInterpolation(textInterpolation: Interpolation | string) {
69+
// TODO - postcss/postcss#1957: Mark this as dirty
70+
if (this._textInterpolation) {
71+
this._textInterpolation.parent = undefined;
72+
}
73+
if (typeof textInterpolation === 'string') {
74+
textInterpolation = new Interpolation({
75+
nodes: [textInterpolation],
76+
});
77+
}
78+
textInterpolation.parent = this;
79+
this._textInterpolation = textInterpolation;
80+
}
81+
private _textInterpolation?: Interpolation;
82+
83+
constructor(defaults: CssCommentProps);
84+
/** @hidden */
85+
constructor(_: undefined, inner: sassInternal.LoudComment);
86+
constructor(defaults?: CssCommentProps, inner?: sassInternal.LoudComment) {
87+
super(defaults as unknown as postcss.CommentProps);
88+
89+
if (inner) {
90+
this.source = new LazySource(inner);
91+
const nodes = [...inner.text.contents];
92+
93+
// The interpolation's contents are guaranteed to begin with a string,
94+
// because Sass includes the `/*`.
95+
let first = nodes[0] as string;
96+
const firstMatch = first.match(/^\/\*([ \t\n\r\f]*)/)!;
97+
this.raws.left ??= firstMatch[1];
98+
first = first.substring(firstMatch[0].length);
99+
if (first.length === 0) {
100+
nodes.shift();
101+
} else {
102+
nodes[0] = first;
103+
}
104+
105+
// The interpolation will end with `*/` in SCSS, but not necessarily in
106+
// the indented syntax.
107+
let last = nodes.at(-1);
108+
if (typeof last === 'string') {
109+
const lastMatch = last.match(/([ \t\n\r\f]*)\*\/$/);
110+
this.raws.right ??= lastMatch?.[1] ?? '';
111+
this.raws.closed = !!lastMatch;
112+
if (lastMatch) {
113+
last = last.substring(0, last.length - lastMatch[0].length);
114+
if (last.length === 0) {
115+
nodes.pop();
116+
} else {
117+
nodes[0] = last;
118+
}
119+
}
120+
} else {
121+
this.raws.right ??= '';
122+
this.raws.closed = false;
123+
}
124+
125+
this.textInterpolation = new Interpolation();
126+
for (const child of nodes) {
127+
this.textInterpolation.append(
128+
typeof child === 'string' ? child : convertExpression(child)
129+
);
130+
}
131+
}
132+
}
133+
134+
clone(overrides?: Partial<CssCommentProps>): this {
135+
return utils.cloneNode(
136+
this,
137+
overrides,
138+
['raws', 'textInterpolation'],
139+
['text']
140+
);
141+
}
142+
143+
toJSON(): object;
144+
/** @hidden */
145+
toJSON(_: string, inputs: Map<postcss.Input, number>): object;
146+
toJSON(_?: string, inputs?: Map<postcss.Input, number>): object {
147+
return utils.toJSON(this, ['text', 'textInterpolation'], inputs);
148+
}
149+
150+
/** @hidden */
151+
toString(
152+
stringifier: postcss.Stringifier | postcss.Syntax = sassParser.scss
153+
.stringify
154+
): string {
155+
return super.toString(stringifier);
156+
}
157+
158+
/** @hidden */
159+
get nonStatementChildren(): ReadonlyArray<Interpolation> {
160+
return [this.textInterpolation];
161+
}
162+
}
163+
164+
interceptIsClean(CssComment);
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
// Copyright 2024 Google Inc. Use of this source code is governed by an
2+
// MIT-style license that can be found in the LICENSE file or at
3+
// https://opensource.org/licenses/MIT.
4+
5+
import {DebugRule, StringExpression, sass, scss} from '../..';
6+
import * as utils from '../../../test/utils';
7+
8+
describe('a @debug rule', () => {
9+
let node: DebugRule;
10+
function describeNode(description: string, create: () => DebugRule): void {
11+
describe(description, () => {
12+
beforeEach(() => void (node = create()));
13+
14+
it('has a name', () => expect(node.name.toString()).toBe('debug'));
15+
16+
it('has an expression', () =>
17+
expect(node).toHaveStringExpression('debugExpression', 'foo'));
18+
19+
it('has matching params', () => expect(node.params).toBe('foo'));
20+
21+
it('has undefined nodes', () => expect(node.nodes).toBeUndefined());
22+
});
23+
}
24+
25+
describeNode(
26+
'parsed as SCSS',
27+
() => scss.parse('@debug foo').nodes[0] as DebugRule
28+
);
29+
30+
describeNode(
31+
'parsed as Sass',
32+
() => sass.parse('@debug foo').nodes[0] as DebugRule
33+
);
34+
35+
describeNode(
36+
'constructed manually',
37+
() =>
38+
new DebugRule({
39+
debugExpression: {text: 'foo'},
40+
})
41+
);
42+
43+
describeNode('constructed from ChildProps', () =>
44+
utils.fromChildProps({
45+
debugExpression: {text: 'foo'},
46+
})
47+
);
48+
49+
it('throws an error when assigned a new name', () =>
50+
expect(
51+
() =>
52+
(new DebugRule({
53+
debugExpression: {text: 'foo'},
54+
}).name = 'bar')
55+
).toThrow());
56+
57+
describe('assigned a new expression', () => {
58+
beforeEach(() => {
59+
node = scss.parse('@debug foo').nodes[0] as DebugRule;
60+
});
61+
62+
it('sets an empty string expression as undefined params', () => {
63+
node.params = undefined;
64+
expect(node.params).toBe('');
65+
expect(node).toHaveStringExpression('debugExpression', '');
66+
});
67+
68+
it('sets an empty string expression as empty string params', () => {
69+
node.params = '';
70+
expect(node.params).toBe('');
71+
expect(node).toHaveStringExpression('debugExpression', '');
72+
});
73+
74+
it("removes the old expression's parent", () => {
75+
const oldExpression = node.debugExpression;
76+
node.debugExpression = {text: 'bar'};
77+
expect(oldExpression.parent).toBeUndefined();
78+
});
79+
80+
it("assigns the new expression's parent", () => {
81+
const expression = new StringExpression({text: 'bar'});
82+
node.debugExpression = expression;
83+
expect(expression.parent).toBe(node);
84+
});
85+
86+
it('assigns the expression explicitly', () => {
87+
const expression = new StringExpression({text: 'bar'});
88+
node.debugExpression = expression;
89+
expect(node.debugExpression).toBe(expression);
90+
});
91+
92+
it('assigns the expression as ExpressionProps', () => {
93+
node.debugExpression = {text: 'bar'};
94+
expect(node).toHaveStringExpression('debugExpression', 'bar');
95+
});
96+
97+
it('assigns the expression as params', () => {
98+
node.params = 'bar';
99+
expect(node).toHaveStringExpression('debugExpression', 'bar');
100+
});
101+
});
102+
103+
describe('stringifies', () => {
104+
describe('to SCSS', () => {
105+
it('with default raws', () =>
106+
expect(
107+
new DebugRule({
108+
debugExpression: {text: 'foo'},
109+
}).toString()
110+
).toBe('@debug foo;'));
111+
112+
it('with afterName', () =>
113+
expect(
114+
new DebugRule({
115+
debugExpression: {text: 'foo'},
116+
raws: {afterName: '/**/'},
117+
}).toString()
118+
).toBe('@debug/**/foo;'));
119+
120+
it('with between', () =>
121+
expect(
122+
new DebugRule({
123+
debugExpression: {text: 'foo'},
124+
raws: {between: '/**/'},
125+
}).toString()
126+
).toBe('@debug foo/**/;'));
127+
});
128+
});
129+
130+
describe('clone', () => {
131+
let original: DebugRule;
132+
beforeEach(() => {
133+
original = scss.parse('@debug foo').nodes[0] as DebugRule;
134+
// TODO: remove this once raws are properly parsed
135+
original.raws.between = ' ';
136+
});
137+
138+
describe('with no overrides', () => {
139+
let clone: DebugRule;
140+
beforeEach(() => void (clone = original.clone()));
141+
142+
describe('has the same properties:', () => {
143+
it('params', () => expect(clone.params).toBe('foo'));
144+
145+
it('debugExpression', () =>
146+
expect(clone).toHaveStringExpression('debugExpression', 'foo'));
147+
148+
it('raws', () => expect(clone.raws).toEqual({between: ' '}));
149+
150+
it('source', () => expect(clone.source).toBe(original.source));
151+
});
152+
153+
describe('creates a new', () => {
154+
it('self', () => expect(clone).not.toBe(original));
155+
156+
for (const attr of ['debugExpression', 'raws'] as const) {
157+
it(attr, () => expect(clone[attr]).not.toBe(original[attr]));
158+
}
159+
});
160+
});
161+
162+
describe('overrides', () => {
163+
describe('raws', () => {
164+
it('defined', () =>
165+
expect(original.clone({raws: {afterName: ' '}}).raws).toEqual({
166+
afterName: ' ',
167+
}));
168+
169+
it('undefined', () =>
170+
expect(original.clone({raws: undefined}).raws).toEqual({
171+
between: ' ',
172+
}));
173+
});
174+
175+
describe('debugExpression', () => {
176+
describe('defined', () => {
177+
let clone: DebugRule;
178+
beforeEach(() => {
179+
clone = original.clone({debugExpression: {text: 'bar'}});
180+
});
181+
182+
it('changes params', () => expect(clone.params).toBe('bar'));
183+
184+
it('changes debugExpression', () =>
185+
expect(clone).toHaveStringExpression('debugExpression', 'bar'));
186+
});
187+
188+
describe('undefined', () => {
189+
let clone: DebugRule;
190+
beforeEach(() => {
191+
clone = original.clone({debugExpression: undefined});
192+
});
193+
194+
it('preserves params', () => expect(clone.params).toBe('foo'));
195+
196+
it('preserves debugExpression', () =>
197+
expect(clone).toHaveStringExpression('debugExpression', 'foo'));
198+
});
199+
});
200+
});
201+
});
202+
203+
it('toJSON', () =>
204+
expect(scss.parse('@debug foo').nodes[0]).toMatchSnapshot());
205+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
// Copyright 2024 Google Inc. Use of this source code is governed by an
2+
// MIT-style license that can be found in the LICENSE file or at
3+
// https://opensource.org/licenses/MIT.
4+
5+
import * as postcss from 'postcss';
6+
import type {AtRuleRaws as PostcssAtRuleRaws} from 'postcss/lib/at-rule';
7+
8+
import {convertExpression} from '../expression/convert';
9+
import {Expression, ExpressionProps} from '../expression';
10+
import {fromProps} from '../expression/from-props';
11+
import {LazySource} from '../lazy-source';
12+
import type * as sassInternal from '../sass-internal';
13+
import * as utils from '../utils';
14+
import {Statement, StatementWithChildren} from '.';
15+
import {_AtRule} from './at-rule-internal';
16+
import {interceptIsClean} from './intercept-is-clean';
17+
import * as sassParser from '../..';
18+
19+
/**
20+
* The set of raws supported by {@link DebugRule}.
21+
*
22+
* @category Statement
23+
*/
24+
export type DebugRuleRaws = Pick<
25+
PostcssAtRuleRaws,
26+
'afterName' | 'before' | 'between'
27+
>;
28+
29+
/**
30+
* The initializer properties for {@link DebugRule}.
31+
*
32+
* @category Statement
33+
*/
34+
export type DebugRuleProps = postcss.NodeProps & {
35+
raws?: DebugRuleRaws;
36+
debugExpression: Expression | ExpressionProps;
37+
};
38+
39+
/**
40+
* A `@debug` rule. Extends [`postcss.AtRule`].
41+
*
42+
* [`postcss.AtRule`]: https://postcss.org/api/#atrule
43+
*
44+
* @category Statement
45+
*/
46+
export class DebugRule
47+
extends _AtRule<Partial<DebugRuleProps>>
48+
implements Statement
49+
{
50+
readonly sassType = 'debug-rule' as const;
51+
declare parent: StatementWithChildren | undefined;
52+
declare raws: DebugRuleRaws;
53+
declare readonly nodes: undefined;
54+
55+
get name(): string {
56+
return 'debug';
57+
}
58+
set name(value: string) {
59+
throw new Error("DebugRule.name can't be overwritten.");
60+
}
61+
62+
get params(): string {
63+
return this.debugExpression.toString();
64+
}
65+
set params(value: string | number | undefined) {
66+
this.debugExpression = {text: value?.toString() ?? ''};
67+
}
68+
69+
/** The expresison whose value is emitted when the debug rule is executed. */
70+
get debugExpression(): Expression {
71+
return this._debugExpression!;
72+
}
73+
set debugExpression(debugExpression: Expression | ExpressionProps) {
74+
if (this._debugExpression) this._debugExpression.parent = undefined;
75+
if (!('sassType' in debugExpression)) {
76+
debugExpression = fromProps(debugExpression);
77+
}
78+
if (debugExpression) debugExpression.parent = this;
79+
this._debugExpression = debugExpression;
80+
}
81+
private _debugExpression?: Expression;
82+
83+
constructor(defaults: DebugRuleProps);
84+
/** @hidden */
85+
constructor(_: undefined, inner: sassInternal.DebugRule);
86+
constructor(defaults?: DebugRuleProps, inner?: sassInternal.DebugRule) {
87+
super(defaults as unknown as postcss.AtRuleProps);
88+
89+
if (inner) {
90+
this.source = new LazySource(inner);
91+
this.debugExpression = convertExpression(inner.expression);
92+
}
93+
}
94+
95+
clone(overrides?: Partial<DebugRuleProps>): this {
96+
return utils.cloneNode(
97+
this,
98+
overrides,
99+
['raws', 'debugExpression'],
100+
[{name: 'params', explicitUndefined: true}]
101+
);
102+
}
103+
104+
toJSON(): object;
105+
/** @hidden */
106+
toJSON(_: string, inputs: Map<postcss.Input, number>): object;
107+
toJSON(_?: string, inputs?: Map<postcss.Input, number>): object {
108+
return utils.toJSON(
109+
this,
110+
['name', 'debugExpression', 'params', 'nodes'],
111+
inputs
112+
);
113+
}
114+
115+
/** @hidden */
116+
toString(
117+
stringifier: postcss.Stringifier | postcss.Syntax = sassParser.scss
118+
.stringify
119+
): string {
120+
return super.toString(stringifier);
121+
}
122+
123+
/** @hidden */
124+
get nonStatementChildren(): ReadonlyArray<Expression> {
125+
return [this.debugExpression];
126+
}
127+
}
128+
129+
interceptIsClean(DebugRule);
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,302 @@
1+
// Copyright 2024 Google Inc. Use of this source code is governed by an
2+
// MIT-style license that can be found in the LICENSE file or at
3+
// https://opensource.org/licenses/MIT.
4+
5+
import {EachRule, GenericAtRule, StringExpression, sass, scss} from '../..';
6+
import * as utils from '../../../test/utils';
7+
8+
describe('an @each rule', () => {
9+
let node: EachRule;
10+
describe('with empty children', () => {
11+
function describeNode(description: string, create: () => EachRule): void {
12+
describe(description, () => {
13+
beforeEach(() => void (node = create()));
14+
15+
it('has a name', () => expect(node.name.toString()).toBe('each'));
16+
17+
it('has variables', () =>
18+
expect(node.variables).toEqual(['foo', 'bar']));
19+
20+
it('has an expression', () =>
21+
expect(node).toHaveStringExpression('eachExpression', 'baz'));
22+
23+
it('has matching params', () =>
24+
expect(node.params).toBe('$foo, $bar in baz'));
25+
26+
it('has empty nodes', () => expect(node.nodes).toEqual([]));
27+
});
28+
}
29+
30+
describeNode(
31+
'parsed as SCSS',
32+
() => scss.parse('@each $foo, $bar in baz {}').nodes[0] as EachRule
33+
);
34+
35+
describeNode(
36+
'parsed as Sass',
37+
() => sass.parse('@each $foo, $bar in baz').nodes[0] as EachRule
38+
);
39+
40+
describeNode(
41+
'constructed manually',
42+
() =>
43+
new EachRule({
44+
variables: ['foo', 'bar'],
45+
eachExpression: {text: 'baz'},
46+
})
47+
);
48+
49+
describeNode('constructed from ChildProps', () =>
50+
utils.fromChildProps({
51+
variables: ['foo', 'bar'],
52+
eachExpression: {text: 'baz'},
53+
})
54+
);
55+
});
56+
57+
describe('with a child', () => {
58+
function describeNode(description: string, create: () => EachRule): void {
59+
describe(description, () => {
60+
beforeEach(() => void (node = create()));
61+
62+
it('has a name', () => expect(node.name.toString()).toBe('each'));
63+
64+
it('has variables', () =>
65+
expect(node.variables).toEqual(['foo', 'bar']));
66+
67+
it('has an expression', () =>
68+
expect(node).toHaveStringExpression('eachExpression', 'baz'));
69+
70+
it('has matching params', () =>
71+
expect(node.params).toBe('$foo, $bar in baz'));
72+
73+
it('has a child node', () => {
74+
expect(node.nodes).toHaveLength(1);
75+
expect(node.nodes[0]).toBeInstanceOf(GenericAtRule);
76+
expect(node.nodes[0]).toHaveProperty('name', 'child');
77+
});
78+
});
79+
}
80+
81+
describeNode(
82+
'parsed as SCSS',
83+
() => scss.parse('@each $foo, $bar in baz {@child}').nodes[0] as EachRule
84+
);
85+
86+
describeNode(
87+
'parsed as Sass',
88+
() => sass.parse('@each $foo, $bar in baz\n @child').nodes[0] as EachRule
89+
);
90+
91+
describeNode(
92+
'constructed manually',
93+
() =>
94+
new EachRule({
95+
variables: ['foo', 'bar'],
96+
eachExpression: {text: 'baz'},
97+
nodes: [{name: 'child'}],
98+
})
99+
);
100+
101+
describeNode('constructed from ChildProps', () =>
102+
utils.fromChildProps({
103+
variables: ['foo', 'bar'],
104+
eachExpression: {text: 'baz'},
105+
nodes: [{name: 'child'}],
106+
})
107+
);
108+
});
109+
110+
describe('throws an error when assigned a new', () => {
111+
beforeEach(
112+
() =>
113+
void (node = new EachRule({
114+
variables: ['foo', 'bar'],
115+
eachExpression: {text: 'baz'},
116+
}))
117+
);
118+
119+
it('name', () => expect(() => (node.name = 'qux')).toThrow());
120+
121+
it('params', () =>
122+
expect(() => (node.params = '$zip, $zap in qux')).toThrow());
123+
});
124+
125+
describe('assigned a new expression', () => {
126+
beforeEach(() => {
127+
node = scss.parse('@each $foo, $bar in baz {}').nodes[0] as EachRule;
128+
});
129+
130+
it("removes the old expression's parent", () => {
131+
const oldExpression = node.eachExpression;
132+
node.eachExpression = {text: 'qux'};
133+
expect(oldExpression.parent).toBeUndefined();
134+
});
135+
136+
it("assigns the new expression's parent", () => {
137+
const expression = new StringExpression({text: 'qux'});
138+
node.eachExpression = expression;
139+
expect(expression.parent).toBe(node);
140+
});
141+
142+
it('assigns the expression explicitly', () => {
143+
const expression = new StringExpression({text: 'qux'});
144+
node.eachExpression = expression;
145+
expect(node.eachExpression).toBe(expression);
146+
});
147+
148+
it('assigns the expression as ExpressionProps', () => {
149+
node.eachExpression = {text: 'qux'};
150+
expect(node).toHaveStringExpression('eachExpression', 'qux');
151+
});
152+
});
153+
154+
describe('stringifies', () => {
155+
describe('to SCSS', () => {
156+
it('with default raws', () =>
157+
expect(
158+
new EachRule({
159+
variables: ['foo', 'bar'],
160+
eachExpression: {text: 'baz'},
161+
}).toString()
162+
).toBe('@each $foo, $bar in baz {}'));
163+
164+
it('with afterName', () =>
165+
expect(
166+
new EachRule({
167+
variables: ['foo', 'bar'],
168+
eachExpression: {text: 'baz'},
169+
raws: {afterName: '/**/'},
170+
}).toString()
171+
).toBe('@each/**/$foo, $bar in baz {}'));
172+
173+
it('with afterVariables', () =>
174+
expect(
175+
new EachRule({
176+
variables: ['foo', 'bar'],
177+
eachExpression: {text: 'baz'},
178+
raws: {afterVariables: ['/**/,', '/* */']},
179+
}).toString()
180+
).toBe('@each $foo/**/,$bar/* */in baz {}'));
181+
182+
it('with afterIn', () =>
183+
expect(
184+
new EachRule({
185+
variables: ['foo', 'bar'],
186+
eachExpression: {text: 'baz'},
187+
raws: {afterIn: '/**/'},
188+
}).toString()
189+
).toBe('@each $foo, $bar in/**/baz {}'));
190+
});
191+
});
192+
193+
describe('clone', () => {
194+
let original: EachRule;
195+
beforeEach(() => {
196+
original = scss.parse('@each $foo, $bar in baz {}').nodes[0] as EachRule;
197+
// TODO: remove this once raws are properly parsed
198+
original.raws.between = ' ';
199+
});
200+
201+
describe('with no overrides', () => {
202+
let clone: EachRule;
203+
beforeEach(() => void (clone = original.clone()));
204+
205+
describe('has the same properties:', () => {
206+
it('params', () => expect(clone.params).toBe('$foo, $bar in baz'));
207+
208+
it('variables', () => expect(clone.variables).toEqual(['foo', 'bar']));
209+
210+
it('eachExpression', () =>
211+
expect(clone).toHaveStringExpression('eachExpression', 'baz'));
212+
213+
it('raws', () => expect(clone.raws).toEqual({between: ' '}));
214+
215+
it('source', () => expect(clone.source).toBe(original.source));
216+
});
217+
218+
describe('creates a new', () => {
219+
it('self', () => expect(clone).not.toBe(original));
220+
221+
for (const attr of ['variables', 'eachExpression', 'raws'] as const) {
222+
it(attr, () => expect(clone[attr]).not.toBe(original[attr]));
223+
}
224+
});
225+
});
226+
227+
describe('overrides', () => {
228+
describe('raws', () => {
229+
it('defined', () =>
230+
expect(original.clone({raws: {afterName: ' '}}).raws).toEqual({
231+
afterName: ' ',
232+
}));
233+
234+
it('undefined', () =>
235+
expect(original.clone({raws: undefined}).raws).toEqual({
236+
between: ' ',
237+
}));
238+
});
239+
240+
describe('variables', () => {
241+
describe('defined', () => {
242+
let clone: EachRule;
243+
beforeEach(() => {
244+
clone = original.clone({variables: ['zip', 'zap']});
245+
});
246+
247+
it('changes params', () =>
248+
expect(clone.params).toBe('$zip, $zap in baz'));
249+
250+
it('changes variables', () =>
251+
expect(clone.variables).toEqual(['zip', 'zap']));
252+
});
253+
254+
describe('undefined', () => {
255+
let clone: EachRule;
256+
beforeEach(() => {
257+
clone = original.clone({variables: undefined});
258+
});
259+
260+
it('preserves params', () =>
261+
expect(clone.params).toBe('$foo, $bar in baz'));
262+
263+
it('preserves variables', () =>
264+
expect(clone.variables).toEqual(['foo', 'bar']));
265+
});
266+
});
267+
268+
describe('eachExpression', () => {
269+
describe('defined', () => {
270+
let clone: EachRule;
271+
beforeEach(() => {
272+
clone = original.clone({eachExpression: {text: 'qux'}});
273+
});
274+
275+
it('changes params', () =>
276+
expect(clone.params).toBe('$foo, $bar in qux'));
277+
278+
it('changes eachExpression', () =>
279+
expect(clone).toHaveStringExpression('eachExpression', 'qux'));
280+
});
281+
282+
describe('undefined', () => {
283+
let clone: EachRule;
284+
beforeEach(() => {
285+
clone = original.clone({eachExpression: undefined});
286+
});
287+
288+
it('preserves params', () =>
289+
expect(clone.params).toBe('$foo, $bar in baz'));
290+
291+
it('preserves eachExpression', () =>
292+
expect(clone).toHaveStringExpression('eachExpression', 'baz'));
293+
});
294+
});
295+
});
296+
});
297+
298+
it('toJSON', () =>
299+
expect(
300+
scss.parse('@each $foo, $bar in baz {}').nodes[0]
301+
).toMatchSnapshot());
302+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
// Copyright 2024 Google Inc. Use of this source code is governed by an
2+
// MIT-style license that can be found in the LICENSE file or at
3+
// https://opensource.org/licenses/MIT.
4+
5+
import * as postcss from 'postcss';
6+
import type {AtRuleRaws} from 'postcss/lib/at-rule';
7+
8+
import {convertExpression} from '../expression/convert';
9+
import {Expression, ExpressionProps} from '../expression';
10+
import {fromProps} from '../expression/from-props';
11+
import {LazySource} from '../lazy-source';
12+
import type * as sassInternal from '../sass-internal';
13+
import * as utils from '../utils';
14+
import {
15+
ChildNode,
16+
ContainerProps,
17+
NewNode,
18+
Statement,
19+
StatementWithChildren,
20+
appendInternalChildren,
21+
normalize,
22+
} from '.';
23+
import {_AtRule} from './at-rule-internal';
24+
import {interceptIsClean} from './intercept-is-clean';
25+
import * as sassParser from '../..';
26+
27+
/**
28+
* The set of raws supported by {@link EachRule}.
29+
*
30+
* @category Statement
31+
*/
32+
export interface EachRuleRaws extends Omit<AtRuleRaws, 'params'> {
33+
/**
34+
* The whitespace and commas after each variable in
35+
* {@link EachRule.variables}.
36+
*
37+
* The element at index `i` is included after the variable at index `i`. Any
38+
* elements beyond `variables.length` are ignored.
39+
*/
40+
afterVariables?: string[];
41+
42+
/** The whitespace between `in` and {@link EachRule.eachExpression}. */
43+
afterIn?: string;
44+
}
45+
46+
/**
47+
* The initializer properties for {@link EachRule}.
48+
*
49+
* @category Statement
50+
*/
51+
export type EachRuleProps = ContainerProps & {
52+
raws?: EachRuleRaws;
53+
variables: string[];
54+
eachExpression: Expression | ExpressionProps;
55+
};
56+
57+
/**
58+
* An `@each` rule. Extends [`postcss.AtRule`].
59+
*
60+
* [`postcss.AtRule`]: https://postcss.org/api/#atrule
61+
*
62+
* @category Statement
63+
*/
64+
export class EachRule
65+
extends _AtRule<Partial<EachRuleProps>>
66+
implements Statement
67+
{
68+
readonly sassType = 'each-rule' as const;
69+
declare parent: StatementWithChildren | undefined;
70+
declare raws: EachRuleRaws;
71+
declare nodes: ChildNode[];
72+
73+
/** The variable names assigned for each iteration, without `"$"`. */
74+
declare variables: string[];
75+
76+
get name(): string {
77+
return 'each';
78+
}
79+
set name(value: string) {
80+
throw new Error("EachRule.name can't be overwritten.");
81+
}
82+
83+
get params(): string {
84+
let result = '';
85+
for (let i = 0; i < this.variables.length; i++) {
86+
result +=
87+
'$' +
88+
this.variables[i] +
89+
(this.raws?.afterVariables?.[i] ??
90+
(i === this.variables.length - 1 ? ' ' : ', '));
91+
}
92+
return `${result}in${this.raws.afterIn ?? ' '}${this.eachExpression}`;
93+
}
94+
set params(value: string | number | undefined) {
95+
throw new Error("EachRule.params can't be overwritten.");
96+
}
97+
98+
/** The expresison whose value is iterated over. */
99+
get eachExpression(): Expression {
100+
return this._eachExpression!;
101+
}
102+
set eachExpression(eachExpression: Expression | ExpressionProps) {
103+
if (this._eachExpression) this._eachExpression.parent = undefined;
104+
if (!('sassType' in eachExpression)) {
105+
eachExpression = fromProps(eachExpression);
106+
}
107+
if (eachExpression) eachExpression.parent = this;
108+
this._eachExpression = eachExpression;
109+
}
110+
private _eachExpression?: Expression;
111+
112+
constructor(defaults: EachRuleProps);
113+
/** @hidden */
114+
constructor(_: undefined, inner: sassInternal.EachRule);
115+
constructor(defaults?: EachRuleProps, inner?: sassInternal.EachRule) {
116+
super(defaults as unknown as postcss.AtRuleProps);
117+
this.nodes ??= [];
118+
119+
if (inner) {
120+
this.source = new LazySource(inner);
121+
this.variables = [...inner.variables];
122+
this.eachExpression = convertExpression(inner.list);
123+
appendInternalChildren(this, inner.children);
124+
}
125+
}
126+
127+
clone(overrides?: Partial<EachRuleProps>): this {
128+
return utils.cloneNode(this, overrides, [
129+
'raws',
130+
'variables',
131+
'eachExpression',
132+
]);
133+
}
134+
135+
toJSON(): object;
136+
/** @hidden */
137+
toJSON(_: string, inputs: Map<postcss.Input, number>): object;
138+
toJSON(_?: string, inputs?: Map<postcss.Input, number>): object {
139+
return utils.toJSON(
140+
this,
141+
['name', 'variables', 'eachExpression', 'params', 'nodes'],
142+
inputs
143+
);
144+
}
145+
146+
/** @hidden */
147+
toString(
148+
stringifier: postcss.Stringifier | postcss.Syntax = sassParser.scss
149+
.stringify
150+
): string {
151+
return super.toString(stringifier);
152+
}
153+
154+
/** @hidden */
155+
get nonStatementChildren(): ReadonlyArray<Expression> {
156+
return [this.eachExpression];
157+
}
158+
159+
/** @hidden */
160+
normalize(node: NewNode, sample?: postcss.Node): ChildNode[] {
161+
return normalize(this, node, sample);
162+
}
163+
}
164+
165+
interceptIsClean(EachRule);
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
// Copyright 2024 Google Inc. Use of this source code is governed by an
2+
// MIT-style license that can be found in the LICENSE file or at
3+
// https://opensource.org/licenses/MIT.
4+
5+
import {ErrorRule, StringExpression, sass, scss} from '../..';
6+
import * as utils from '../../../test/utils';
7+
8+
describe('a @error rule', () => {
9+
let node: ErrorRule;
10+
function describeNode(description: string, create: () => ErrorRule): void {
11+
describe(description, () => {
12+
beforeEach(() => void (node = create()));
13+
14+
it('has a name', () => expect(node.name.toString()).toBe('error'));
15+
16+
it('has an expression', () =>
17+
expect(node).toHaveStringExpression('errorExpression', 'foo'));
18+
19+
it('has matching params', () => expect(node.params).toBe('foo'));
20+
21+
it('has undefined nodes', () => expect(node.nodes).toBeUndefined());
22+
});
23+
}
24+
25+
describeNode(
26+
'parsed as SCSS',
27+
() => scss.parse('@error foo').nodes[0] as ErrorRule
28+
);
29+
30+
describeNode(
31+
'parsed as Sass',
32+
() => sass.parse('@error foo').nodes[0] as ErrorRule
33+
);
34+
35+
describeNode(
36+
'constructed manually',
37+
() =>
38+
new ErrorRule({
39+
errorExpression: {text: 'foo'},
40+
})
41+
);
42+
43+
describeNode('constructed from ChildProps', () =>
44+
utils.fromChildProps({
45+
errorExpression: {text: 'foo'},
46+
})
47+
);
48+
49+
it('throws an error when assigned a new name', () =>
50+
expect(
51+
() =>
52+
(new ErrorRule({
53+
errorExpression: {text: 'foo'},
54+
}).name = 'bar')
55+
).toThrow());
56+
57+
describe('assigned a new expression', () => {
58+
beforeEach(() => {
59+
node = scss.parse('@error foo').nodes[0] as ErrorRule;
60+
});
61+
62+
it('sets an empty string expression as undefined params', () => {
63+
node.params = undefined;
64+
expect(node.params).toBe('');
65+
expect(node).toHaveStringExpression('errorExpression', '');
66+
});
67+
68+
it('sets an empty string expression as empty string params', () => {
69+
node.params = '';
70+
expect(node.params).toBe('');
71+
expect(node).toHaveStringExpression('errorExpression', '');
72+
});
73+
74+
it("removes the old expression's parent", () => {
75+
const oldExpression = node.errorExpression;
76+
node.errorExpression = {text: 'bar'};
77+
expect(oldExpression.parent).toBeUndefined();
78+
});
79+
80+
it("assigns the new expression's parent", () => {
81+
const expression = new StringExpression({text: 'bar'});
82+
node.errorExpression = expression;
83+
expect(expression.parent).toBe(node);
84+
});
85+
86+
it('assigns the expression explicitly', () => {
87+
const expression = new StringExpression({text: 'bar'});
88+
node.errorExpression = expression;
89+
expect(node.errorExpression).toBe(expression);
90+
});
91+
92+
it('assigns the expression as ExpressionProps', () => {
93+
node.errorExpression = {text: 'bar'};
94+
expect(node).toHaveStringExpression('errorExpression', 'bar');
95+
});
96+
97+
it('assigns the expression as params', () => {
98+
node.params = 'bar';
99+
expect(node).toHaveStringExpression('errorExpression', 'bar');
100+
});
101+
});
102+
103+
describe('stringifies', () => {
104+
describe('to SCSS', () => {
105+
it('with default raws', () =>
106+
expect(
107+
new ErrorRule({
108+
errorExpression: {text: 'foo'},
109+
}).toString()
110+
).toBe('@error foo;'));
111+
112+
it('with afterName', () =>
113+
expect(
114+
new ErrorRule({
115+
errorExpression: {text: 'foo'},
116+
raws: {afterName: '/**/'},
117+
}).toString()
118+
).toBe('@error/**/foo;'));
119+
120+
it('with between', () =>
121+
expect(
122+
new ErrorRule({
123+
errorExpression: {text: 'foo'},
124+
raws: {between: '/**/'},
125+
}).toString()
126+
).toBe('@error foo/**/;'));
127+
});
128+
});
129+
130+
describe('clone', () => {
131+
let original: ErrorRule;
132+
beforeEach(() => {
133+
original = scss.parse('@error foo').nodes[0] as ErrorRule;
134+
// TODO: remove this once raws are properly parsed
135+
original.raws.between = ' ';
136+
});
137+
138+
describe('with no overrides', () => {
139+
let clone: ErrorRule;
140+
beforeEach(() => void (clone = original.clone()));
141+
142+
describe('has the same properties:', () => {
143+
it('params', () => expect(clone.params).toBe('foo'));
144+
145+
it('errorExpression', () =>
146+
expect(clone).toHaveStringExpression('errorExpression', 'foo'));
147+
148+
it('raws', () => expect(clone.raws).toEqual({between: ' '}));
149+
150+
it('source', () => expect(clone.source).toBe(original.source));
151+
});
152+
153+
describe('creates a new', () => {
154+
it('self', () => expect(clone).not.toBe(original));
155+
156+
for (const attr of ['errorExpression', 'raws'] as const) {
157+
it(attr, () => expect(clone[attr]).not.toBe(original[attr]));
158+
}
159+
});
160+
});
161+
162+
describe('overrides', () => {
163+
describe('raws', () => {
164+
it('defined', () =>
165+
expect(original.clone({raws: {afterName: ' '}}).raws).toEqual({
166+
afterName: ' ',
167+
}));
168+
169+
it('undefined', () =>
170+
expect(original.clone({raws: undefined}).raws).toEqual({
171+
between: ' ',
172+
}));
173+
});
174+
175+
describe('errorExpression', () => {
176+
describe('defined', () => {
177+
let clone: ErrorRule;
178+
beforeEach(() => {
179+
clone = original.clone({errorExpression: {text: 'bar'}});
180+
});
181+
182+
it('changes params', () => expect(clone.params).toBe('bar'));
183+
184+
it('changes errorExpression', () =>
185+
expect(clone).toHaveStringExpression('errorExpression', 'bar'));
186+
});
187+
188+
describe('undefined', () => {
189+
let clone: ErrorRule;
190+
beforeEach(() => {
191+
clone = original.clone({errorExpression: undefined});
192+
});
193+
194+
it('preserves params', () => expect(clone.params).toBe('foo'));
195+
196+
it('preserves errorExpression', () =>
197+
expect(clone).toHaveStringExpression('errorExpression', 'foo'));
198+
});
199+
});
200+
});
201+
});
202+
203+
it('toJSON', () =>
204+
expect(scss.parse('@error foo').nodes[0]).toMatchSnapshot());
205+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
// Copyright 2024 Google Inc. Use of this source code is governed by an
2+
// MIT-style license that can be found in the LICENSE file or at
3+
// https://opensource.org/licenses/MIT.
4+
5+
import * as postcss from 'postcss';
6+
import type {AtRuleRaws as PostcssAtRuleRaws} from 'postcss/lib/at-rule';
7+
8+
import {convertExpression} from '../expression/convert';
9+
import {Expression, ExpressionProps} from '../expression';
10+
import {fromProps} from '../expression/from-props';
11+
import {LazySource} from '../lazy-source';
12+
import type * as sassInternal from '../sass-internal';
13+
import * as utils from '../utils';
14+
import {Statement, StatementWithChildren} from '.';
15+
import {_AtRule} from './at-rule-internal';
16+
import {interceptIsClean} from './intercept-is-clean';
17+
import * as sassParser from '../..';
18+
19+
/**
20+
* The set of raws supported by {@link ErrorRule}.
21+
*
22+
* @category Statement
23+
*/
24+
export type ErrorRuleRaws = Pick<
25+
PostcssAtRuleRaws,
26+
'afterName' | 'before' | 'between'
27+
>;
28+
29+
/**
30+
* The initializer properties for {@link ErrorRule}.
31+
*
32+
* @category Statement
33+
*/
34+
export type ErrorRuleProps = postcss.NodeProps & {
35+
raws?: ErrorRuleRaws;
36+
errorExpression: Expression | ExpressionProps;
37+
};
38+
39+
/**
40+
* An `@error` rule. Extends [`postcss.AtRule`].
41+
*
42+
* [`postcss.AtRule`]: https://postcss.org/api/#atrule
43+
*
44+
* @category Statement
45+
*/
46+
export class ErrorRule
47+
extends _AtRule<Partial<ErrorRuleProps>>
48+
implements Statement
49+
{
50+
readonly sassType = 'error-rule' as const;
51+
declare parent: StatementWithChildren | undefined;
52+
declare raws: ErrorRuleRaws;
53+
declare readonly nodes: undefined;
54+
55+
get name(): string {
56+
return 'error';
57+
}
58+
set name(value: string) {
59+
throw new Error("ErrorRule.name can't be overwritten.");
60+
}
61+
62+
get params(): string {
63+
return this.errorExpression.toString();
64+
}
65+
set params(value: string | number | undefined) {
66+
this.errorExpression = {text: value?.toString() ?? ''};
67+
}
68+
69+
/** The expresison whose value is thrown when the error rule is executed. */
70+
get errorExpression(): Expression {
71+
return this._errorExpression!;
72+
}
73+
set errorExpression(errorExpression: Expression | ExpressionProps) {
74+
if (this._errorExpression) this._errorExpression.parent = undefined;
75+
if (!('sassType' in errorExpression)) {
76+
errorExpression = fromProps(errorExpression);
77+
}
78+
if (errorExpression) errorExpression.parent = this;
79+
this._errorExpression = errorExpression;
80+
}
81+
private _errorExpression?: Expression;
82+
83+
constructor(defaults: ErrorRuleProps);
84+
/** @hidden */
85+
constructor(_: undefined, inner: sassInternal.ErrorRule);
86+
constructor(defaults?: ErrorRuleProps, inner?: sassInternal.ErrorRule) {
87+
super(defaults as unknown as postcss.AtRuleProps);
88+
89+
if (inner) {
90+
this.source = new LazySource(inner);
91+
this.errorExpression = convertExpression(inner.expression);
92+
}
93+
}
94+
95+
clone(overrides?: Partial<ErrorRuleProps>): this {
96+
return utils.cloneNode(
97+
this,
98+
overrides,
99+
['raws', 'errorExpression'],
100+
[{name: 'params', explicitUndefined: true}]
101+
);
102+
}
103+
104+
toJSON(): object;
105+
/** @hidden */
106+
toJSON(_: string, inputs: Map<postcss.Input, number>): object;
107+
toJSON(_?: string, inputs?: Map<postcss.Input, number>): object {
108+
return utils.toJSON(
109+
this,
110+
['name', 'errorExpression', 'params', 'nodes'],
111+
inputs
112+
);
113+
}
114+
115+
/** @hidden */
116+
toString(
117+
stringifier: postcss.Stringifier | postcss.Syntax = sassParser.scss
118+
.stringify
119+
): string {
120+
return super.toString(stringifier);
121+
}
122+
123+
/** @hidden */
124+
get nonStatementChildren(): ReadonlyArray<Expression> {
125+
return [this.errorExpression];
126+
}
127+
}
128+
129+
interceptIsClean(ErrorRule);
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
// Copyright 2024 Google Inc. Use of this source code is governed by an
2+
// MIT-style license that can be found in the LICENSE file or at
3+
// https://opensource.org/licenses/MIT.
4+
5+
import {GenericAtRule, Rule, scss} from '../..';
6+
7+
describe('an @extend rule', () => {
8+
let node: GenericAtRule;
9+
10+
describe('with no interpolation', () => {
11+
beforeEach(
12+
() =>
13+
void (node = (scss.parse('.foo {@extend .bar}').nodes[0] as Rule)
14+
.nodes[0] as GenericAtRule)
15+
);
16+
17+
it('has a name', () => expect(node.name).toBe('extend'));
18+
19+
it('has a paramsInterpolation', () =>
20+
expect(node).toHaveInterpolation('paramsInterpolation', '.bar'));
21+
22+
it('has matching params', () => expect(node.params).toBe('.bar'));
23+
});
24+
25+
describe('with interpolation', () => {
26+
beforeEach(
27+
() =>
28+
void (node = (scss.parse('.foo {@extend .#{bar}}').nodes[0] as Rule)
29+
.nodes[0] as GenericAtRule)
30+
);
31+
32+
it('has a name', () => expect(node.name).toBe('extend'));
33+
34+
it('has a paramsInterpolation', () => {
35+
const params = node.paramsInterpolation!;
36+
expect(params.nodes[0]).toBe('.');
37+
expect(params).toHaveStringExpression(1, 'bar');
38+
});
39+
40+
it('has matching params', () => expect(node.params).toBe('.#{bar}'));
41+
});
42+
43+
describe('with !optional', () => {
44+
beforeEach(
45+
() =>
46+
void (node = (
47+
scss.parse('.foo {@extend .bar !optional}').nodes[0] as Rule
48+
).nodes[0] as GenericAtRule)
49+
);
50+
51+
it('has a name', () => expect(node.name).toBe('extend'));
52+
53+
it('has a paramsInterpolation', () =>
54+
expect(node).toHaveInterpolation(
55+
'paramsInterpolation',
56+
'.bar !optional'
57+
));
58+
59+
it('has matching params', () => expect(node.params).toBe('.bar !optional'));
60+
});
61+
});

‎pkg/sass-parser/lib/src/statement/for-rule.test.ts

+437
Large diffs are not rendered by default.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
1+
// Copyright 2024 Google Inc. Use of this source code is governed by an
2+
// MIT-style license that can be found in the LICENSE file or at
3+
// https://opensource.org/licenses/MIT.
4+
5+
import * as postcss from 'postcss';
6+
import type {AtRuleRaws} from 'postcss/lib/at-rule';
7+
8+
import {convertExpression} from '../expression/convert';
9+
import {Expression, ExpressionProps} from '../expression';
10+
import {fromProps} from '../expression/from-props';
11+
import {LazySource} from '../lazy-source';
12+
import type * as sassInternal from '../sass-internal';
13+
import * as utils from '../utils';
14+
import {
15+
ChildNode,
16+
ContainerProps,
17+
NewNode,
18+
Statement,
19+
StatementWithChildren,
20+
appendInternalChildren,
21+
normalize,
22+
} from '.';
23+
import {_AtRule} from './at-rule-internal';
24+
import {interceptIsClean} from './intercept-is-clean';
25+
import * as sassParser from '../..';
26+
27+
/**
28+
* The set of raws supported by {@link ForRule}.
29+
*
30+
* @category Statement
31+
*/
32+
export interface ForRuleRaws extends Omit<AtRuleRaws, 'params'> {
33+
/** The whitespace after {@link ForRule.variable}. */
34+
afterVariable?: string;
35+
36+
/** The whitespace after a {@link ForRule}'s `from` keyword. */
37+
afterFrom?: string;
38+
39+
/** The whitespace after {@link ForRule.fromExpression}. */
40+
afterFromExpression?: string;
41+
42+
/** The whitespace after a {@link ForRule}'s `to` or `through` keyword. */
43+
afterTo?: string;
44+
}
45+
46+
/**
47+
* The initializer properties for {@link ForRule}.
48+
*
49+
* @category Statement
50+
*/
51+
export type ForRuleProps = ContainerProps & {
52+
raws?: ForRuleRaws;
53+
variable: string;
54+
fromExpression: Expression | ExpressionProps;
55+
toExpression: Expression | ExpressionProps;
56+
to?: 'to' | 'through';
57+
};
58+
59+
/**
60+
* A `@for` rule. Extends [`postcss.AtRule`].
61+
*
62+
* [`postcss.AtRule`]: https://postcss.org/api/#atrule
63+
*
64+
* @category Statement
65+
*/
66+
export class ForRule
67+
extends _AtRule<Partial<ForRuleProps>>
68+
implements Statement
69+
{
70+
readonly sassType = 'for-rule' as const;
71+
declare parent: StatementWithChildren | undefined;
72+
declare raws: ForRuleRaws;
73+
declare nodes: ChildNode[];
74+
75+
/** The variabl names assigned for for iteration, without `"$"`. */
76+
declare variable: string;
77+
78+
/**
79+
* The keyword that appears before {@link toExpression}.
80+
*
81+
* If this is `"to"`, the loop is exclusive; if it's `"through"`, the loop is
82+
* inclusive. It defaults to `"to"` when creating a new `ForRule`.
83+
*/
84+
declare to: 'to' | 'through';
85+
86+
get name(): string {
87+
return 'for';
88+
}
89+
set name(value: string) {
90+
throw new Error("ForRule.name can't be overwritten.");
91+
}
92+
93+
get params(): string {
94+
return (
95+
`$${this.variable}${this.raws.afterVariable ?? ' '}from` +
96+
`${this.raws.afterFrom ?? ' '}${this.fromExpression}` +
97+
`${this.raws.afterFromExpression ?? ' '}${this.to}` +
98+
`${this.raws.afterTo ?? ' '}${this.toExpression}`
99+
);
100+
}
101+
set params(value: string | number | undefined) {
102+
throw new Error("ForRule.params can't be overwritten.");
103+
}
104+
105+
/** The expresison whose value is the starting point of the iteration. */
106+
get fromExpression(): Expression {
107+
return this._fromExpression!;
108+
}
109+
set fromExpression(fromExpression: Expression | ExpressionProps) {
110+
if (this._fromExpression) this._fromExpression.parent = undefined;
111+
if (!('sassType' in fromExpression)) {
112+
fromExpression = fromProps(fromExpression);
113+
}
114+
if (fromExpression) fromExpression.parent = this;
115+
this._fromExpression = fromExpression;
116+
}
117+
private _fromExpression?: Expression;
118+
119+
/** The expresison whose value is the ending point of the iteration. */
120+
get toExpression(): Expression {
121+
return this._toExpression!;
122+
}
123+
set toExpression(toExpression: Expression | ExpressionProps) {
124+
if (this._toExpression) this._toExpression.parent = undefined;
125+
if (!('sassType' in toExpression)) {
126+
toExpression = fromProps(toExpression);
127+
}
128+
if (toExpression) toExpression.parent = this;
129+
this._toExpression = toExpression;
130+
}
131+
private _toExpression?: Expression;
132+
133+
constructor(defaults: ForRuleProps);
134+
/** @hidden */
135+
constructor(_: undefined, inner: sassInternal.ForRule);
136+
constructor(defaults?: ForRuleProps, inner?: sassInternal.ForRule) {
137+
super(defaults as unknown as postcss.AtRuleProps);
138+
this.nodes ??= [];
139+
140+
if (inner) {
141+
this.source = new LazySource(inner);
142+
this.variable = inner.variable;
143+
this.to = inner.isExclusive ? 'to' : 'through';
144+
this.fromExpression = convertExpression(inner.from);
145+
this.toExpression = convertExpression(inner.to);
146+
appendInternalChildren(this, inner.children);
147+
}
148+
149+
this.to ??= 'to';
150+
}
151+
152+
clone(overrides?: Partial<ForRuleProps>): this {
153+
return utils.cloneNode(this, overrides, [
154+
'raws',
155+
'variable',
156+
'to',
157+
'fromExpression',
158+
'toExpression',
159+
]);
160+
}
161+
162+
toJSON(): object;
163+
/** @hidden */
164+
toJSON(_: string, inputs: Map<postcss.Input, number>): object;
165+
toJSON(_?: string, inputs?: Map<postcss.Input, number>): object {
166+
return utils.toJSON(
167+
this,
168+
[
169+
'name',
170+
'variable',
171+
'to',
172+
'fromExpression',
173+
'toExpression',
174+
'params',
175+
'nodes',
176+
],
177+
inputs
178+
);
179+
}
180+
181+
/** @hidden */
182+
toString(
183+
stringifier: postcss.Stringifier | postcss.Syntax = sassParser.scss
184+
.stringify
185+
): string {
186+
return super.toString(stringifier);
187+
}
188+
189+
/** @hidden */
190+
get nonStatementChildren(): ReadonlyArray<Expression> {
191+
return [this.fromExpression, this.toExpression];
192+
}
193+
194+
/** @hidden */
195+
normalize(node: NewNode, sample?: postcss.Node): ChildNode[] {
196+
return normalize(this, node, sample);
197+
}
198+
}
199+
200+
interceptIsClean(ForRule);

‎pkg/sass-parser/lib/src/statement/generic-at-rule.ts

+12-3
Original file line numberDiff line numberDiff line change
@@ -26,12 +26,20 @@ import * as sassParser from '../..';
2626
/**
2727
* The set of raws supported by {@link GenericAtRule}.
2828
*
29-
* Sass doesn't support PostCSS's `params` raws, since the param interpolation
30-
* is lexed and made directly available to the caller.
29+
* Sass doesn't support PostCSS's `params` raws, since
30+
* {@link GenericAtRule.paramInterpolation} has its own raws.
3131
*
3232
* @category Statement
3333
*/
34-
export type GenericAtRuleRaws = Omit<PostcssAtRuleRaws, 'params'>;
34+
export interface GenericAtRuleRaws extends Omit<PostcssAtRuleRaws, 'params'> {
35+
/**
36+
* Whether to collapse the nesting for an `@at-root` with no params that
37+
* contains only a single style rule.
38+
*
39+
* This is ignored for rules that don't meet all of those criteria.
40+
*/
41+
atRootShorthand?: boolean;
42+
}
3543

3644
/**
3745
* The initializer properties for {@link GenericAtRule}.
@@ -64,6 +72,7 @@ export class GenericAtRule
6472
readonly sassType = 'atrule' as const;
6573
declare parent: StatementWithChildren | undefined;
6674
declare raws: GenericAtRuleRaws;
75+
declare nodes: ChildNode[];
6776

6877
get name(): string {
6978
return this.nameInterpolation.toString();

‎pkg/sass-parser/lib/src/statement/index.ts

+72-7
Original file line numberDiff line numberDiff line change
@@ -4,22 +4,29 @@
44

55
import * as postcss from 'postcss';
66

7+
import {Interpolation} from '../interpolation';
8+
import {LazySource} from '../lazy-source';
79
import {Node, NodeProps} from '../node';
810
import * as sassInternal from '../sass-internal';
11+
import {CssComment, CssCommentProps} from './css-comment';
912
import {GenericAtRule, GenericAtRuleProps} from './generic-at-rule';
13+
import {DebugRule, DebugRuleProps} from './debug-rule';
14+
import {EachRule, EachRuleProps} from './each-rule';
15+
import {ErrorRule, ErrorRuleProps} from './error-rule';
16+
import {ForRule, ForRuleProps} from './for-rule';
1017
import {Root} from './root';
1118
import {Rule, RuleProps} from './rule';
1219

1320
// TODO: Replace this with the corresponding Sass types once they're
1421
// implemented.
15-
export {Comment, Declaration} from 'postcss';
22+
export {Declaration} from 'postcss';
1623

1724
/**
1825
* The union type of all Sass statements.
1926
*
2027
* @category Statement
2128
*/
22-
export type AnyStatement = Root | Rule | GenericAtRule;
29+
export type AnyStatement = Comment | Root | Rule | GenericAtRule;
2330

2431
/**
2532
* Sass statement types.
@@ -30,14 +37,29 @@ export type AnyStatement = Root | Rule | GenericAtRule;
3037
*
3138
* @category Statement
3239
*/
33-
export type StatementType = 'root' | 'rule' | 'atrule';
40+
export type StatementType =
41+
| 'root'
42+
| 'rule'
43+
| 'atrule'
44+
| 'comment'
45+
| 'debug-rule'
46+
| 'each-rule'
47+
| 'for-rule'
48+
| 'error-rule';
3449

3550
/**
3651
* All Sass statements that are also at-rules.
3752
*
3853
* @category Statement
3954
*/
40-
export type AtRule = GenericAtRule;
55+
export type AtRule = DebugRule | EachRule | ErrorRule | ForRule | GenericAtRule;
56+
57+
/**
58+
* All Sass statements that are comments.
59+
*
60+
* @category Statement
61+
*/
62+
export type Comment = CssComment;
4163

4264
/**
4365
* All Sass statements that are valid children of other statements.
@@ -46,7 +68,7 @@ export type AtRule = GenericAtRule;
4668
*
4769
* @category Statement
4870
*/
49-
export type ChildNode = Rule | AtRule;
71+
export type ChildNode = Rule | AtRule | Comment;
5072

5173
/**
5274
* The properties that can be used to construct {@link ChildNode}s.
@@ -55,7 +77,15 @@ export type ChildNode = Rule | AtRule;
5577
*
5678
* @category Statement
5779
*/
58-
export type ChildProps = postcss.ChildProps | RuleProps | GenericAtRuleProps;
80+
export type ChildProps =
81+
| postcss.ChildProps
82+
| CssCommentProps
83+
| DebugRuleProps
84+
| EachRuleProps
85+
| ErrorRuleProps
86+
| ForRuleProps
87+
| GenericAtRuleProps
88+
| RuleProps;
5989

6090
/**
6191
* The Sass eqivalent of PostCSS's `ContainerProps`.
@@ -93,7 +123,32 @@ export interface Statement extends postcss.Node, Node {
93123

94124
/** The visitor to use to convert internal Sass nodes to JS. */
95125
const visitor = sassInternal.createStatementVisitor<Statement>({
126+
visitAtRootRule: inner => {
127+
const rule = new GenericAtRule({
128+
name: 'at-root',
129+
paramsInterpolation: inner.query
130+
? new Interpolation(undefined, inner.query)
131+
: undefined,
132+
source: new LazySource(inner),
133+
});
134+
appendInternalChildren(rule, inner.children);
135+
return rule;
136+
},
96137
visitAtRule: inner => new GenericAtRule(undefined, inner),
138+
visitDebugRule: inner => new DebugRule(undefined, inner),
139+
visitErrorRule: inner => new ErrorRule(undefined, inner),
140+
visitEachRule: inner => new EachRule(undefined, inner),
141+
visitForRule: inner => new ForRule(undefined, inner),
142+
visitExtendRule: inner => {
143+
const paramsInterpolation = new Interpolation(undefined, inner.selector);
144+
if (inner.isOptional) paramsInterpolation.append('!optional');
145+
return new GenericAtRule({
146+
name: 'extend',
147+
paramsInterpolation,
148+
source: new LazySource(inner),
149+
});
150+
},
151+
visitLoudComment: inner => new CssComment(undefined, inner),
97152
visitStyleRule: inner => new Rule(undefined, inner),
98153
});
99154

@@ -196,7 +251,17 @@ export function normalize(
196251
) {
197252
result.push(new Rule(node));
198253
} else if ('name' in node || 'nameInterpolation' in node) {
199-
result.push(new GenericAtRule(node));
254+
result.push(new GenericAtRule(node as GenericAtRuleProps));
255+
} else if ('debugExpression' in node) {
256+
result.push(new DebugRule(node));
257+
} else if ('eachExpression' in node) {
258+
result.push(new EachRule(node));
259+
} else if ('fromExpression' in node) {
260+
result.push(new ForRule(node));
261+
} else if ('errorExpression' in node) {
262+
result.push(new ErrorRule(node));
263+
} else if ('text' in node || 'textInterpolation' in node) {
264+
result.push(new CssComment(node as CssCommentProps));
200265
} else {
201266
result.push(...postcssNormalizeAndConvertToSass(self, node, sample));
202267
}

‎pkg/sass-parser/lib/src/stringifier.ts

+63
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,9 @@
2929
import * as postcss from 'postcss';
3030

3131
import {AnyStatement} from './statement';
32+
import {DebugRule} from './statement/debug-rule';
33+
import {EachRule} from './statement/each-rule';
34+
import {ErrorRule} from './statement/error-rule';
3235
import {GenericAtRule} from './statement/generic-at-rule';
3336
import {Rule} from './statement/rule';
3437

@@ -67,7 +70,67 @@ export class Stringifier extends PostCssStringifier {
6770
)(statement, semicolon);
6871
}
6972

73+
private ['debug-rule'](node: DebugRule, semicolon: boolean): void {
74+
this.builder(
75+
'@debug' +
76+
(node.raws.afterName ?? ' ') +
77+
node.debugExpression +
78+
(node.raws.between ?? '') +
79+
(semicolon ? ';' : ''),
80+
node
81+
);
82+
}
83+
84+
private ['each-rule'](node: EachRule): void {
85+
this.block(
86+
node,
87+
'@each' +
88+
(node.raws.afterName ?? ' ') +
89+
node.params +
90+
(node.raws.between ?? '')
91+
);
92+
}
93+
94+
private ['error-rule'](node: ErrorRule, semicolon: boolean): void {
95+
this.builder(
96+
'@error' +
97+
(node.raws.afterName ?? ' ') +
98+
node.errorExpression +
99+
(node.raws.between ?? '') +
100+
(semicolon ? ';' : ''),
101+
node
102+
);
103+
}
104+
105+
private ['for-rule'](node: EachRule): void {
106+
this.block(
107+
node,
108+
'@for' +
109+
(node.raws.afterName ?? ' ') +
110+
node.params +
111+
(node.raws.between ?? '')
112+
);
113+
}
114+
70115
private atrule(node: GenericAtRule, semicolon: boolean): void {
116+
// In the @at-root shorthand, stringify `@at-root {.foo {...}}` as
117+
// `@at-root .foo {...}`.
118+
if (
119+
node.raws.atRootShorthand &&
120+
node.name === 'at-root' &&
121+
node.paramsInterpolation === undefined &&
122+
node.nodes.length === 1 &&
123+
node.nodes[0].sassType === 'rule'
124+
) {
125+
this.block(
126+
node.nodes[0],
127+
'@at-root' +
128+
(node.raws.afterName ?? ' ') +
129+
node.nodes[0].selectorInterpolation
130+
);
131+
return;
132+
}
133+
71134
const start =
72135
`@${node.nameInterpolation}` +
73136
(node.raws.afterName ?? (node.paramsInterpolation ? ' ' : '')) +

‎pkg/sass_api/CHANGELOG.md

+3
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@
22

33
* Remove the `CallableDeclaration()` constructor.
44

5+
* Loud comments in the Sass syntax no longer automatically inject ` */` to the
6+
end when parsed.
7+
58
## 10.4.8
69

710
* No user-visible changes.

0 commit comments

Comments
 (0)
Please sign in to comment.