Skip to content

Commit d067c3a

Browse files
nex3Goodwine
andauthored
Add support for parsing parenthesized expressions (#2527)
Co-authored-by: Carlos (Goodwine) <2022649+Goodwine@users.noreply.github.com>
1 parent 2e59f14 commit d067c3a

File tree

9 files changed

+275
-1
lines changed

9 files changed

+275
-1
lines changed

pkg/sass-parser/CHANGELOG.md

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

33
* Add support for parsing null literals.
44

5+
* Add support for parsing parenthesized expressions.
6+
57
## 0.4.15
68

79
* Add support for parsing list expressions.

pkg/sass-parser/lib/index.ts

+5
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,11 @@ export {
108108
NumberExpressionProps,
109109
NumberExpressionRaws,
110110
} from './src/expression/number';
111+
export {
112+
ParenthesizedExpression,
113+
ParenthesizedExpressionProps,
114+
ParenthesizedExpressionRaws,
115+
} from './src/expression/parenthesized';
111116
export {
112117
ImportList,
113118
ImportListObjectProps,
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
// Jest Snapshot v1, https://goo.gl/fbAQLP
2+
3+
exports[`a parenthesized expression toJSON 1`] = `
4+
{
5+
"inParens": <foo>,
6+
"inputs": [
7+
{
8+
"css": "@#{(foo)}",
9+
"hasBOM": false,
10+
"id": "<input css _____>",
11+
},
12+
],
13+
"raws": {},
14+
"sassType": "parenthesized",
15+
"source": <1:4-1:9 in 0>,
16+
}
17+
`;

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

+3
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {ListExpression} from './list';
1515
import {MapExpression} from './map';
1616
import {NullExpression} from './null';
1717
import {NumberExpression} from './number';
18+
import {ParenthesizedExpression} from './parenthesized';
1819
import {StringExpression} from './string';
1920

2021
/** The visitor to use to convert internal Sass nodes to JS. */
@@ -36,6 +37,8 @@ const visitor = sassInternal.createExpressionVisitor<Expression>({
3637
visitMapExpression: inner => new MapExpression(undefined, inner),
3738
visitNullExpression: inner => new NullExpression(undefined, inner),
3839
visitNumberExpression: inner => new NumberExpression(undefined, inner),
40+
visitParenthesizedExpression: inner =>
41+
new ParenthesizedExpression(undefined, inner),
3942
});
4043

4144
/** 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
@@ -13,6 +13,7 @@ import {ListExpression} from './list';
1313
import {MapExpression} from './map';
1414
import {NullExpression} from './null';
1515
import {NumberExpression} from './number';
16+
import {ParenthesizedExpression} from './parenthesized';
1617
import {StringExpression} from './string';
1718

1819
/** Constructs an expression from {@link ExpressionProps}. */
@@ -21,6 +22,7 @@ export function fromProps(props: ExpressionProps): Expression {
2122
if ('left' in props) return new BinaryOperationExpression(props);
2223
if ('separator' in props) return new ListExpression(props);
2324
if ('nodes' in props) return new MapExpression(props);
25+
if ('inParens' in props) return new ParenthesizedExpression(props);
2426
if ('name' in props) {
2527
if (typeof props.name === 'string') {
2628
return new FunctionExpression(props as FunctionExpressionProps);

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

+7
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,10 @@ import {ListExpression, ListExpressionProps} from './list';
1818
import {MapExpression, MapExpressionProps} from './map';
1919
import {NullExpression, NullExpressionProps} from './null';
2020
import {NumberExpression, NumberExpressionProps} from './number';
21+
import {
22+
ParenthesizedExpression,
23+
ParenthesizedExpressionProps,
24+
} from './parenthesized';
2125
import type {StringExpression, StringExpressionProps} from './string';
2226

2327
/**
@@ -35,6 +39,7 @@ export type AnyExpression =
3539
| MapExpression
3640
| NullExpression
3741
| NumberExpression
42+
| ParenthesizedExpression
3843
| StringExpression;
3944

4045
/**
@@ -52,6 +57,7 @@ export type ExpressionType =
5257
| 'map'
5358
| 'null'
5459
| 'number'
60+
| 'parenthesized'
5561
| 'string';
5662

5763
/**
@@ -70,6 +76,7 @@ export type ExpressionProps =
7076
| MapExpressionProps
7177
| NullExpressionProps
7278
| NumberExpressionProps
79+
| ParenthesizedExpressionProps
7380
| StringExpressionProps;
7481

7582
/**
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
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 {ParenthesizedExpression, StringExpression} from '../..';
6+
import * as utils from '../../../test/utils';
7+
8+
describe('a parenthesized expression', () => {
9+
let node: ParenthesizedExpression;
10+
function describeNode(
11+
description: string,
12+
create: () => ParenthesizedExpression,
13+
): void {
14+
describe(description, () => {
15+
beforeEach(() => void (node = create()));
16+
17+
it('has sassType parenthesized', () =>
18+
expect(node.sassType).toBe('parenthesized'));
19+
20+
it('has an expression', () =>
21+
expect(node).toHaveStringExpression('inParens', 'foo'));
22+
});
23+
}
24+
25+
describeNode('parsed', () => utils.parseExpression('(foo)'));
26+
27+
describeNode(
28+
'constructed manually',
29+
() => new ParenthesizedExpression({inParens: {text: 'foo'}}),
30+
);
31+
32+
describeNode('constructed from ExpressionProps', () =>
33+
utils.fromExpressionProps({inParens: {text: 'foo'}}),
34+
);
35+
36+
describe('assigned new', () => {
37+
beforeEach(() => void (node = utils.parseExpression('(foo)')));
38+
39+
describe('expression', () => {
40+
it("removes the old expression's parent", () => {
41+
const oldInParens = node.inParens;
42+
node.inParens = {text: 'zip'};
43+
expect(oldInParens.parent).toBeUndefined();
44+
});
45+
46+
it('assigns the expression explicitly', () => {
47+
const inParens = new StringExpression({text: 'zip'});
48+
node.inParens = inParens;
49+
expect(node.inParens).toBe(inParens);
50+
expect(node).toHaveStringExpression('inParens', 'zip');
51+
});
52+
53+
it('assigns the expression as ExpressionProps', () => {
54+
node.inParens = {text: 'zip'};
55+
expect(node).toHaveStringExpression('inParens', 'zip');
56+
});
57+
});
58+
});
59+
60+
describe('stringifies', () => {
61+
beforeEach(() => void (node = utils.parseExpression('(foo)')));
62+
63+
it('without raws', () => expect(node.toString()).toBe('(foo)'));
64+
65+
it('with afterOpen', () => {
66+
node.raws.afterOpen = '/**/';
67+
expect(node.toString()).toBe('(/**/foo)');
68+
});
69+
70+
it('with beforeClose', () => {
71+
node.raws.beforeClose = '/**/';
72+
expect(node.toString()).toBe('(foo/**/)');
73+
});
74+
});
75+
76+
describe('clone', () => {
77+
let original: ParenthesizedExpression;
78+
beforeEach(() => {
79+
original = utils.parseExpression('(foo)');
80+
// TODO: remove this once raws are properly parsed
81+
original.raws.afterOpen = ' ';
82+
});
83+
84+
describe('with no overrides', () => {
85+
let clone: ParenthesizedExpression;
86+
beforeEach(() => void (clone = original.clone()));
87+
88+
describe('has the same properties:', () => {
89+
it('inParens', () =>
90+
expect(clone).toHaveStringExpression('inParens', 'foo'));
91+
92+
it('raws', () => expect(clone.raws).toEqual({afterOpen: ' '}));
93+
94+
it('source', () => expect(clone.source).toBe(original.source));
95+
});
96+
97+
describe('creates a new', () => {
98+
it('self', () => expect(clone).not.toBe(original));
99+
100+
for (const attr of ['inParens', 'raws'] as const) {
101+
it(attr, () => expect(clone[attr]).not.toBe(original[attr]));
102+
}
103+
});
104+
});
105+
106+
describe('overrides', () => {
107+
describe('inParens', () => {
108+
it('defined', () =>
109+
expect(
110+
original.clone({inParens: {text: 'zip'}}),
111+
).toHaveStringExpression('inParens', 'zip'));
112+
113+
it('undefined', () =>
114+
expect(original.clone({inParens: undefined})).toHaveStringExpression(
115+
'inParens',
116+
'foo',
117+
));
118+
});
119+
120+
describe('raws', () => {
121+
it('defined', () =>
122+
expect(original.clone({raws: {beforeClose: ' '}}).raws).toEqual({
123+
beforeClose: ' ',
124+
}));
125+
126+
it('undefined', () =>
127+
expect(original.clone({raws: undefined}).raws).toEqual({
128+
afterOpen: ' ',
129+
}));
130+
});
131+
});
132+
});
133+
134+
it('toJSON', () => expect(utils.parseExpression('(foo)')).toMatchSnapshot());
135+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
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 * as postcss from 'postcss';
6+
7+
import {LazySource} from '../lazy-source';
8+
import {NodeProps} from '../node';
9+
import type * as sassInternal from '../sass-internal';
10+
import * as utils from '../utils';
11+
import {Expression, ExpressionProps} from '.';
12+
import {convertExpression} from './convert';
13+
import {fromProps} from './from-props';
14+
15+
/**
16+
* The initializer properties for {@link ParenthesizedExpression}.
17+
*
18+
* @category Expression
19+
*/
20+
export interface ParenthesizedExpressionProps extends NodeProps {
21+
inParens: Expression | ExpressionProps;
22+
raws?: ParenthesizedExpressionRaws;
23+
}
24+
25+
/**
26+
* Raws indicating how to precisely serialize a {@link ParenthesizedExpression}.
27+
*
28+
* @category Expression
29+
*/
30+
export interface ParenthesizedExpressionRaws {
31+
/** The whitespace after the opening parenthesis. */
32+
afterOpen?: string;
33+
34+
/** The whitespace before the closing parenthesis. */
35+
beforeClose?: string;
36+
}
37+
38+
/**
39+
* An expression representing a parenthesized Sass expression.
40+
*
41+
* @category Expression
42+
*/
43+
export class ParenthesizedExpression extends Expression {
44+
readonly sassType = 'parenthesized' as const;
45+
declare raws: ParenthesizedExpressionRaws;
46+
47+
/** The expression within the parentheses. */
48+
get inParens(): Expression {
49+
return this._inParens;
50+
}
51+
set inParens(inParens: Expression | ExpressionProps) {
52+
// TODO - postcss/postcss#1957: Mark this as dirty
53+
if (this._inParens) this._inParens.parent = undefined;
54+
if (!('sassType' in inParens)) inParens = fromProps(inParens);
55+
inParens.parent = this;
56+
this._inParens = inParens;
57+
}
58+
private declare _inParens: Expression;
59+
60+
constructor(defaults: ParenthesizedExpressionProps);
61+
/** @hidden */
62+
constructor(_: undefined, inner: sassInternal.ParenthesizedExpression);
63+
constructor(defaults?: object, inner?: sassInternal.ParenthesizedExpression) {
64+
super(defaults);
65+
if (inner) {
66+
this.source = new LazySource(inner);
67+
this.inParens = convertExpression(inner.expression);
68+
}
69+
}
70+
71+
clone(overrides?: Partial<ParenthesizedExpressionProps>): this {
72+
return utils.cloneNode(this, overrides, ['raws', 'inParens']);
73+
}
74+
75+
toJSON(): object;
76+
/** @hidden */
77+
toJSON(_: string, inputs: Map<postcss.Input, number>): object;
78+
toJSON(_?: string, inputs?: Map<postcss.Input, number>): object {
79+
return utils.toJSON(this, ['inParens'], inputs);
80+
}
81+
82+
/** @hidden */
83+
toString(): string {
84+
return (
85+
'(' +
86+
(this.raws.afterOpen ?? '') +
87+
this.inParens +
88+
(this.raws.beforeClose ?? '') +
89+
')'
90+
);
91+
}
92+
93+
/** @hidden */
94+
get nonStatementChildren(): ReadonlyArray<Expression> {
95+
return [this.inParens];
96+
}
97+
}

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

+7-1
Original file line numberDiff line numberDiff line change
@@ -378,6 +378,10 @@ declare namespace SassInternal {
378378
readonly value: number;
379379
readonly unit: string;
380380
}
381+
382+
class ParenthesizedExpression extends Expression {
383+
readonly expression: Expression;
384+
}
381385
}
382386

