Skip to content

Commit

Permalink
Add support for parsing unary operations (#2538)
Browse files Browse the repository at this point in the history
  • Loading branch information
nex3 authored Mar 10, 2025
1 parent 734e9de commit 2680d5f
Show file tree
Hide file tree
Showing 9 changed files with 439 additions and 4 deletions.
2 changes: 2 additions & 0 deletions pkg/sass-parser/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@

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

* Add support for parsing unary operation expressions.

## 0.4.15

* Add support for parsing list expressions.
Expand Down
5 changes: 5 additions & 0 deletions pkg/sass-parser/lib/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,11 @@ export {
StaticImportProps,
StaticImportRaws,
} from './src/static-import';
export {
UnaryOperationExpression,
UnaryOperationExpressionProps,
UnaryOperationExpressionRaws,
} from './src/expression/unary-operation';

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

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

/** The visitor to use to convert internal Sass nodes to JS. */
const visitor = sassInternal.createExpressionVisitor<AnyExpression>({
Expand Down Expand Up @@ -52,6 +53,8 @@ const visitor = sassInternal.createExpressionVisitor<AnyExpression>({
]),
source: new LazySource(inner),
}),
visitUnaryOperationExpression: inner =>
new UnaryOperationExpression(undefined, inner),
});

/** Converts an internal expression AST node into an external one. */
Expand Down
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
Expand Up @@ -15,11 +15,13 @@ import {NullExpression} from './null';
import {NumberExpression} from './number';
import {ParenthesizedExpression} from './parenthesized';
import {StringExpression} from './string';
import {UnaryOperationExpression} from './unary-operation';

/** Constructs an expression from {@link ExpressionProps}. */
export function fromProps(props: ExpressionProps): AnyExpression {
if ('text' in props) return new StringExpression(props);
if ('left' in props) return new BinaryOperationExpression(props);
if ('operand' in props) return new UnaryOperationExpression(props);
if ('separator' in props) return new ListExpression(props);
if ('nodes' in props) return new MapExpression(props);
if ('inParens' in props) return new ParenthesizedExpression(props);
Expand Down
13 changes: 10 additions & 3 deletions pkg/sass-parser/lib/src/expression/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@ import {
} from './parenthesized';
import type {SelectorExpression} from './selector';
import type {StringExpression, StringExpressionProps} from './string';
import type {
UnaryOperationExpression,
UnaryOperationExpressionProps,
} from './unary-operation';

/**
* The union type of all Sass expressions.
Expand All @@ -42,7 +46,8 @@ export type AnyExpression =
| NumberExpression
| ParenthesizedExpression
| SelectorExpression
| StringExpression;
| StringExpression
| UnaryOperationExpression;

/**
* Sass expression types.
Expand All @@ -61,7 +66,8 @@ export type ExpressionType =
| 'number'
| 'parenthesized'
| 'selector-expr'
| 'string';
| 'string'
| 'unary-operation';

/**
* The union type of all properties that can be used to construct Sass
Expand All @@ -80,7 +86,8 @@ export type ExpressionProps =
| NullExpressionProps
| NumberExpressionProps
| ParenthesizedExpressionProps
| StringExpressionProps;
| StringExpressionProps
| UnaryOperationExpressionProps;

/**
* The superclass of Sass expression nodes.
Expand Down
269 changes: 269 additions & 0 deletions pkg/sass-parser/lib/src/expression/unary-operation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,269 @@
// 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 {StringExpression, UnaryOperationExpression} from '../..';
import * as utils from '../../../test/utils';

describe('a unary operation', () => {
let node: UnaryOperationExpression;
function describeNode(
description: string,
create: () => UnaryOperationExpression,
): void {
describe(description, () => {
beforeEach(() => void (node = create()));

it('has sassType unary-operation', () =>
expect(node.sassType).toBe('unary-operation'));

it('has an operator', () => expect(node.operator).toBe('+'));

it('has an operand', () =>
expect(node).toHaveStringExpression('operand', 'foo'));
});
}

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

describeNode(
'constructed manually',
() =>
new UnaryOperationExpression({
operator: '+',
operand: {text: 'foo'},
}),
);

describeNode('constructed from ExpressionProps', () =>
utils.fromExpressionProps({
operator: '+',
operand: {text: 'foo'},
}),
);

describe('assigned new', () => {
beforeEach(() => void (node = utils.parseExpression('+foo')));

it('operator', () => {
node.operator = 'not';
expect(node.operator).toBe('not');
});

describe('operand', () => {
it("removes the old operand's parent", () => {
const oldOperand = node.operand;
node.operand = {text: 'zip'};
expect(oldOperand.parent).toBeUndefined();
});

it('assigns operand explicitly', () => {
const operand = new StringExpression({text: 'zip'});
node.operand = operand;
expect(node.operand).toBe(operand);
expect(node.operand.parent).toBe(node);
expect(node).toHaveStringExpression('operand', 'zip');
});

it('assigns operand as ExpressionProps', () => {
node.operand = {text: 'zip'};
expect(node).toHaveStringExpression('operand', 'zip');
});
});
});

describe('stringifies', () => {
describe('plus', () => {
describe('with an identifier', () => {
beforeEach(
() =>
void (node = new UnaryOperationExpression({
operator: '+',
operand: {text: 'foo'},
})),
);

it('without raws', () => expect(node.toString()).toBe('+foo'));

it('with between', () => {
node.raws.between = '/**/';
expect(node.toString()).toBe('+/**/foo');
});
});

