Skip to content

Commit 2680d5f

Browse files
authoredMar 10, 2025··
Add support for parsing unary operations (#2538)
1 parent 734e9de commit 2680d5f

9 files changed

+439
-4
lines changed
 

‎pkg/sass-parser/CHANGELOG.md

+2
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@
1212

1313
* Add support for parsing the `supports()` function in `@import` modifiers.
1414

15+
* Add support for parsing unary operation expressions.
16+
1517
## 0.4.15
1618

1719
* Add support for parsing list expressions.

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

+5
Original file line numberDiff line numberDiff line change
@@ -248,6 +248,11 @@ export {
248248
StaticImportProps,
249249
StaticImportRaws,
250250
} from './src/static-import';
251+
export {
252+
UnaryOperationExpression,
253+
UnaryOperationExpressionProps,
254+
UnaryOperationExpressionRaws,
255+
} from './src/expression/unary-operation';
251256

252257
/** Options that can be passed to the Sass parsers to control their behavior. */
253258
export type SassParserOptions = Pick<postcss.ProcessOptions, 'from' | 'map'>;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
// Jest Snapshot v1, https://goo.gl/fbAQLP
2+
3+
exports[`a unary operation toJSON 1`] = `
4+
{
5+
"inputs": [
6+
{
7+
"css": "@#{+foo}",
8+
"hasBOM": false,
9+
"id": "<input css _____>",
10+
},
11+
],
12+
"operand": <foo>,
13+
"operator": "+",
14+
"raws": {},
15+
"sassType": "unary-operation",
16+
"source": <1:4-1:8 in 0>,
17+
}
18+
`;

‎pkg/sass-parser/lib/src/expression/convert.ts

+3
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import {NumberExpression} from './number';
2020
import {ParenthesizedExpression} from './parenthesized';
2121
import {SelectorExpression} from './selector';
2222
import {StringExpression} from './string';
23+
import {UnaryOperationExpression} from './unary-operation';
2324

2425
/** The visitor to use to convert internal Sass nodes to JS. */
2526
const visitor = sassInternal.createExpressionVisitor<AnyExpression>({
@@ -52,6 +53,8 @@ const visitor = sassInternal.createExpressionVisitor<AnyExpression>({
5253
]),
5354
source: new LazySource(inner),
5455
}),
56+
visitUnaryOperationExpression: inner =>
57+
new UnaryOperationExpression(undefined, inner),
5558
});
5659

5760
/** Converts an internal expression AST node into an external one. */

‎pkg/sass-parser/lib/src/expression/from-props.ts

+2
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,13 @@ import {NullExpression} from './null';
1515
import {NumberExpression} from './number';
1616
import {ParenthesizedExpression} from './parenthesized';
1717
import {StringExpression} from './string';
18+
import {UnaryOperationExpression} from './unary-operation';
1819

