Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for parsing variable expressions #2541

Merged
merged 2 commits into from
Mar 10, 2025
Merged
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions pkg/sass-parser/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -14,6 +14,8 @@

* Add support for parsing unary operation expressions.

* Add support for parsing variable expressions.

## 0.4.15

* Add support for parsing list expressions.
5 changes: 5 additions & 0 deletions pkg/sass-parser/lib/index.ts
Original file line number Diff line number Diff line change
@@ -253,6 +253,11 @@ export {
UnaryOperationExpressionProps,
UnaryOperationExpressionRaws,
} from './src/expression/unary-operation';
export {
VariableExpression,
VariableExpressionProps,
VariableExpressionRaws,
} from './src/expression/variable';

/** Options that can be passed to the Sass parsers to control their behavior. */
export type SassParserOptions = Pick<postcss.ProcessOptions, 'from' | 'map'>;
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`a variable expression toJSON with a namespace 1`] = `
{
"inputs": [
{
"css": "@#{bar.$foo}",
"hasBOM": false,
"id": "<input css _____>",
},
],
"namespace": "bar",
"raws": {},
"sassType": "variable",
"source": <1:4-1:12 in 0>,
"variableName": "foo",
}
`;

exports[`a variable expression toJSON without a namespace 1`] = `
{
"inputs": [
{
"css": "@#{$foo}",
"hasBOM": false,
"id": "<input css _____>",
},
],
"raws": {},
"sassType": "variable",
"source": <1:4-1:8 in 0>,
"variableName": "foo",
}
`;
2 changes: 2 additions & 0 deletions pkg/sass-parser/lib/src/expression/convert.ts
Original file line number Diff line number Diff line change
@@ -21,6 +21,7 @@ import {ParenthesizedExpression} from './parenthesized';
import {SelectorExpression} from './selector';
import {StringExpression} from './string';
import {UnaryOperationExpression} from './unary-operation';
import {VariableExpression} from './variable';

/** The visitor to use to convert internal Sass nodes to JS. */
const visitor = sassInternal.createExpressionVisitor<AnyExpression>({
@@ -55,6 +56,7 @@ const visitor = sassInternal.createExpressionVisitor<AnyExpression>({
}),
visitUnaryOperationExpression: inner =>
new UnaryOperationExpression(undefined, inner),
visitVariableExpression: inner => new VariableExpression(undefined, inner),
});

/** Converts an internal expression AST node into an external one. */
2 changes: 2 additions & 0 deletions pkg/sass-parser/lib/src/expression/from-props.ts
Original file line number Diff line number Diff line change
@@ -16,6 +16,7 @@ import {NumberExpression} from './number';
import {ParenthesizedExpression} from './parenthesized';
import {StringExpression} from './string';
import {UnaryOperationExpression} from './unary-operation';
import {VariableExpression} from './variable';

/** Constructs an expression from {@link ExpressionProps}. */
export function fromProps(props: ExpressionProps): AnyExpression {
@@ -25,6 +26,7 @@ export function fromProps(props: ExpressionProps): AnyExpression {
if ('separator' in props) return new ListExpression(props);
if ('nodes' in props) return new MapExpression(props);
if ('inParens' in props) return new ParenthesizedExpression(props);
if ('variableName' in props) return new VariableExpression(props);
if ('name' in props) {
if (typeof props.name === 'string') {
return new FunctionExpression(props as FunctionExpressionProps);
35 changes: 27 additions & 8 deletions pkg/sass-parser/lib/src/expression/function.test.ts
Original file line number Diff line number Diff line change
@@ -162,16 +162,35 @@ describe('a function expression', () => {
}).toString(),
).toBe('foo(bar)'));

it('with a namespace', () =>
expect(
new FunctionExpression({
namespace: 'baz',
name: 'foo',
arguments: [{text: 'bar'}],
}).toString(),
).toBe('baz.foo(bar)'));
describe('with a namespace', () => {
it("that's an identifier", () =>
expect(
new FunctionExpression({
namespace: 'baz',
name: 'foo',
arguments: [{text: 'bar'}],
}).toString(),
).toBe('baz.foo(bar)'));

it("that's not an identifier", () =>
expect(
new FunctionExpression({
namespace: 'b z',
name: 'foo',
arguments: [{text: 'bar'}],
}).toString(),
).toBe('b\\20z.foo(bar)'));
});
});