383387
const sassInternal = (
@@ -438,6 +442,7 @@ export type BooleanExpression = SassInternal.BooleanExpression;
438442
export type ColorExpression = SassInternal.ColorExpression;
439443
export type NullExpression = SassInternal.NullExpression;
440444
export type NumberExpression = SassInternal.NumberExpression;
445+
export type ParenthesizedExpression = SassInternal.ParenthesizedExpression;
441446

442447
export interface StatementVisitorObject<T> {
443448
visitAtRootRule(node: AtRootRule): T;
@@ -469,7 +474,6 @@ export interface StatementVisitorObject<T> {
469474

470475
export interface ExpressionVisitorObject<T> {
471476
visitBinaryOperationExpression(node: BinaryOperationExpression): T;
472-
visitStringExpression(node: StringExpression): T;
473477
visitBooleanExpression(node: BooleanExpression): T;
474478
visitColorExpression(node: ColorExpression): T;
475479
visitFunctionExpression(node: FunctionExpression): T;
@@ -479,6 +483,8 @@ export interface ExpressionVisitorObject<T> {
479483
visitMapExpression(node: MapExpression): T;
480484
visitNullExpression(node: NullExpression): T;
481485
visitNumberExpression(node: NumberExpression): T;
486+
visitParenthesizedExpression(node: ParenthesizedExpression): T;
487+
visitStringExpression(node: StringExpression): T;
482488
}
483489

484490
export const createExpressionVisitor = sassInternal.createExpressionVisitor;

0 commit comments

Comments
 (0)