1920
/** Constructs an expression from {@link ExpressionProps}. */
2021
export function fromProps(props: ExpressionProps): AnyExpression {
2122
if ('text' in props) return new StringExpression(props);
2223
if ('left' in props) return new BinaryOperationExpression(props);
24+
if ('operand' in props) return new UnaryOperationExpression(props);
2325
if ('separator' in props) return new ListExpression(props);
2426
if ('nodes' in props) return new MapExpression(props);
2527
if ('inParens' in props) return new ParenthesizedExpression(props);

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

+10-3
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,10 @@ import {
2424
} from './parenthesized';
2525
import type {SelectorExpression} from './selector';
2626
import type {StringExpression, StringExpressionProps} from './string';
27+
import type {
28+
UnaryOperationExpression,
29+
UnaryOperationExpressionProps,
30+
} from './unary-operation';
2731

2832
/**
2933
* The union type of all Sass expressions.
@@ -42,7 +46,8 @@ export type AnyExpression =
4246
| NumberExpression
4347
| ParenthesizedExpression
4448
| SelectorExpression
45-
| StringExpression;
49+
| StringExpression
50+
| UnaryOperationExpression;
4651

4752
/**
4853
* Sass expression types.
@@ -61,7 +66,8 @@ export type ExpressionType =
6166
| 'number'
6267
| 'parenthesized'
6368
| 'selector-expr'
64-
| 'string';
69+
| 'string'
70+
| 'unary-operation';
6571

6672
/**
6773
* The union type of all properties that can be used to construct Sass
@@ -80,7 +86,8 @@ export type ExpressionProps =
8086
| NullExpressionProps
8187
| NumberExpressionProps
8288
| ParenthesizedExpressionProps
83-
| StringExpressionProps;
89+
| StringExpressionProps
90+
| UnaryOperationExpressionProps;
8491

8592
/**
8693
* The superclass of Sass expression nodes.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,269 @@
1+
// Copyright 2025 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 {StringExpression, UnaryOperationExpression} from '../..';
6+
import * as utils from '../../../test/utils';
7+
8+
describe('a unary operation', () => {
9+
let node: UnaryOperationExpression;
10+
function describeNode(
11+
description: string,
12+
create: () => UnaryOperationExpression,
13+
): void {
14+
describe(description, () => {
15+
beforeEach(() => void (node = create()));
16+
17+
it('has sassType unary-operation', () =>
18+
expect(node.sassType).toBe('unary-operation'));
19+
20+
it('has an operator', () => expect(node.operator).toBe('+'));
21+
22+
it('has an operand', () =>
23+
expect(node).toHaveStringExpression('operand', 'foo'));
24+
});
25+
}
26+
27+
describeNode('parsed', () => utils.parseExpression('+foo'));
28+
29+
describeNode(
30+
'constructed manually',
31+
() =>
32+
new UnaryOperationExpression({
33+
operator: '+',
34+
operand: {text: 'foo'},
35+
}),
36+
);
37+
38+
describeNode('constructed from ExpressionProps', () =>
39+
utils.fromExpressionProps({
40+
operator: '+',
41+
operand: {text: 'foo'},
42+
}),
43+
);
44+
45+
describe('assigned new', () => {
46+
beforeEach(() => void (node = utils.parseExpression('+foo')));
47+
48+
it('operator', () => {
49+
node.operator = 'not';
50+
expect(node.operator).toBe('not');
51+
});
52+
53+
describe('operand', () => {
54+
it("removes the old operand's parent", () => {
55+
const oldOperand = node.operand;
56+
node.operand = {text: 'zip'};
57+
expect(oldOperand.parent).toBeUndefined();
58+
});
59+
60+
it('assigns operand explicitly', () => {
61+
const operand = new StringExpression({text: 'zip'});
62+
node.operand = operand;
63+
expect(node.operand).toBe(operand);
64+
expect(node.operand.parent).toBe(node);
65+
expect(node).toHaveStringExpression('operand', 'zip');
66+
});
67+
68+
it('assigns operand as ExpressionProps', () => {
69+
node.operand = {text: 'zip'};
70+
expect(node).toHaveStringExpression('operand', 'zip');
71+
});
72+
});
73+
});
74+
75+
describe('stringifies', () => {
76+
describe('plus', () => {
77+
describe('with an identifier', () => {
78+
beforeEach(
79+
() =>
80+
void (node = new UnaryOperationExpression({
81+
operator: '+',
82+
operand: {text: 'foo'},
83+
})),
84+
);
85+
86+
it('without raws', () => expect(node.toString()).toBe('+foo'));
87+
88+
it('with between', () => {
89+
node.raws.between = '/**/';
90+
expect(node.toString()).toBe('+/**/foo');
91+
});
92+
});
93+
94+
describe('with a number', () => {
95+
beforeEach(
96+
() =>
97+
void (node = new UnaryOperationExpression({
98+
operator: '+',
99+
operand: {value: 0},
100+
})),
101+
);
102+
103+
it('without raws', () => expect(node.toString()).toBe('+ 0'));
104+
105+
it('with between', () => {
106+
node.raws.between = '/**/';
107+
expect(node.toString()).toBe('+/**/0');
108+
});
109+
});
110+
});
111+
112+
describe('not', () => {
113+
beforeEach(
114+
() =>
115+
void (node = new UnaryOperationExpression({
116+
operator: 'not',
117+
operand: {text: 'foo'},
118+
})),
119+
);
120+
121+
it('without raws', () => expect(node.toString()).toBe('not foo'));
122+
123+
it('with between', () => {
124+
node.raws.between = '/**/';
125+
expect(node.toString()).toBe('not/**/foo');
126+
});
127+
});
128+
129+
describe('minus', () => {
130+
describe('with an identifier', () => {
131+
beforeEach(
132+
() =>
133+
void (node = new UnaryOperationExpression({
134+
operator: '-',
135+
operand: {text: 'foo'},
136+
})),
137+
);
138+
139+
it('without raws', () => expect(node.toString()).toBe('- foo'));
140+
141+
it('with between', () => {
142+
node.raws.between = '/**/';
143+
expect(node.toString()).toBe('-/**/foo');
144+
});
145+
});
146+
147+
describe('with a number', () => {
148+
beforeEach(
149+
() =>
150+
void (node = new UnaryOperationExpression({
151+
operator: '-',
152+
operand: {value: 0},
153+
})),
154+
);
155+
156+
it('without raws', () => expect(node.toString()).toBe('- 0'));
157+
158+
it('with between', () => {
159+
node.raws.between = '/**/';
160+
expect(node.toString()).toBe('-/**/0');
161+
});
162+
});
163+
164+
describe('with a function call', () => {
165+
beforeEach(
166+
() =>
167+
void (node = new UnaryOperationExpression({
168+
operator: '-',
169+
operand: {name: 'foo', arguments: []},
170+
})),
171+
);
172+
173+
it('without raws', () => expect(node.toString()).toBe('- foo()'));
174+
175+
it('with between', () => {
176+
node.raws.between = '/**/';
177+
expect(node.toString()).toBe('-/**/foo()');
178+
});
179+
});
180+
181+
describe('with a parenthesized expression', () => {
182+
beforeEach(
183+
() =>
184+
void (node = new UnaryOperationExpression({
185+
operator: '-',
186+
operand: {inParens: {text: 'foo'}},
187+
})),
188+
);
189+
190+
it('without raws', () => expect(node.toString()).toBe('-(foo)'));
191+
192+
it('with between', () => {
193+
node.raws.between = '/**/';
194+
expect(node.toString()).toBe('-/**/(foo)');
195+
});
196+
});
197+
});
198+
});
199+
200+
describe('clone', () => {
201+
let original: UnaryOperationExpression;
202+
beforeEach(() => {
203+
original = utils.parseExpression('+foo');
204+
// TODO: remove this once raws are properly parsed
205+
original.raws.between = ' ';
206+
});
207+
208+
describe('with no overrides', () => {
209+
let clone: UnaryOperationExpression;
210+
beforeEach(() => void (clone = original.clone()));
211+
212+
describe('has the same properties:', () => {
213+
it('operator', () => expect(clone.operator).toBe('+'));
214+
215+
it('operand', () =>
216+
expect(clone).toHaveStringExpression('operand', 'foo'));
217+
218+
it('raws', () => expect(clone.raws).toEqual({between: ' '}));
219+
220+
it('source', () => expect(clone.source).toBe(original.source));
221+
});
222+
223+
describe('creates a new', () => {
224+
it('self', () => expect(clone).not.toBe(original));
225+
226+
for (const attr of ['operand', 'raws'] as const) {
227+
it(attr, () => expect(clone[attr]).not.toBe(original[attr]));
228+
}
229+
});
230+
});
231+
232+
describe('overrides', () => {
233+
describe('operator', () => {
234+
it('defined', () =>
235+
expect(original.clone({operator: '-'}).operator).toBe('-'));
236+
237+
it('undefined', () =>
238+
expect(original.clone({operator: undefined}).operator).toBe('+'));
239+
});
240+
241+
describe('operand', () => {
242+
it('defined', () =>
243+
expect(
244+
original.clone({operand: {text: 'zip'}}),
245+
).toHaveStringExpression('operand', 'zip'));
246+
247+
it('undefined', () =>
248+
expect(original.clone({operand: undefined})).toHaveStringExpression(
249+
'operand',
250+
'foo',
251+
));
252+
});
253+
254+
describe('raws', () => {
255+
it('defined', () =>
256+
expect(original.clone({raws: {between: '/**/'}}).raws).toEqual({
257+
between: '/**/',
258+
}));
259+
260+
it('undefined', () =>
261+
expect(original.clone({raws: undefined}).raws).toEqual({
262+
between: ' ',
263+
}));
264+
});
265+
});
266+
});
267+
268+
it('toJSON', () => expect(utils.parseExpression('+foo')).toMatchSnapshot());
269+
});

0 commit comments

Comments
 (0)
Please sign in to comment.