it("with a name that's not an identifier", () =>
expect(
new FunctionExpression({
name: 'f o',
arguments: [{text: 'bar'}],
}).toString(),
).toBe('f\\20o(bar)'));

it('with matching namespace', () =>
expect(
new FunctionExpression({
14 changes: 8 additions & 6 deletions pkg/sass-parser/lib/src/expression/function.ts
Original file line number Diff line number Diff line change
@@ -8,7 +8,7 @@ import {ArgumentList, ArgumentListProps} from '../argument-list';
import {LazySource} from '../lazy-source';
import {NodeProps} from '../node';
import {RawWithValue} from '../raw-with-value';
import type * as sassInternal from '../sass-internal';
import * as sassInternal from '../sass-internal';
import * as utils from '../utils';
import {Expression} from '.';

@@ -34,7 +34,7 @@ export interface FunctionExpressionRaws {
* The function's namespace.
*
* This may be different than {@link FunctionExpression.namespace} if the
* namespace contains escape codes or underscores.
* namespace contains escape codes.
*/
namespace?: RawWithValue<string>;

@@ -59,8 +59,8 @@ export class FunctionExpression extends Expression {
/**
* This function's namespace.
*
* This is the parsed and normalized value, with underscores converted to
* hyphens and escapes resolved to the characters they represent.
* This is the parsed and normalized value, with escapes resolved to the
* characters they represent.
*/
get namespace(): string | undefined {
return this._namespace;
@@ -136,9 +136,11 @@ export class FunctionExpression extends Expression {
(this.namespace
? (this.raws.namespace?.value === this.namespace
? this.raws.namespace.raw
: this.namespace) + '.'
: sassInternal.toCssIdentifier(this.namespace)) + '.'
: '') +
(this.raws.name?.value === this.name ? this.raws.name.raw : this.name) +
(this.raws.name?.value === this.name
? this.raws.name.raw
: sassInternal.toCssIdentifier(this.name)) +
this.arguments
);
}
10 changes: 7 additions & 3 deletions pkg/sass-parser/lib/src/expression/index.ts
Original file line number Diff line number Diff line change
@@ -28,6 +28,7 @@ import type {
UnaryOperationExpression,
UnaryOperationExpressionProps,
} from './unary-operation';
import type {VariableExpression, VariableExpressionProps} from './variable';

/**
* The union type of all Sass expressions.
@@ -47,7 +48,8 @@ export type AnyExpression =
| ParenthesizedExpression
| SelectorExpression
| StringExpression
| UnaryOperationExpression;
| UnaryOperationExpression
| VariableExpression;

/**
* Sass expression types.
@@ -67,7 +69,8 @@ export type ExpressionType =
| 'parenthesized'
| 'selector-expr'
| 'string'
| 'unary-operation';
| 'unary-operation'
| 'variable';

/**
* The union type of all properties that can be used to construct Sass
@@ -87,7 +90,8 @@ export type ExpressionProps =
| NumberExpressionProps
| ParenthesizedExpressionProps
| StringExpressionProps
| UnaryOperationExpressionProps;
| UnaryOperationExpressionProps
| VariableExpressionProps;

/**
* The superclass of Sass expression nodes.
243 changes: 243 additions & 0 deletions pkg/sass-parser/lib/src/expression/variable.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,243 @@
// Copyright 2025 Google Inc. Use of this source code is governed by an
// MIT-style license that can be found in the LICENSE file or at
// https://opensource.org/licenses/MIT.

import {VariableExpression} from '../..';
import * as utils from '../../../test/utils';

describe('a variable expression', () => {
let node: VariableExpression;

describe('with no namespace', () => {
function describeNode(
description: string,
create: () => VariableExpression,
): void {
describe(description, () => {
beforeEach(() => void (node = create()));

it('has sassType variable', () =>
expect(node.sassType).toBe('variable'));

it('has no namespace', () => expect(node.namespace).toBe(undefined));

it('has a name', () => expect(node.variableName).toBe('foo'));
});
}

describeNode('parsed', () => utils.parseExpression('$foo'));

describeNode(
'constructed manually',
() => new VariableExpression({variableName: 'foo'}),
);

describeNode('constructed from ExpressionProps', () =>
utils.fromExpressionProps({variableName: 'foo'}),
);
});

describe('with a namespace', () => {
function describeNode(
description: string,
create: () => VariableExpression,
): void {
describe(description, () => {
beforeEach(() => void (node = create()));

it('has sassType variable-call', () =>
expect(node.sassType).toBe('variable'));

it('has a namespace', () => expect(node.namespace).toBe('bar'));

it('has a name', () => expect(node.variableName).toBe('foo'));
});
}

describeNode('parsed', () => utils.parseExpression('bar.$foo'));

describeNode(
'constructed manually',
() =>
new VariableExpression({
namespace: 'bar',
variableName: 'foo',
}),
);

describeNode('constructed from ExpressionProps', () =>
utils.fromExpressionProps({
namespace: 'bar',
variableName: 'foo',
}),
);
});

describe('assigned new namespace', () => {
it('defined', () => {
node = utils.parseExpression('bar.$foo');
node.namespace = 'baz';
expect(node.namespace).toBe('baz');
});

it('undefined', () => {
node = utils.parseExpression('bar.$foo');
node.namespace = undefined;
expect(node.namespace).toBe(undefined);
});
});

it('assigned new name', () => {
node = utils.parseExpression('$foo');
node.variableName = 'baz';
expect(node.variableName).toBe('baz');
});

describe('stringifies', () => {
describe('with default raws', () => {
it('with no namespace', () =>
expect(new VariableExpression({variableName: 'foo'}).toString()).toBe(
'$foo',
));

describe('with a namespace', () => {
it("that's an identifier", () =>
expect(
new VariableExpression({
namespace: 'bar',
variableName: 'foo',
}).toString(),
).toBe('bar.$foo'));

it("that's not an identifier", () =>
expect(
new VariableExpression({
namespace: 'b r',
variableName: 'foo',
}).toString(),
).toBe('b\\20r.$foo'));
});
});

it("with a name that's not an identifier", () =>
expect(new VariableExpression({variableName: 'f o'}).toString()).toBe(
'$f\\20o',
));

it('with matching namespace', () =>
expect(
new VariableExpression({
namespace: 'bar',
variableName: 'foo',
raws: {namespace: {value: 'bar', raw: 'b\\61r'}},
}).toString(),
).toBe('b\\61r.$foo'));

it('with non-matching namespace', () =>
expect(
new VariableExpression({
namespace: 'zip',
variableName: 'foo',
raws: {namespace: {value: 'bar', raw: 'b\\61r'}},
}).toString(),
).toBe('zip.$foo'));

it('with matching name', () =>
expect(
new VariableExpression({
variableName: 'foo',
raws: {variableName: {value: 'foo', raw: 'f\\6fo'}},
}).toString(),
).toBe('$f\\6fo'));

it('with non-matching name', () =>
expect(
new VariableExpression({
variableName: 'zip',
raws: {variableName: {value: 'foo', raw: 'f\\6fo'}},
}).toString(),
).toBe('$zip'));
});

describe('clone', () => {
let original: VariableExpression;

beforeEach(() => {
original = utils.parseExpression('bar.$foo');
// TODO: remove this once raws are properly parsed
original.raws.variableName = {value: 'foo', raw: 'f\\6fo'};
});

describe('with no overrides', () => {
let clone: VariableExpression;

beforeEach(() => void (clone = original.clone()));

describe('has the same properties:', () => {
it('namespace', () => expect(clone.namespace).toBe('bar'));

it('name', () => expect(clone.variableName).toBe('foo'));

it('raws', () =>
expect(clone.raws).toEqual({
variableName: {value: 'foo', raw: 'f\\6fo'},
}));

it('source', () => expect(clone.source).toBe(original.source));
});

describe('creates a new', () => {
it('self', () => expect(clone).not.toBe(original));

for (const attr of ['raws'] as const) {
it(attr, () => expect(clone[attr]).not.toBe(original[attr]));
}
});
});

describe('overrides', () => {
describe('raws', () => {
it('defined', () =>
expect(
original.clone({raws: {namespace: {value: 'bar', raw: 'b\\61r'}}})
.raws,
).toEqual({namespace: {value: 'bar', raw: 'b\\61r'}}));

it('undefined', () =>
expect(original.clone({raws: undefined}).raws).toEqual({
variableName: {value: 'foo', raw: 'f\\6fo'},
}));
});

describe('namespace', () => {
it('defined', () =>
expect(original.clone({namespace: 'zip'}).namespace).toBe('zip'));

it('undefined', () =>
expect(original.clone({namespace: undefined}).namespace).toBe(
undefined,
));
});

describe('variableName', () => {
it('defined', () =>
expect(original.clone({variableName: 'zip'}).variableName).toBe(
'zip',
));

it('undefined', () =>
expect(original.clone({variableName: undefined}).variableName).toBe(
'foo',
));
});
});
});

describe('toJSON', () => {
it('without a namespace', () =>
expect(utils.parseExpression('$foo')).toMatchSnapshot());

it('with a namespace', () =>
expect(utils.parseExpression('bar.$foo')).toMatchSnapshot());
});
});
133 changes: 133 additions & 0 deletions pkg/sass-parser/lib/src/expression/variable.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
// Copyright 2025 Google Inc. Use of this source code is governed by an
// MIT-style license that can be found in the LICENSE file or at
// https://opensource.org/licenses/MIT.

import * as postcss from 'postcss';

import {LazySource} from '../lazy-source';
import {NodeProps} from '../node';
import {RawWithValue} from '../raw-with-value';
import * as sassInternal from '../sass-internal';
import * as utils from '../utils';
import {Expression} from '.';

/**
* The initializer properties for {@link VariableExpression}.
*
* @category Expression
*/
export interface VariableExpressionProps extends NodeProps {
namespace?: string;
variableName: string;
raws?: VariableExpressionRaws;
}

/**
* Raws indicating how to precisely serialize a {@link VariableExpression}.
*
* @category Expression
*/
export interface VariableExpressionRaws {
/**
* The variable's namespace.
*
* This may be different than {@link VariableExpression.namespace} if the
* namespace contains escape codes.
*/
namespace?: RawWithValue<string>;

/**
* The variable's name.
*
* This may be different than {@link VariableExpression.varialbeName} if the
* name contains escape codes or underscores.
*/
variableName?: RawWithValue<string>;
}

/**
* An expression representing a variable reference in Sass.
*
* @category Expression
*/
export class VariableExpression extends Expression {
readonly sassType = 'variable' as const;
declare raws: VariableExpressionRaws;

/**
* This variable's namespace.
*
* This is the parsed and normalized value, with escapes resolved to the
* characters they represent.
*/
get namespace(): string | undefined {
return this._namespace;
}
set namespace(namespace: string | undefined) {
// TODO - postcss/postcss#1957: Mark this as dirty
this._namespace = namespace;
}
private declare _namespace: string | undefined;

/**
* This variable's name.
*
* This is the parsed and normalized value, with underscores converted to
* hyphens and escapes resolved to the characters they represent.
*/
get variableName(): string {
return this._variableName;
}
set variableName(variableName: string) {
// TODO - postcss/postcss#1957: Mark this as dirty
this._variableName = variableName;
}
private declare _variableName: string;

constructor(defaults: VariableExpressionProps);
/** @hidden */
constructor(_: undefined, inner: sassInternal.VariableExpression);
constructor(defaults?: object, inner?: sassInternal.VariableExpression) {
super(defaults);
if (inner) {
this.source = new LazySource(inner);
this.namespace = inner.namespace ?? undefined;
this.variableName = inner.name;
}
}

clone(overrides?: Partial<VariableExpressionProps>): this {
return utils.cloneNode(this, overrides, [
'raws',
{name: 'namespace', explicitUndefined: true},
'variableName',
]);
}

toJSON(): object;
/** @hidden */
toJSON(_: string, inputs: Map<postcss.Input, number>): object;
toJSON(_?: string, inputs?: Map<postcss.Input, number>): object {
return utils.toJSON(this, ['namespace', 'variableName'], inputs);
}

/** @hidden */
toString(): string {
return (
(this.namespace
? (this.raws.namespace?.value === this.namespace
? this.raws.namespace.raw
: sassInternal.toCssIdentifier(this.namespace)) + '.'
: '') +
'$' +
(this.raws.variableName?.value === this.variableName
? this.raws.variableName.raw
: sassInternal.toCssIdentifier(this.variableName))
);
}

/** @hidden */
get nonStatementChildren(): ReadonlyArray<never> {
return [];
}
}
7 changes: 7 additions & 0 deletions pkg/sass-parser/lib/src/sass-internal.ts
Original file line number Diff line number Diff line change
@@ -397,6 +397,11 @@ declare namespace SassInternal {
readonly operator: UnaryOperator;
readonly operand: Expression;
}

class VariableExpression extends Expression {
readonly namespace?: string | null;
readonly name: string;
}
}

const sassInternal = (
@@ -461,6 +466,7 @@ export type SelectorExpression = SassInternal.SelectorExpression;
export type StringExpression = SassInternal.StringExpression;
export type SupportsExpression = SassInternal.SupportsExpression;
export type UnaryOperationExpression = SassInternal.UnaryOperationExpression;
export type VariableExpression = SassInternal.VariableExpression;

export interface StatementVisitorObject<T> {
visitAtRootRule(node: AtRootRule): T;
@@ -506,6 +512,7 @@ export interface ExpressionVisitorObject<T> {
visitStringExpression(node: StringExpression): T;
visitSupportsExpression(node: SupportsExpression): T;
visitUnaryOperationExpression(node: UnaryOperationExpression): T;
visitVariableExpression(node: VariableExpression): T;
}

export const createExpressionVisitor = sassInternal.createExpressionVisitor;
2 changes: 1 addition & 1 deletion pkg/sass-parser/lib/src/statement/include-rule.ts
Original file line number Diff line number Diff line change
@@ -34,7 +34,7 @@ export interface IncludeRuleRaws extends Omit<AtRuleRaws, 'params'> {
* The mixin's namespace.
*
* This may be different than {@link IncludeRule.namespace} if the name
* contains escape codes or underscores.
* contains escape codes.
*/
namespace?: RawWithValue<string>;

2 changes: 1 addition & 1 deletion pkg/sass-parser/lib/src/statement/variable-declaration.ts
Original file line number Diff line number Diff line change
@@ -28,7 +28,7 @@ export interface VariableDeclarationRaws
* The variable's namespace.
*
* This may be different than {@link VariableDeclaration.namespace} if the
* name contains escape codes or underscores.
* name contains escape codes.
*/
namespace?: RawWithValue<string>;