From 95ed807245027d1ec49800cc7cd9a2e5a5302c2d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Israel=20Ortiz=20Garc=C3=ADa?= Date: Wed, 2 Oct 2024 21:28:47 -0700 Subject: [PATCH] Add NumberExpression to sass-parser --- pkg/sass-parser/README.md | 4 +- pkg/sass-parser/lib/index.ts | 5 + .../__snapshots__/number.test.ts.snap | 18 ++ pkg/sass-parser/lib/src/expression/convert.ts | 2 + .../lib/src/expression/from-props.ts | 7 +- pkg/sass-parser/lib/src/expression/index.ts | 15 +- .../lib/src/expression/number.test.ts | 176 ++++++++++++++++++ pkg/sass-parser/lib/src/expression/number.ts | 100 ++++++++++ pkg/sass-parser/lib/src/sass-internal.ts | 7 + 9 files changed, 327 insertions(+), 7 deletions(-) create mode 100644 pkg/sass-parser/lib/src/expression/__snapshots__/number.test.ts.snap create mode 100644 pkg/sass-parser/lib/src/expression/number.test.ts create mode 100644 pkg/sass-parser/lib/src/expression/number.ts diff --git a/pkg/sass-parser/README.md b/pkg/sass-parser/README.md index 2c4c451a0..1b865c3c5 100644 --- a/pkg/sass-parser/README.md +++ b/pkg/sass-parser/README.md @@ -261,9 +261,9 @@ There are a few cases where an operation that's valid in PostCSS won't work with ## Contributing Before sending out a pull request, please run the following commands from the -`sass-parser` directory: +`pkg/sass-parser` directory: * `npm run check` - Runs `eslint`, and then tries to compile the package with `tsc`. -* `npm run test` - Runs all tests in the package. +* `npm run test` - Runs all the tests in the package. diff --git a/pkg/sass-parser/lib/index.ts b/pkg/sass-parser/lib/index.ts index 152a28452..2b699848c 100644 --- a/pkg/sass-parser/lib/index.ts +++ b/pkg/sass-parser/lib/index.ts @@ -32,6 +32,11 @@ export { BooleanExpressionProps, BooleanExpressionRaws, } from './src/expression/boolean'; +export { + NumberExpression, + NumberExpressionProps, + NumberExpressionRaws, +} from './src/expression/number'; export { Interpolation, InterpolationProps, diff --git a/pkg/sass-parser/lib/src/expression/__snapshots__/number.test.ts.snap b/pkg/sass-parser/lib/src/expression/__snapshots__/number.test.ts.snap new file mode 100644 index 000000000..6af882031 --- /dev/null +++ b/pkg/sass-parser/lib/src/expression/__snapshots__/number.test.ts.snap @@ -0,0 +1,18 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`a number expression toJSON 1`] = ` +{ + "inputs": [ + { + "css": "@#{123%}", + "hasBOM": false, + "id": "", + }, + ], + "raws": {}, + "sassType": "number", + "source": <1:4-1:8 in 0>, + "unit": "%", + "value": 123, +} +`; diff --git a/pkg/sass-parser/lib/src/expression/convert.ts b/pkg/sass-parser/lib/src/expression/convert.ts index 3219844cb..52cbb2363 100644 --- a/pkg/sass-parser/lib/src/expression/convert.ts +++ b/pkg/sass-parser/lib/src/expression/convert.ts @@ -8,6 +8,7 @@ import {BinaryOperationExpression} from './binary-operation'; import {StringExpression} from './string'; import {Expression} from '.'; import {BooleanExpression} from './boolean'; +import {NumberExpression} from './number'; /** The visitor to use to convert internal Sass nodes to JS. */ const visitor = sassInternal.createExpressionVisitor({ @@ -15,6 +16,7 @@ const visitor = sassInternal.createExpressionVisitor({ new BinaryOperationExpression(undefined, inner), visitStringExpression: inner => new StringExpression(undefined, inner), visitBooleanExpression: inner => new BooleanExpression(undefined, inner), + visitNumberExpression: inner => new NumberExpression(undefined, inner), }); /** Converts an internal expression AST node into an external one. */ diff --git a/pkg/sass-parser/lib/src/expression/from-props.ts b/pkg/sass-parser/lib/src/expression/from-props.ts index 520ea1dfb..d74450813 100644 --- a/pkg/sass-parser/lib/src/expression/from-props.ts +++ b/pkg/sass-parser/lib/src/expression/from-props.ts @@ -6,11 +6,16 @@ import {BinaryOperationExpression} from './binary-operation'; import {Expression, ExpressionProps} from '.'; import {StringExpression} from './string'; import {BooleanExpression} from './boolean'; +import {NumberExpression} from './number'; /** Constructs an expression from {@link ExpressionProps}. */ export function fromProps(props: ExpressionProps): Expression { if ('text' in props) return new StringExpression(props); if ('left' in props) return new BinaryOperationExpression(props); - if ('value' in props) return new BooleanExpression(props); + if ('value' in props) { + if (typeof props.value === 'boolean') return new BooleanExpression(props); + if (typeof props.value === 'number') return new NumberExpression(props); + } + throw new Error(`Unknown node type: ${props}`); } diff --git a/pkg/sass-parser/lib/src/expression/index.ts b/pkg/sass-parser/lib/src/expression/index.ts index 67cb8a3c4..ac1d37671 100644 --- a/pkg/sass-parser/lib/src/expression/index.ts +++ b/pkg/sass-parser/lib/src/expression/index.ts @@ -7,7 +7,8 @@ import type { BinaryOperationExpression, BinaryOperationExpressionProps, } from './binary-operation'; -import {BooleanExpressionProps} from './boolean'; +import {BooleanExpression, BooleanExpressionProps} from './boolean'; +import {NumberExpression, NumberExpressionProps} from './number'; import type {StringExpression, StringExpressionProps} from './string'; /** @@ -18,14 +19,19 @@ import type {StringExpression, StringExpressionProps} from './string'; export type AnyExpression = | BinaryOperationExpression | StringExpression - | BooleanExpressionProps; + | BooleanExpression + | NumberExpression; /** * Sass expression types. * * @category Expression */ -export type ExpressionType = 'binary-operation' | 'string' | 'boolean'; +export type ExpressionType = + | 'binary-operation' + | 'string' + | 'boolean' + | 'number'; /** * The union type of all properties that can be used to construct Sass @@ -36,7 +42,8 @@ export type ExpressionType = 'binary-operation' | 'string' | 'boolean'; export type ExpressionProps = | BinaryOperationExpressionProps | StringExpressionProps - | BooleanExpressionProps; + | BooleanExpressionProps + | NumberExpressionProps; /** * The superclass of Sass expression nodes. diff --git a/pkg/sass-parser/lib/src/expression/number.test.ts b/pkg/sass-parser/lib/src/expression/number.test.ts new file mode 100644 index 000000000..c2fa9fdb2 --- /dev/null +++ b/pkg/sass-parser/lib/src/expression/number.test.ts @@ -0,0 +1,176 @@ +// Copyright 2024 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 {NumberExpression} from '../..'; +import * as utils from '../../../test/utils'; + +describe('a number expression', () => { + let node: NumberExpression; + + describe('unitless', () => { + function describeNode( + description: string, + create: () => NumberExpression + ): void { + describe(description, () => { + beforeEach(() => void (node = create())); + + it('has sassType number', () => expect(node.sassType).toBe('number')); + + it('is a number', () => expect(node.value).toBe(123)); + + it('has no unit', () => expect(node.unit).toBeNull()); + }); + } + + describeNode('parsed', () => utils.parseExpression('123')); + + describeNode( + 'constructed manually', + () => + new NumberExpression({ + value: 123, + }) + ); + + describeNode('constructed from ExpressionProps', () => + utils.fromExpressionProps({ + value: 123, + }) + ); + }); + + describe('with a unit', () => { + function describeNode( + description: string, + create: () => NumberExpression + ): void { + describe(description, () => { + beforeEach(() => void (node = create())); + + it('has sassType number', () => expect(node.sassType).toBe('number')); + + it('is a number', () => expect(node.value).toBe(123)); + + it('has a unit', () => expect(node.unit).toBe('px')); + }); + } + + describeNode('parsed', () => utils.parseExpression('123px')); + + describeNode( + 'constructed manually', + () => + new NumberExpression({ + value: 123, + unit: 'px', + }) + ); + + describeNode('constructed from ExpressionProps', () => + utils.fromExpressionProps({ + value: 123, + unit: 'px', + }) + ); + }); + + describe('floating-point number', () => { + describe('unitless', () => { + beforeEach(() => void (node = utils.parseExpression('3.14'))); + + it('value', () => expect(node.value).toBe(3.14)); + + it('unit', () => expect(node.unit).toBeNull()); + }); + + describe('with a unit', () => { + beforeEach(() => void (node = utils.parseExpression('1.618px'))); + + it('value', () => expect(node.value).toBe(1.618)); + + it('unit', () => expect(node.unit).toBe('px')); + }); + }); + + describe('assigned new', () => { + beforeEach(() => void (node = utils.parseExpression('123'))); + + it('value', () => { + node.value = 456; + expect(node.value).toBe(456); + }); + + it('unit', () => { + node.unit = 'px'; + expect(node.unit).toBe('px'); + }); + }); + + describe('stringifies', () => { + it('unitless', () => { + expect(utils.parseExpression('123').toString()).toBe('123'); + }); + + it('with a unit', () => { + expect(utils.parseExpression('123px').toString()).toBe('123px'); + }); + }); + + describe('clone', () => { + let original: NumberExpression; + + beforeEach(() => { + original = utils.parseExpression('123'); + }); + + describe('with no overrides', () => { + let clone: NumberExpression; + + beforeEach(() => void (clone = original.clone())); + + describe('has the same properties:', () => { + it('value', () => expect(clone.value).toBe(123)); + + it('unit', () => expect(clone.unit).toBeNull()); + + it('raws', () => expect(clone.raws).toEqual({})); + + it('source', () => expect(clone.source).toBe(original.source)); + }); + + describe('creates a new', () => { + it('self', () => expect(clone).not.toBe(original)); + }); + }); + + describe('overrides', () => { + describe('value', () => { + it('defined', () => + expect(original.clone({value: 123}).value).toBe(123)); + + it('undefined', () => + expect(original.clone({value: undefined}).value).toBe(123)); + }); + + describe('unit', () => { + it('defined', () => + expect(original.clone({unit: 'px'}).unit).toBe('px')); + + it('undefined', () => + expect(original.clone({unit: undefined}).unit).toBeNull()); + }); + + describe('raws', () => { + it('defined', () => + expect(original.clone({raws: {}}).raws).toEqual({})); + + it('undefined', () => + expect(original.clone({raws: undefined}).raws).toEqual({})); + }); + }); + }); + + it('toJSON', () => expect(utils.parseExpression('123%')).toMatchSnapshot()); +}); diff --git a/pkg/sass-parser/lib/src/expression/number.ts b/pkg/sass-parser/lib/src/expression/number.ts new file mode 100644 index 000000000..5ebbe6064 --- /dev/null +++ b/pkg/sass-parser/lib/src/expression/number.ts @@ -0,0 +1,100 @@ +// Copyright 2024 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 type * as sassInternal from '../sass-internal'; +import * as utils from '../utils'; +import {Expression} from '.'; + +/** + * The initializer properties for {@link NumberExpression}. + * + * @category Expression + */ +export interface NumberExpressionProps { + value: number; + unit?: string; + raws?: NumberExpressionRaws; +} + +/** + * Raws indicating how to precisely serialize a {@link NumberExpression}. + * + * @category Expression + */ +// eslint-disable-next-line @typescript-eslint/no-empty-interface -- No raws for a number expression yet. +export interface NumberExpressionRaws {} + +/** + * An expression representing a number literal in Sass. + * + * @category Expression + */ +export class NumberExpression extends Expression { + readonly sassType = 'number' as const; + declare raws: NumberExpressionRaws; + + /** The numeric value of this expression. */ + get value(): number { + return this._value; + } + set value(value: number) { + // TODO - postcss/postcss#1957: Mark this as dirty + this._value = value; + } + private _value!: number; + + /** The denominator units of this number. */ + get unit(): string | null { + return this._unit; + } + set unit(unit: string | null) { + // TODO - postcss/postcss#1957: Mark this as dirty + this._unit = unit; + } + private _unit!: string | null; + + /** Whether the number is unitless. */ + isUnitless(): boolean { + return this.unit === null; + } + + constructor(defaults: NumberExpressionProps); + /** @hidden */ + constructor(_: undefined, inner: sassInternal.NumberExpression); + constructor(defaults?: object, inner?: sassInternal.NumberExpression) { + super(defaults); + if (inner) { + this.source = new LazySource(inner); + this.value = inner.value; + this.unit = inner.unit; + } else { + this.value ??= 0; + this.unit ??= null; + } + } + + clone(overrides?: Partial): this { + return utils.cloneNode(this, overrides, ['raws', 'value', 'unit']); + } + + toJSON(): object; + /** @hidden */ + toJSON(_: string, inputs: Map): object; + toJSON(_?: string, inputs?: Map): object { + return utils.toJSON(this, ['value', 'unit'], inputs); + } + + /** @hidden */ + toString(): string { + return this.value + (this.unit ?? ''); + } + + /** @hidden */ + get nonStatementChildren(): ReadonlyArray { + return []; + } +} diff --git a/pkg/sass-parser/lib/src/sass-internal.ts b/pkg/sass-parser/lib/src/sass-internal.ts index 2b55f0abb..aa2423276 100644 --- a/pkg/sass-parser/lib/src/sass-internal.ts +++ b/pkg/sass-parser/lib/src/sass-internal.ts @@ -195,6 +195,11 @@ declare namespace SassInternal { class BooleanExpression extends Expression { readonly value: boolean; } + + class NumberExpression extends Expression { + readonly value: number; + readonly unit: string; + } } const sassInternal = ( @@ -223,6 +228,7 @@ export type Expression = SassInternal.Expression; export type BinaryOperationExpression = SassInternal.BinaryOperationExpression; export type StringExpression = SassInternal.StringExpression; export type BooleanExpression = SassInternal.BooleanExpression; +export type NumberExpression = SassInternal.NumberExpression; export interface StatementVisitorObject { visitAtRootRule(node: AtRootRule): T; @@ -243,6 +249,7 @@ export interface ExpressionVisitorObject { visitBinaryOperationExpression(node: BinaryOperationExpression): T; visitStringExpression(node: StringExpression): T; visitBooleanExpression(node: BooleanExpression): T; + visitNumberExpression(node: NumberExpression): T; } export const parse = sassInternal.parse;