Skip to content

Commit 0df0a86

Browse files
committed
Add support for @each rules
1 parent 6123b22 commit 0df0a86

File tree

7 files changed

+523
-2
lines changed

7 files changed

+523
-2
lines changed

pkg/sass-parser/lib/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ export {
3838
DebugRuleProps,
3939
DebugRuleRaws,
4040
} from './src/statement/debug-rule';
41+
export {EachRule, EachRuleProps, EachRuleRaws} from './src/statement/each-rule';
4142
export {
4243
GenericAtRule,
4344
GenericAtRuleProps,

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

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,11 @@ declare namespace SassInternal {
8080
readonly expression: Expression;
8181
}
8282

83+
class EachRule extends ParentStatement<Statement[]> {
84+
readonly variables: string[];
85+
readonly list: Expression;
86+
}
87+
8388
class Stylesheet extends ParentStatement<Statement[]> {}
8489

8590
class StyleRule extends ParentStatement<Statement[]> {
@@ -118,6 +123,7 @@ export type ParentStatement<T extends Statement[] | null> =
118123
export type AtRootRule = SassInternal.AtRootRule;
119124
export type AtRule = SassInternal.AtRule;
120125
export type DebugRule = SassInternal.DebugRule;
126+
export type EachRule = SassInternal.EachRule;
121127
export type Stylesheet = SassInternal.Stylesheet;
122128
export type StyleRule = SassInternal.StyleRule;
123129
export type Interpolation = SassInternal.Interpolation;
@@ -129,6 +135,7 @@ export interface StatementVisitorObject<T> {
129135
visitAtRootRule(node: AtRootRule): T;
130136
visitAtRule(node: AtRule): T;
131137
visitDebugRule(node: DebugRule): T;
138+
visitEachRule(node: EachRule): T;
132139
visitStyleRule(node: StyleRule): T;
133140
}
134141

Lines changed: 25 additions & 0 deletions
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+
`;
Lines changed: 302 additions & 0 deletions
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+
});

0 commit comments

Comments
 (0)