Skip to content

Commit 1c328e4

Browse files
committed
Add support for parsing unary operations
1 parent 734e9de commit 1c328e4

9 files changed

+438
-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,268 @@
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).toHaveStringExpression('operand', 'zip');
65+
});
66+
67+
it('assigns operand as ExpressionProps', () => {
68+
node.operand = {text: 'zip'};
69+
expect(node).toHaveStringExpression('operand', 'zip');
70+
});
71+
});
72+
});
73+
74+
describe('stringifies', () => {
75+
describe('plus', () => {
76+
describe('with an identifier', () => {
77+
beforeEach(
78+
() =>
79+
void (node = new UnaryOperationExpression({
80+
operator: '+',
81+
operand: {text: 'foo'},
82+
})),
83+
);
84+
85+
it('without raws', () => expect(node.toString()).toBe('+foo'));
86+
87+
it('with between', () => {
88+
node.raws.between = '/**/';
89+
expect(node.toString()).toBe('+/**/foo');
90+
});
91+
});
92+
93+
describe('with a number', () => {
94+
beforeEach(
95+
() =>
96+
void (node = new UnaryOperationExpression({
97+
operator: '+',
98+
operand: {value: 0},
99+
})),
100+
);
101+
102+
it('without raws', () => expect(node.toString()).toBe('+ 0'));
103+
104+
it('with between', () => {
105+
node.raws.between = '/**/';
106+
expect(node.toString()).toBe('+/**/0');
107+
});
108+
});
109+
});
110+
111+
describe('not', () => {
112+
beforeEach(
113+
() =>
114+
void (node = new UnaryOperationExpression({
115+
operator: 'not',
116+
operand: {text: 'foo'},
117+
})),
118+
);
119+
120+
it('without raws', () => expect(node.toString()).toBe('not foo'));
121+
122+
it('with between', () => {
123+
node.raws.between = '/**/';
124+
expect(node.toString()).toBe('not/**/foo');
125+
});
126+
});
127+
128+
describe('minus', () => {
129+
describe('with an identifier', () => {
130+
beforeEach(
131+
() =>
132+
void (node = new UnaryOperationExpression({
133+
operator: '-',
134+
operand: {text: 'foo'},
135+
})),
136+
);
137+
138+
it('without raws', () => expect(node.toString()).toBe('- foo'));
139+
140+
it('with between', () => {
141+
node.raws.between = '/**/';
142+
expect(node.toString()).toBe('-/**/foo');
143+
});
144+
});
145+
146+
describe('with a number', () => {
147+
beforeEach(
148+
() =>
149+
void (node = new UnaryOperationExpression({
150+
operator: '-',
151+
operand: {value: 0},
152+
})),
153+
);
154+
155+
it('without raws', () => expect(node.toString()).toBe('- 0'));
156+
157+
it('with between', () => {
158+
node.raws.between = '/**/';
159+
expect(node.toString()).toBe('-/**/0');
160+
});
161+
});
162+
163+
describe('with a function call', () => {
164+
beforeEach(
165+
() =>
166+
void (node = new UnaryOperationExpression({
167+
operator: '-',
168+
operand: {name: 'foo', arguments: []},
169+
})),
170+
);
171+
172+
it('without raws', () => expect(node.toString()).toBe('- foo()'));
173+
174+
it('with between', () => {
175+
node.raws.between = '/**/';
176+
expect(node.toString()).toBe('-/**/foo()');
177+
});
178+
});
179+
180+
describe('with a parenthesized expression', () => {
181+
beforeEach(
182+
() =>
183+
void (node = new UnaryOperationExpression({
184+
operator: '-',
185+
operand: {inParens: {text: 'foo'}},
186+
})),
187+
);
188+
189+
it('without raws', () => expect(node.toString()).toBe('-(foo)'));
190+
191+
it('with between', () => {
192+
node.raws.between = '/**/';
193+
expect(node.toString()).toBe('-/**/(foo)');
194+
});
195+
});
196+
});
197+
});
198+
199+
describe('clone', () => {
200+
let original: UnaryOperationExpression;
201+
beforeEach(() => {
202+
original = utils.parseExpression('+foo');
203+
// TODO: remove this once raws are properly parsed
204+
original.raws.between = ' ';
205+
});
206+
207+
describe('with no overrides', () => {
208+
let clone: UnaryOperationExpression;
209+
beforeEach(() => void (clone = original.clone()));
210+
211+
describe('has the same properties:', () => {
212+
it('operator', () => expect(clone.operator).toBe('+'));
213+
214+
it('operand', () =>
215+
expect(clone).toHaveStringExpression('operand', 'foo'));
216+
217+
it('raws', () => expect(clone.raws).toEqual({between: ' '}));
218+
219+
it('source', () => expect(clone.source).toBe(original.source));
220+
});
221+
222+
describe('creates a new', () => {
223+
it('self', () => expect(clone).not.toBe(original));
224+
225+
for (const attr of ['operand', 'raws'] as const) {
226+
it(attr, () => expect(clone[attr]).not.toBe(original[attr]));
227+
}
228+
});
229+
});
230+
231+
describe('overrides', () => {
232+
describe('operator', () => {
233+
it('defined', () =>
234+
expect(original.clone({operator: '-'}).operator).toBe('-'));
235+
236+
it('undefined', () =>
237+
expect(original.clone({operator: undefined}).operator).toBe('+'));
238+
});
239+
240+
describe('operand', () => {
241+
it('defined', () =>
242+
expect(
243+
original.clone({operand: {text: 'zip'}}),
244+
).toHaveStringExpression('operand', 'zip'));
245+
246+
it('undefined', () =>
247+
expect(original.clone({operand: undefined})).toHaveStringExpression(
248+
'operand',
249+
'foo',
250+
));
251+
});
252+
253+
describe('raws', () => {
254+
it('defined', () =>
255+
expect(original.clone({raws: {between: '/**/'}}).raws).toEqual({
256+
between: '/**/',
257+
}));
258+
259+
it('undefined', () =>
260+
expect(original.clone({raws: undefined}).raws).toEqual({
261+
between: ' ',
262+
}));
263+
});
264+
});
265+
});
266+
267+
it('toJSON', () => expect(utils.parseExpression('+foo')).toMatchSnapshot());
268+
});

0 commit comments

Comments
 (0)