describe('with a number', () => {
beforeEach(
() =>
void (node = new UnaryOperationExpression({
operator: '+',
operand: {value: 0},
})),
);

it('without raws', () => expect(node.toString()).toBe('+ 0'));

it('with between', () => {
node.raws.between = '/**/';
expect(node.toString()).toBe('+/**/0');
});
});
});

describe('not', () => {
beforeEach(
() =>
void (node = new UnaryOperationExpression({
operator: 'not',
operand: {text: 'foo'},
})),
);

it('without raws', () => expect(node.toString()).toBe('not foo'));

it('with between', () => {
node.raws.between = '/**/';
expect(node.toString()).toBe('not/**/foo');
});
});

describe('minus', () => {
describe('with an identifier', () => {
beforeEach(
() =>
void (node = new UnaryOperationExpression({
operator: '-',
operand: {text: 'foo'},
})),
);

it('without raws', () => expect(node.toString()).toBe('- foo'));

it('with between', () => {
node.raws.between = '/**/';
expect(node.toString()).toBe('-/**/foo');
});
});

describe('with a number', () => {
beforeEach(
() =>
void (node = new UnaryOperationExpression({
operator: '-',
operand: {value: 0},
})),
);

it('without raws', () => expect(node.toString()).toBe('- 0'));

it('with between', () => {
node.raws.between = '/**/';
expect(node.toString()).toBe('-/**/0');
});
});

describe('with a function call', () => {
beforeEach(
() =>
void (node = new UnaryOperationExpression({
operator: '-',
operand: {name: 'foo', arguments: []},
})),
);

it('without raws', () => expect(node.toString()).toBe('- foo()'));

it('with between', () => {
node.raws.between = '/**/';
expect(node.toString()).toBe('-/**/foo()');
});
});

describe('with a parenthesized expression', () => {
beforeEach(
() =>
void (node = new UnaryOperationExpression({
operator: '-',
operand: {inParens: {text: 'foo'}},
})),
);

it('without raws', () => expect(node.toString()).toBe('-(foo)'));

it('with between', () => {
node.raws.between = '/**/';
expect(node.toString()).toBe('-/**/(foo)');
});
});
});
});

describe('clone', () => {
let original: UnaryOperationExpression;
beforeEach(() => {
original = utils.parseExpression('+foo');
// TODO: remove this once raws are properly parsed
original.raws.between = ' ';
});

describe('with no overrides', () => {
let clone: UnaryOperationExpression;
beforeEach(() => void (clone = original.clone()));

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

it('operand', () =>
expect(clone).toHaveStringExpression('operand', 'foo'));

it('raws', () => expect(clone.raws).toEqual({between: ' '}));

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

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

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

describe('overrides', () => {
describe('operator', () => {
it('defined', () =>
expect(original.clone({operator: '-'}).operator).toBe('-'));

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

describe('operand', () => {
it('defined', () =>
expect(
original.clone({operand: {text: 'zip'}}),
).toHaveStringExpression('operand', 'zip'));

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

describe('raws', () => {
it('defined', () =>
expect(original.clone({raws: {between: '/**/'}}).raws).toEqual({
between: '/**/',
}));

it('undefined', () =>
expect(original.clone({raws: undefined}).raws).toEqual({
between: ' ',
}));
});
});
});

it('toJSON', () => expect(utils.parseExpression('+foo')).toMatchSnapshot());
});
Loading

0 comments on commit 2680d5f

Please sign in to comment.