diff --git a/pkg/sass-parser/CHANGELOG.md b/pkg/sass-parser/CHANGELOG.md index ced66daf1..5929ae329 100644 --- a/pkg/sass-parser/CHANGELOG.md +++ b/pkg/sass-parser/CHANGELOG.md @@ -2,6 +2,8 @@ * Add support for parsing list expressions. +* Add support for parsing map expressions. + ## 0.4.14 * Add support for parsing color expressions. diff --git a/pkg/sass-parser/lib/index.ts b/pkg/sass-parser/lib/index.ts index acb2fa742..b0912611f 100644 --- a/pkg/sass-parser/lib/index.ts +++ b/pkg/sass-parser/lib/index.ts @@ -77,6 +77,17 @@ export { ListSeparator, NewNodeForListExpression, } from './src/expression/list'; +export { + MapEntry, + MapEntryProps, + MapEntryRaws, +} from './src/expression/map-entry'; +export { + MapExpression, + MapExpressionProps, + MapExpressionRaws, + NewNodeForMapExpression, +} from './src/expression/map'; export { NumberExpression, NumberExpressionProps, diff --git a/pkg/sass-parser/lib/src/expression/__snapshots__/map-entry.test.ts.snap b/pkg/sass-parser/lib/src/expression/__snapshots__/map-entry.test.ts.snap new file mode 100644 index 000000000..784298934 --- /dev/null +++ b/pkg/sass-parser/lib/src/expression/__snapshots__/map-entry.test.ts.snap @@ -0,0 +1,17 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`a map entry toJSON 1`] = ` +{ + "inputs": [ + { + "css": "@#{(baz: qux)}", + "hasBOM": false, + "id": "", + }, + ], + "key": , + "raws": {}, + "sassType": "map-entry", + "value": , +} +`; diff --git a/pkg/sass-parser/lib/src/expression/__snapshots__/map.test.ts.snap b/pkg/sass-parser/lib/src/expression/__snapshots__/map.test.ts.snap new file mode 100644 index 000000000..d3345b161 --- /dev/null +++ b/pkg/sass-parser/lib/src/expression/__snapshots__/map.test.ts.snap @@ -0,0 +1,20 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`a map expression toJSON 1`] = ` +{ + "inputs": [ + { + "css": "@#{(foo: bar, baz: bang)}", + "hasBOM": false, + "id": "", + }, + ], + "nodes": [ + , + , + ], + "raws": {}, + "sassType": "map", + "source": <1:4-1:25 in 0>, +} +`; diff --git a/pkg/sass-parser/lib/src/expression/convert.ts b/pkg/sass-parser/lib/src/expression/convert.ts index 00e888d69..eb522da5d 100644 --- a/pkg/sass-parser/lib/src/expression/convert.ts +++ b/pkg/sass-parser/lib/src/expression/convert.ts @@ -9,6 +9,7 @@ import {BinaryOperationExpression} from './binary-operation'; import {BooleanExpression} from './boolean'; import {ColorExpression} from './color'; import {ListExpression} from './list'; +import {MapExpression} from './map'; import {NumberExpression} from './number'; import {StringExpression} from './string'; @@ -20,6 +21,7 @@ const visitor = sassInternal.createExpressionVisitor({ visitBooleanExpression: inner => new BooleanExpression(undefined, inner), visitColorExpression: inner => new ColorExpression(undefined, inner), visitListExpression: inner => new ListExpression(undefined, inner), + visitMapExpression: inner => new MapExpression(undefined, inner), visitNumberExpression: inner => new NumberExpression(undefined, inner), }); diff --git a/pkg/sass-parser/lib/src/expression/from-props.ts b/pkg/sass-parser/lib/src/expression/from-props.ts index 776118ee9..33eb0bdcd 100644 --- a/pkg/sass-parser/lib/src/expression/from-props.ts +++ b/pkg/sass-parser/lib/src/expression/from-props.ts @@ -8,6 +8,7 @@ import {BinaryOperationExpression} from './binary-operation'; import {BooleanExpression} from './boolean'; import {ColorExpression} from './color'; import {ListExpression} from './list'; +import {MapExpression} from './map'; import {NumberExpression} from './number'; import {StringExpression} from './string'; @@ -16,6 +17,7 @@ export function fromProps(props: ExpressionProps): Expression { if ('text' in props) return new StringExpression(props); if ('left' in props) return new BinaryOperationExpression(props); if ('separator' in props) return new ListExpression(props); + if ('nodes' in props) return new MapExpression(props); if ('value' in props) { if (typeof props.value === 'boolean') return new BooleanExpression(props); if (typeof props.value === 'number') return new NumberExpression(props); diff --git a/pkg/sass-parser/lib/src/expression/index.ts b/pkg/sass-parser/lib/src/expression/index.ts index 0056fdeed..0dc1d14f0 100644 --- a/pkg/sass-parser/lib/src/expression/index.ts +++ b/pkg/sass-parser/lib/src/expression/index.ts @@ -10,6 +10,7 @@ import type { import {BooleanExpression, BooleanExpressionProps} from './boolean'; import {ColorExpression, ColorExpressionProps} from './color'; import {ListExpression, ListExpressionProps} from './list'; +import {MapExpression, MapExpressionProps} from './map'; import {NumberExpression, NumberExpressionProps} from './number'; import type {StringExpression, StringExpressionProps} from './string'; @@ -23,6 +24,7 @@ export type AnyExpression = | BooleanExpression | ColorExpression | ListExpression + | MapExpression | NumberExpression | StringExpression; @@ -36,6 +38,7 @@ export type ExpressionType = | 'boolean' | 'color' | 'list' + | 'map' | 'number' | 'string'; @@ -50,6 +53,7 @@ export type ExpressionProps = | BooleanExpressionProps | ColorExpressionProps | ListExpressionProps + | MapExpressionProps | NumberExpressionProps | StringExpressionProps; diff --git a/pkg/sass-parser/lib/src/expression/map-entry.test.ts b/pkg/sass-parser/lib/src/expression/map-entry.test.ts new file mode 100644 index 000000000..8fb026df0 --- /dev/null +++ b/pkg/sass-parser/lib/src/expression/map-entry.test.ts @@ -0,0 +1,206 @@ +// 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 {MapEntry, MapExpression, StringExpression} from '../..'; +import * as utils from '../../../test/utils'; + +describe('a map entry', () => { + let node: MapEntry; + beforeEach( + () => + void (node = new MapEntry({ + key: {text: 'foo'}, + value: {text: 'bar'}, + })), + ); + + function describeNode(description: string, create: () => MapEntry): void { + describe(description, () => { + beforeEach(() => (node = create())); + + it('has a sassType', () => + expect(node.sassType.toString()).toBe('map-entry')); + + it('has a key', () => expect(node).toHaveStringExpression('key', 'foo')); + + it('has a value', () => + expect(node).toHaveStringExpression('value', 'bar')); + }); + } + + describeNode( + 'parsed', + () => (utils.parseExpression('(foo: bar)') as MapExpression).nodes[0], + ); + + describe('constructed manually', () => { + describe('with an array', () => { + describeNode( + 'with two Expressions', + () => + new MapEntry([ + new StringExpression({text: 'foo'}), + new StringExpression({text: 'bar'}), + ]), + ); + + describeNode( + 'with two ExpressionProps', + () => new MapEntry([{text: 'foo'}, {text: 'bar'}]), + ); + + describeNode( + 'with mixed Expressions and ExpressionProps', + () => + new MapEntry([{text: 'foo'}, new StringExpression({text: 'bar'})]), + ); + }); + + describe('with an object', () => { + describeNode( + 'with two Expressions', + () => + new MapEntry({ + key: new StringExpression({text: 'foo'}), + value: new StringExpression({text: 'bar'}), + }), + ); + + describeNode( + 'with ExpressionProps', + () => new MapEntry({key: {text: 'foo'}, value: {text: 'bar'}}), + ); + }); + }); + + it('assigned a new key', () => { + const old = node.key; + node.key = {text: 'baz'}; + expect(old.parent).toBeUndefined(); + expect(node).toHaveStringExpression('key', 'baz'); + }); + + it('assigned a new value', () => { + const old = node.value; + node.value = {text: 'baz'}; + expect(old.parent).toBeUndefined(); + expect(node).toHaveStringExpression('value', 'baz'); + }); + + describe('stringifies', () => { + describe('to SCSS', () => { + it('with default raws', () => + expect( + new MapEntry({ + key: {text: 'foo'}, + value: {text: 'bar'}, + }).toString(), + ).toBe('foo: bar')); + + // raws.before is only used as part of a MapExpression + it('ignores before', () => + expect( + new MapEntry({ + key: {text: 'foo'}, + value: {text: 'bar'}, + raws: {before: '/**/'}, + }).toString(), + ).toBe('foo: bar')); + + it('with between', () => + expect( + new MapEntry({ + key: {text: 'foo'}, + value: {text: 'bar'}, + raws: {between: ' : '}, + }).toString(), + ).toBe('foo : bar')); + + // raws.after is only used as part of a Configuration + it('ignores after', () => + expect( + new MapEntry({ + key: {text: 'foo'}, + value: {text: 'bar'}, + raws: {after: '/**/'}, + }).toString(), + ).toBe('foo: bar')); + }); + }); + + describe('clone()', () => { + let original: MapEntry; + beforeEach(() => { + original = (utils.parseExpression('(foo: bar)') as MapExpression) + .nodes[0]; + original.raws.between = ' : '; + }); + + describe('with no overrides', () => { + let clone: MapEntry; + beforeEach(() => void (clone = original.clone())); + + describe('has the same properties:', () => { + it('key', () => expect(clone).toHaveStringExpression('key', 'foo')); + + it('value', () => expect(clone).toHaveStringExpression('value', 'bar')); + }); + + describe('creates a new', () => { + it('self', () => expect(clone).not.toBe(original)); + + for (const attr of ['key', 'value', 'raws'] as const) { + it(attr, () => expect(clone[attr]).not.toBe(original[attr])); + } + }); + }); + + describe('overrides', () => { + describe('raws', () => { + it('defined', () => + expect(original.clone({raws: {before: ' '}}).raws).toEqual({ + before: ' ', + })); + + it('undefined', () => + expect(original.clone({raws: undefined}).raws).toEqual({ + between: ' : ', + })); + }); + + describe('key', () => { + it('defined', () => + expect(original.clone({key: {text: 'baz'}})).toHaveStringExpression( + 'key', + 'baz', + )); + + it('undefined', () => + expect(original.clone({key: undefined})).toHaveStringExpression( + 'key', + 'foo', + )); + }); + + describe('value', () => { + it('defined', () => + expect(original.clone({value: {text: 'baz'}})).toHaveStringExpression( + 'value', + 'baz', + )); + + it('undefined', () => + expect(original.clone({value: undefined})).toHaveStringExpression( + 'value', + 'bar', + )); + }); + }); + }); + + it('toJSON', () => + expect( + (utils.parseExpression('(baz: qux)') as MapExpression).nodes[0], + ).toMatchSnapshot()); +}); diff --git a/pkg/sass-parser/lib/src/expression/map-entry.ts b/pkg/sass-parser/lib/src/expression/map-entry.ts new file mode 100644 index 000000000..d4cefc1d0 --- /dev/null +++ b/pkg/sass-parser/lib/src/expression/map-entry.ts @@ -0,0 +1,116 @@ +// 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 {Expression, ExpressionProps} from './index'; +import {fromProps} from './from-props'; +import {Node, NodeProps} from '../node'; +import {MapExpression} from './map'; +import * as utils from '../utils'; + +/** + * The set of raws supported by {@link MapEntry}. + * + * @category Expression + */ +export interface MapEntryRaws { + /** The whitespace before the key. */ + before?: string; + + /** The whitespace and colon between the key and its value. */ + between?: string; + + /** + * The space symbols between the end value and the comma afterwards. Always + * empty for an entry that doesn't have a trailing comma. + */ + after?: string; +} + +/** + * The initializer properties for {@link MapEntry} passed as an + * options object. + * + * @category Expression + */ +export interface MapEntryObjectProps extends NodeProps { + raws?: MapEntryRaws; + key: Expression | ExpressionProps; + value: Expression | ExpressionProps; +} + +/** + * The initializer properties for {@link MapEntry}. + * + * @category Expression + */ +export type MapEntryProps = + | MapEntryObjectProps + | [Expression | ExpressionProps, Expression | ExpressionProps]; + +/** + * A single key/value pair in a map literal. This is always included in a {@link + * Map}. + * + * @category Expression + */ +export class MapEntry extends Node { + readonly sassType = 'map-entry' as const; + declare raws: MapEntryRaws; + declare parent: MapExpression | undefined; + + /** The map key. */ + get key(): Expression { + return this._key!; + } + set key(key: Expression | ExpressionProps) { + if (this._key) this._key.parent = undefined; + if (!('sassType' in key)) key = fromProps(key); + if (key) key.parent = this; + this._key = key; + } + private declare _key?: Expression; + + /** The map value. */ + get value(): Expression { + return this._value!; + } + set value(value: Expression | ExpressionProps) { + if (this._value) this._value.parent = undefined; + if (!('sassType' in value)) value = fromProps(value); + if (value) value.parent = this; + this._value = value; + } + private declare _value?: Expression; + + constructor(defaults: MapEntryProps) { + if (Array.isArray(defaults)) { + defaults = {key: defaults[0], value: defaults[1]}; + } + super(defaults); + this.raws ??= {}; + } + + clone(overrides?: Partial): this { + return utils.cloneNode(this, overrides, ['raws', 'key', 'value']); + } + + toJSON(): object; + /** @hidden */ + toJSON(_: string, inputs: Map): object; + toJSON(_?: string, inputs?: Map): object { + return utils.toJSON(this, ['key', 'value'], inputs); + } + + /** @hidden */ + toString(): string { + return this.key + (this.raws.between ?? ': ') + this.value; + } + + /** @hidden */ + get nonStatementChildren(): ReadonlyArray { + return [this.key, this.value]; + } +} diff --git a/pkg/sass-parser/lib/src/expression/map.test.ts b/pkg/sass-parser/lib/src/expression/map.test.ts new file mode 100644 index 000000000..5ded7011a --- /dev/null +++ b/pkg/sass-parser/lib/src/expression/map.test.ts @@ -0,0 +1,966 @@ +// 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 {MapEntry, MapExpression, StringExpression} from '../..'; +import * as utils from '../../../test/utils'; + +type EachFn = Parameters[0]; + +let node: MapExpression; +describe('a map expression', () => { + describe('empty', () => { + function describeNode( + description: string, + create: () => MapExpression, + ): void { + describe(description, () => { + beforeEach(() => void (node = create())); + + it('has sassType map', () => expect(node.sassType).toBe('map')); + + it('is empty', () => expect(node.nodes).toHaveLength(0)); + }); + } + + // An empty map can't be parsed, because it's always parsed as a list + // instead. + + describeNode('constructed manually', () => new MapExpression({nodes: []})); + + describeNode('constructed from ExpressionProps', () => + utils.fromExpressionProps({nodes: []}), + ); + }); + + describe('with one pair', () => { + function describeNode( + description: string, + create: () => MapExpression, + ): void { + describe(description, () => { + beforeEach(() => void (node = create())); + + it('has sassType map', () => expect(node.sassType).toBe('map')); + + it('has an entry', () => { + expect(node.nodes).toHaveLength(1); + expect(node.nodes[0]).toHaveStringExpression('key', 'foo'); + expect(node.nodes[0]).toHaveStringExpression('value', 'bar'); + }); + }); + } + + describeNode('parsed', () => utils.parseExpression('(foo: bar)')); + + describeNode( + 'constructed manually', + () => + new MapExpression({ + nodes: [[{text: 'foo'}, {text: 'bar'}]], + }), + ); + + describeNode('constructed from ExpressionProps', () => + utils.fromExpressionProps({ + nodes: [[{text: 'foo'}, {text: 'bar'}]], + }), + ); + }); + + describe('with multiple elements ', () => { + function describeNode( + description: string, + create: () => MapExpression, + ): void { + describe(description, () => { + beforeEach(() => void (node = create())); + + it('has sassType map', () => expect(node.sassType).toBe('map')); + + it('has elements', () => { + expect(node.nodes).toHaveLength(2); + expect(node.nodes[0]).toHaveStringExpression('key', 'foo'); + expect(node.nodes[0]).toHaveStringExpression('value', 'bar'); + expect(node.nodes[1]).toHaveStringExpression('key', 'baz'); + expect(node.nodes[1]).toHaveStringExpression('value', 'qux'); + }); + }); + } + + describeNode('parsed', () => utils.parseExpression('(foo: bar, baz: qux)')); + + describeNode( + 'constructed manually', + () => + new MapExpression({ + nodes: [ + [{text: 'foo'}, {text: 'bar'}], + [{text: 'baz'}, {text: 'qux'}], + ], + }), + ); + + describeNode('constructed from ExpressionProps', () => + utils.fromExpressionProps({ + nodes: [ + [{text: 'foo'}, {text: 'bar'}], + [{text: 'baz'}, {text: 'qux'}], + ], + }), + ); + }); + + describe('can add', () => { + beforeEach(() => void (node = new MapExpression({nodes: []}))); + + it('a single entry', () => { + const entry = new MapEntry({key: {text: 'foo'}, value: {text: 'bar'}}); + node.append(entry); + expect(node.nodes[0]).toBe(entry); + expect(entry.parent).toBe(node); + }); + + it('a list of entries', () => { + const entry1 = new MapEntry({key: {text: 'foo'}, value: {text: 'bar'}}); + const entry2 = new MapEntry({key: {text: 'baz'}, value: {text: 'qux'}}); + node.append([entry1, entry2]); + expect(node.nodes[0]).toBe(entry1); + expect(node.nodes[1]).toBe(entry2); + expect(entry1.parent).toBe(node); + expect(entry2.parent).toBe(node); + }); + + it("a single entry's properties as an object", () => { + node.append({key: {text: 'foo'}, value: {text: 'bar'}}); + expect(node.nodes[0].parent).toBe(node); + expect(node.nodes[0]).toHaveStringExpression('key', 'foo'); + expect(node.nodes[0]).toHaveStringExpression('value', 'bar'); + }); + + it("a single entry's properties as a list", () => { + node.append([{text: 'foo'}, {text: 'bar'}]); + expect(node.nodes[0].parent).toBe(node); + expect(node.nodes[0]).toHaveStringExpression('key', 'foo'); + expect(node.nodes[0]).toHaveStringExpression('value', 'bar'); + }); + + it('a list of entry properties as objects', () => { + node.append([ + {key: {text: 'foo'}, value: {text: 'bar'}}, + {key: {text: 'baz'}, value: {text: 'qux'}}, + ]); + expect(node.nodes[0].parent).toBe(node); + expect(node.nodes[0]).toHaveStringExpression('key', 'foo'); + expect(node.nodes[0]).toHaveStringExpression('value', 'bar'); + expect(node.nodes[1].parent).toBe(node); + expect(node.nodes[1]).toHaveStringExpression('key', 'baz'); + expect(node.nodes[1]).toHaveStringExpression('value', 'qux'); + }); + + it('a list of entry properties as lists', () => { + node.append([ + [{text: 'foo'}, {text: 'bar'}], + [{text: 'baz'}, {text: 'qux'}], + ]); + expect(node.nodes[0].parent).toBe(node); + expect(node.nodes[0]).toHaveStringExpression('key', 'foo'); + expect(node.nodes[0]).toHaveStringExpression('value', 'bar'); + expect(node.nodes[1].parent).toBe(node); + expect(node.nodes[1]).toHaveStringExpression('key', 'baz'); + expect(node.nodes[1]).toHaveStringExpression('value', 'qux'); + }); + + it('undefined', () => { + node.append(undefined); + expect(node.nodes).toHaveLength(0); + }); + }); + + describe('append', () => { + beforeEach( + () => + void (node = new MapExpression({ + nodes: [ + [{text: 'foo'}, {text: 'bar'}], + [{text: 'baz'}, {text: 'qux'}], + ], + })), + ); + + it('adds multiple children to the end', () => { + node.append( + [{text: 'zip'}, {text: 'zap'}], + [{text: 'zop'}, {text: 'zoop'}], + ); + expect(node.nodes).toHaveLength(4); + expect(node.nodes[0]).toHaveStringExpression('key', 'foo'); + expect(node.nodes[1]).toHaveStringExpression('key', 'baz'); + expect(node.nodes[2]).toHaveStringExpression('key', 'zip'); + expect(node.nodes[2]).toHaveStringExpression('value', 'zap'); + expect(node.nodes[3]).toHaveStringExpression('key', 'zop'); + expect(node.nodes[3]).toHaveStringExpression('value', 'zoop'); + }); + + it('can be called during iteration', () => + testEachMutation( + [ + ['foo', 'bar'], + ['baz', 'qux'], + ['zip', 'zap'], + ], + 0, + () => node.append([{text: 'zip'}, {text: 'zap'}]), + )); + + it('returns itself', () => expect(node.append()).toBe(node)); + }); + + describe('each', () => { + beforeEach( + () => + void (node = new MapExpression({ + nodes: [ + [{text: 'foo'}, {text: 'bar'}], + [{text: 'baz'}, {text: 'qux'}], + ], + })), + ); + + it('calls the callback for each node', () => { + const fn: EachFn = jest.fn(); + node.each(fn); + expect(fn).toHaveBeenCalledTimes(2); + expect(fn).toHaveBeenNthCalledWith(1, node.nodes[0], 0); + expect(fn).toHaveBeenNthCalledWith(2, node.nodes[1], 1); + }); + + it('returns undefined if the callback is void', () => + expect(node.each(() => {})).toBeUndefined()); + + it('returns false and stops iterating if the callback returns false', () => { + const fn: EachFn = jest.fn(() => false); + expect(node.each(fn)).toBe(false); + expect(fn).toHaveBeenCalledTimes(1); + }); + }); + + describe('every', () => { + beforeEach( + () => + void (node = new MapExpression({ + nodes: [ + [{text: 'foo'}, {text: 'bar'}], + [{text: 'baz'}, {text: 'bang'}], + [{text: 'zip'}, {text: 'zap'}], + ], + })), + ); + + it('returns true if the callback returns true for all elements', () => + expect(node.every(() => true)).toBe(true)); + + it('returns false if the callback returns false for any element', () => + expect( + node.every( + element => (element.key as StringExpression).text.asPlain !== 'baz', + ), + ).toBe(false)); + }); + + describe('index', () => { + beforeEach( + () => + void (node = new MapExpression({ + nodes: [ + [{text: 'foo'}, {text: 'bar'}], + [{text: 'baz'}, {text: 'bang'}], + [{text: 'zip'}, {text: 'zap'}], + ], + })), + ); + + it('returns the first index of a given expression', () => + expect(node.index(node.nodes[2])).toBe(2)); + + it('returns a number as-is', () => expect(node.index(3)).toBe(3)); + }); + + describe('insertAfter', () => { + beforeEach( + () => + void (node = new MapExpression({ + nodes: [ + [{text: 'foo'}, {text: 'bar'}], + [{text: 'baz'}, {text: 'bang'}], + [{text: 'zip'}, {text: 'zap'}], + ], + })), + ); + + it('inserts a node after the given element', () => { + node.insertAfter(node.nodes[1], [{text: 'zop'}, {text: 'zoop'}]); + expect(node.nodes).toHaveLength(4); + expect(node.nodes[0]).toHaveStringExpression('key', 'foo'); + expect(node.nodes[1]).toHaveStringExpression('key', 'baz'); + expect(node.nodes[2]).toHaveStringExpression('key', 'zop'); + expect(node.nodes[2]).toHaveStringExpression('value', 'zoop'); + expect(node.nodes[3]).toHaveStringExpression('key', 'zip'); + }); + + it('inserts a node at the beginning', () => { + node.insertAfter(-1, [{text: 'zop'}, {text: 'zoop'}]); + expect(node.nodes).toHaveLength(4); + expect(node.nodes[0]).toHaveStringExpression('key', 'zop'); + expect(node.nodes[0]).toHaveStringExpression('value', 'zoop'); + expect(node.nodes[1]).toHaveStringExpression('key', 'foo'); + expect(node.nodes[2]).toHaveStringExpression('key', 'baz'); + expect(node.nodes[3]).toHaveStringExpression('key', 'zip'); + }); + + it('inserts a node at the end', () => { + node.insertAfter(3, [{text: 'zop'}, {text: 'zoop'}]); + expect(node.nodes).toHaveLength(4); + expect(node.nodes[0]).toHaveStringExpression('key', 'foo'); + expect(node.nodes[1]).toHaveStringExpression('key', 'baz'); + expect(node.nodes[2]).toHaveStringExpression('key', 'zip'); + expect(node.nodes[3]).toHaveStringExpression('key', 'zop'); + expect(node.nodes[3]).toHaveStringExpression('value', 'zoop'); + }); + + it('inserts multiple nodes', () => { + node.insertAfter(1, [ + [{text: 'zop'}, {text: 'zoop'}], + [{text: 'flip'}, {text: 'flap'}], + ]); + expect(node.nodes).toHaveLength(5); + expect(node.nodes[0]).toHaveStringExpression('key', 'foo'); + expect(node.nodes[1]).toHaveStringExpression('key', 'baz'); + expect(node.nodes[2]).toHaveStringExpression('key', 'zop'); + expect(node.nodes[2]).toHaveStringExpression('value', 'zoop'); + expect(node.nodes[3]).toHaveStringExpression('key', 'flip'); + expect(node.nodes[3]).toHaveStringExpression('value', 'flap'); + expect(node.nodes[4]).toHaveStringExpression('key', 'zip'); + }); + + it('inserts before an iterator', () => + testEachMutation( + [ + ['foo', 'bar'], + ['baz', 'bang'], + [['zip', 'zap'], 5], + ], + 1, + () => + node.insertAfter(0, [ + [{text: 'zop'}, {text: 'zoop'}], + [{text: 'qux'}, {text: 'qax'}], + [{text: 'flip'}, {text: 'flap'}], + ]), + )); + + it('inserts after an iterator', () => + testEachMutation( + [ + ['foo', 'bar'], + ['baz', 'bang'], + ['zop', 'zoop'], + ['qux', 'qax'], + ['flip', 'flap'], + ['zip', 'zap'], + ], + 1, + () => + node.insertAfter(1, [ + [{text: 'zop'}, {text: 'zoop'}], + [{text: 'qux'}, {text: 'qax'}], + [{text: 'flip'}, {text: 'flap'}], + ]), + )); + + it('returns itself', () => + expect( + node.insertAfter(node.nodes[0], [{text: 'qux'}, {text: 'qax'}]), + ).toBe(node)); + }); + + describe('insertBefore', () => { + beforeEach( + () => + void (node = new MapExpression({ + nodes: [ + [{text: 'foo'}, {text: 'bar'}], + [{text: 'baz'}, {text: 'bang'}], + [{text: 'zip'}, {text: 'zap'}], + ], + })), + ); + + it('inserts a node before the given element', () => { + node.insertBefore(node.nodes[1], [{text: 'zop'}, {text: 'zoop'}]); + expect(node.nodes).toHaveLength(4); + expect(node.nodes[0]).toHaveStringExpression('key', 'foo'); + expect(node.nodes[1]).toHaveStringExpression('key', 'zop'); + expect(node.nodes[1]).toHaveStringExpression('value', 'zoop'); + expect(node.nodes[2]).toHaveStringExpression('key', 'baz'); + expect(node.nodes[3]).toHaveStringExpression('key', 'zip'); + }); + + it('inserts a node at the beginning', () => { + node.insertBefore(0, [{text: 'zop'}, {text: 'zoop'}]); + expect(node.nodes).toHaveLength(4); + expect(node.nodes[0]).toHaveStringExpression('key', 'zop'); + expect(node.nodes[0]).toHaveStringExpression('value', 'zoop'); + expect(node.nodes[1]).toHaveStringExpression('key', 'foo'); + expect(node.nodes[2]).toHaveStringExpression('key', 'baz'); + expect(node.nodes[3]).toHaveStringExpression('key', 'zip'); + }); + + it('inserts a node at the end', () => { + node.insertBefore(4, [{text: 'zop'}, {text: 'zoop'}]); + expect(node.nodes).toHaveLength(4); + expect(node.nodes[0]).toHaveStringExpression('key', 'foo'); + expect(node.nodes[1]).toHaveStringExpression('key', 'baz'); + expect(node.nodes[2]).toHaveStringExpression('key', 'zip'); + expect(node.nodes[3]).toHaveStringExpression('key', 'zop'); + expect(node.nodes[3]).toHaveStringExpression('value', 'zoop'); + }); + + it('inserts multiple nodes', () => { + node.insertBefore(1, [ + [{text: 'zop'}, {text: 'zoop'}], + [{text: 'flip'}, {text: 'flap'}], + ]); + expect(node.nodes).toHaveLength(5); + expect(node.nodes[0]).toHaveStringExpression('key', 'foo'); + expect(node.nodes[1]).toHaveStringExpression('key', 'zop'); + expect(node.nodes[1]).toHaveStringExpression('value', 'zoop'); + expect(node.nodes[2]).toHaveStringExpression('key', 'flip'); + expect(node.nodes[2]).toHaveStringExpression('value', 'flap'); + expect(node.nodes[3]).toHaveStringExpression('key', 'baz'); + expect(node.nodes[4]).toHaveStringExpression('key', 'zip'); + }); + + it('inserts before an iterator', () => + testEachMutation( + [ + ['foo', 'bar'], + ['baz', 'bang'], + [['zip', 'zap'], 5], + ], + 1, + () => + node.insertBefore(1, [ + [{text: 'zop'}, {text: 'zoop'}], + [{text: 'qux'}, {text: 'qax'}], + [{text: 'flip'}, {text: 'flap'}], + ]), + )); + + it('inserts after an iterator', () => + testEachMutation( + [ + ['foo', 'bar'], + ['baz', 'bang'], + ['zop', 'zoop'], + ['qux', 'qax'], + ['flip', 'flap'], + ['zip', 'zap'], + ], + 1, + () => + node.insertBefore(2, [ + [{text: 'zop'}, {text: 'zoop'}], + [{text: 'qux'}, {text: 'qax'}], + [{text: 'flip'}, {text: 'flap'}], + ]), + )); + + it('returns itself', () => + expect( + node.insertBefore(node.nodes[0], [{text: 'qux'}, {text: 'qax'}]), + ).toBe(node)); + }); + + describe('prepend', () => { + beforeEach( + () => + void (node = new MapExpression({ + nodes: [ + [{text: 'foo'}, {text: 'bar'}], + [{text: 'baz'}, {text: 'bang'}], + [{text: 'zip'}, {text: 'zap'}], + ], + })), + ); + + it('inserts one node', () => { + node.prepend([{text: 'zop'}, {text: 'zoop'}]); + expect(node.nodes).toHaveLength(4); + expect(node.nodes[0]).toHaveStringExpression('key', 'zop'); + expect(node.nodes[0]).toHaveStringExpression('value', 'zoop'); + expect(node.nodes[1]).toHaveStringExpression('key', 'foo'); + expect(node.nodes[2]).toHaveStringExpression('key', 'baz'); + expect(node.nodes[3]).toHaveStringExpression('key', 'zip'); + }); + + it('inserts multiple nodes', () => { + node.prepend([ + [{text: 'zop'}, {text: 'zoop'}], + [{text: 'flip'}, {text: 'flap'}], + ]); + expect(node.nodes).toHaveLength(5); + expect(node.nodes[0]).toHaveStringExpression('key', 'zop'); + expect(node.nodes[0]).toHaveStringExpression('value', 'zoop'); + expect(node.nodes[1]).toHaveStringExpression('key', 'flip'); + expect(node.nodes[1]).toHaveStringExpression('value', 'flap'); + expect(node.nodes[2]).toHaveStringExpression('key', 'foo'); + expect(node.nodes[3]).toHaveStringExpression('key', 'baz'); + expect(node.nodes[4]).toHaveStringExpression('key', 'zip'); + }); + + it('inserts before an iterator', () => + testEachMutation( + [ + ['foo', 'bar'], + ['baz', 'bang'], + [['zip', 'zap'], 5], + ], + 1, + () => + node.prepend( + [{text: 'zop'}, {text: 'zoop'}], + [{text: 'qux'}, {text: 'qax'}], + [{text: 'flip'}, {text: 'flap'}], + ), + )); + + it('returns itself', () => + expect(node.prepend([{text: 'qux'}, {text: 'qax'}])).toBe(node)); + }); + + describe('push', () => { + beforeEach( + () => + void (node = new MapExpression({ + nodes: [ + [{text: 'foo'}, {text: 'bar'}], + [{text: 'baz'}, {text: 'bang'}], + ], + })), + ); + + it('inserts one node', () => { + node.push(new MapEntry([{text: 'zip'}, {text: 'zap'}])); + expect(node.nodes).toHaveLength(3); + expect(node.nodes[0]).toHaveStringExpression('key', 'foo'); + expect(node.nodes[1]).toHaveStringExpression('key', 'baz'); + expect(node.nodes[2]).toHaveStringExpression('key', 'zip'); + expect(node.nodes[2]).toHaveStringExpression('value', 'zap'); + }); + + it('can be called during iteration', () => + testEachMutation( + [ + ['foo', 'bar'], + ['baz', 'bang'], + ['zip', 'zap'], + ], + 0, + () => node.push(new MapEntry([{text: 'zip'}, {text: 'zap'}])), + )); + + it('returns itself', () => + expect(node.push(new MapEntry([{text: 'zip'}, {text: 'zap'}]))).toBe( + node, + )); + }); + + describe('removeAll', () => { + beforeEach( + () => + void (node = new MapExpression({ + nodes: [ + [{text: 'foo'}, {text: 'bar'}], + [{text: 'baz'}, {text: 'bang'}], + [{text: 'zip'}, {text: 'zap'}], + ], + })), + ); + + it('removes all nodes', () => { + node.removeAll(); + expect(node.nodes).toHaveLength(0); + }); + + it("removes a node's parents", () => { + const string = node.nodes[1]; + node.removeAll(); + expect(string).toHaveProperty('parent', undefined); + }); + + it('can be called during iteration', () => + testEachMutation([['foo', 'bar']], 0, () => node.removeAll())); + + it('returns itself', () => expect(node.removeAll()).toBe(node)); + }); + + describe('removeChild', () => { + beforeEach( + () => + void (node = new MapExpression({ + nodes: [ + [{text: 'foo'}, {text: 'bar'}], + [{text: 'baz'}, {text: 'bang'}], + [{text: 'zip'}, {text: 'zap'}], + ], + })), + ); + + it('removes a matching node', () => { + const child1 = node.nodes[1]; + const child2 = node.nodes[2]; + node.removeChild(node.nodes[0]); + expect(node.nodes).toEqual([child1, child2]); + }); + + it('removes a node at index', () => { + node.removeChild(1); + expect(node.nodes).toHaveLength(2); + expect(node.nodes[0]).toHaveStringExpression('key', 'foo'); + expect(node.nodes[1]).toHaveStringExpression('key', 'zip'); + }); + + it("removes a node's parents", () => { + const child = node.nodes[1]; + node.removeChild(1); + expect(child).toHaveProperty('parent', undefined); + }); + + it('removes a node before the iterator', () => + testEachMutation( + [ + ['foo', 'bar'], + ['baz', 'bang'], + [['zip', 'zap'], 1], + ], + 1, + () => node.removeChild(1), + )); + + it('removes a node after the iterator', () => + testEachMutation( + [ + ['foo', 'bar'], + ['baz', 'bang'], + ], + 1, + () => node.removeChild(2), + )); + + it('returns itself', () => expect(node.removeChild(0)).toBe(node)); + }); + + describe('some', () => { + beforeEach( + () => + void (node = new MapExpression({ + nodes: [ + [{text: 'foo'}, {text: 'bar'}], + [{text: 'baz'}, {text: 'bang'}], + [{text: 'zip'}, {text: 'zap'}], + ], + })), + ); + + it('returns false if the callback returns false for all elements', () => + expect(node.some(() => false)).toBe(false)); + + it('returns true if the callback returns true for any element', () => + expect( + node.some( + element => (element.key as StringExpression).text.asPlain === 'baz', + ), + ).toBe(true)); + }); + + describe('first', () => { + it('returns the first element', () => + expect( + new MapExpression({ + nodes: [ + [{text: 'foo'}, {text: 'bar'}], + [{text: 'baz'}, {text: 'bang'}], + [{text: 'zip'}, {text: 'zap'}], + ], + }).first, + ).toHaveStringExpression('key', 'foo')); + + it('returns undefined for an empty map', () => + expect(new MapExpression({nodes: []}).first).toBeUndefined()); + }); + + describe('last', () => { + it('returns the last element', () => + expect( + new MapExpression({ + nodes: [ + [{text: 'foo'}, {text: 'bar'}], + [{text: 'baz'}, {text: 'bang'}], + [{text: 'zip'}, {text: 'zap'}], + ], + }).last, + ).toHaveStringExpression('key', 'zip')); + + it('returns undefined for an empty interpolation', () => + expect(new MapExpression({nodes: []}).last).toBeUndefined()); + }); + + describe('stringifies', () => { + describe('with default raws', () => { + it('empty', () => + expect(new MapExpression({nodes: []}).toString()).toBe('()')); + + it('with one pair', () => + expect( + new MapExpression({ + nodes: [[{text: 'foo'}, {text: 'bar'}]], + }).toString(), + ).toBe('(foo: bar)')); + + it('with multiple pairs', () => + expect( + new MapExpression({ + nodes: [ + [{text: 'foo'}, {text: 'bar'}], + [{text: 'zip'}, {text: 'zap'}], + ], + }).toString(), + ).toBe('(foo: bar, zip: zap)')); + }); + + describe('afterOpen', () => { + describe('empty', () => { + it('no beforeClose', () => + expect( + new MapExpression({ + raws: {afterOpen: '/**/'}, + nodes: [], + }).toString(), + ).toBe('(/**/)')); + + it('and beforeClose', () => + expect( + new MapExpression({ + raws: {afterOpen: '/**/', beforeClose: ' '}, + nodes: [], + }).toString(), + ).toBe('(/**/ )')); + }); + + describe('one pair', () => { + it('no nodes.before', () => + expect( + new MapExpression({ + raws: {afterOpen: '/**/'}, + nodes: [[{text: 'foo'}, {text: 'bar'}]], + }).toString(), + ).toBe('(/**/foo: bar)')); + + it('with nodes.before', () => + expect( + new MapExpression({ + raws: {afterOpen: '/**/'}, + nodes: [ + { + key: {text: 'foo'}, + value: {text: 'bar'}, + raws: {before: ' '}, + }, + ], + }).toString(), + ).toBe('(/**/ foo: bar)')); + }); + }); + + describe('beforeClose', () => { + it('empty', () => + expect( + new MapExpression({ + raws: {beforeClose: '/**/'}, + nodes: [], + }).toString(), + ).toBe('(/**/)')); + + describe('one pair', () => { + it('no nodes.after', () => + expect( + new MapExpression({ + raws: {beforeClose: '/**/'}, + nodes: [[{text: 'foo'}, {text: 'bar'}]], + }).toString(), + ).toBe('(foo: bar/**/)')); + + it('with nodes.after', () => + expect( + new MapExpression({ + raws: {beforeClose: '/**/'}, + nodes: [ + { + key: {text: 'foo'}, + value: {text: 'bar'}, + raws: {after: ' '}, + }, + ], + }).toString(), + ).toBe('(foo: bar /**/)')); + }); + }); + + describe('trailingComma', () => { + describe('is ignored for', () => { + describe('empty', () => + expect( + new MapExpression({ + raws: {trailingComma: true}, + nodes: [], + }).toString(), + ).toBe('()')); + }); + + describe('one element', () => { + it('no nodes.after', () => + expect( + new MapExpression({ + raws: {trailingComma: true}, + nodes: [[{text: 'foo'}, {text: 'bar'}]], + }).toString(), + ).toBe('(foo: bar,)')); + + it('with nodes.after', () => + expect( + new MapExpression({ + raws: {trailingComma: true}, + nodes: [ + { + key: {text: 'foo'}, + value: {text: 'bar'}, + raws: {after: '/**/'}, + }, + ], + }).toString(), + ).toBe('(foo: bar/**/,)')); + }); + }); + }); + + describe('clone', () => { + let original: MapExpression; + + beforeEach(() => { + original = utils.parseExpression('(foo: bar, baz: bang)'); + // TODO: remove this once raws are properly parsed. + original.raws.afterOpen = ' '; + }); + + describe('with no overrides', () => { + let clone: MapExpression; + + beforeEach(() => void (clone = original.clone())); + + describe('has the same properties:', () => { + it('nodes', () => { + expect(clone.nodes).toHaveLength(2); + expect(clone.nodes[0]).toHaveStringExpression('key', 'foo'); + expect(clone.nodes[0]).toHaveStringExpression('value', 'bar'); + expect(clone.nodes[1]).toHaveStringExpression('key', 'baz'); + expect(clone.nodes[1]).toHaveStringExpression('value', 'bang'); + }); + + it('raws', () => expect(clone.raws).toEqual({afterOpen: ' '})); + + it('source', () => expect(clone.source).toBe(original.source)); + }); + + describe('creates a new', () => { + it('self', () => expect(clone).not.toBe(original)); + + for (const attr of ['nodes', 'raws'] as const) { + it(attr, () => expect(clone[attr]).not.toBe(original[attr])); + } + }); + }); + + describe('overrides', () => { + describe('nodes', () => { + it('defined', () => { + const clone = original.clone({ + nodes: [[{text: 'zip'}, {text: 'zap'}]], + }); + expect(clone.nodes).toHaveLength(1); + expect(clone.nodes[0]).toHaveStringExpression('key', 'zip'); + expect(clone.nodes[0]).toHaveStringExpression('value', 'zap'); + }); + + it('undefined', () => { + const clone = original.clone({nodes: undefined}); + expect(clone.nodes).toHaveLength(2); + expect(clone.nodes[0]).toHaveStringExpression('key', 'foo'); + expect(clone.nodes[1]).toHaveStringExpression('key', 'baz'); + }); + }); + + describe('raws', () => { + it('defined', () => + expect( + original.clone({raws: {beforeClose: '/**/'}}).raws.beforeClose, + ).toBe('/**/')); + + it('undefined', () => + expect(original.clone({raws: undefined}).raws.afterOpen).toBe(' ')); + }); + }); + }); + + it('toJSON', () => + expect(utils.parseExpression('(foo: bar, baz: bang)')).toMatchSnapshot()); +}); + +/** + * Runs `node.each`, asserting that it sees each element and index in {@link + * elements} in order. If an index isn't explicitly provided, it defaults to the + * index in {@link elements}. + * + * When it reaches {@link indexToModify}, it calls {@link modify}, which is + * expected to modify `node.nodes`. + */ +function testEachMutation( + elements: ([[string, string], number] | [string, string])[], + indexToModify: number, + modify: () => void, +): void { + const fn: EachFn = jest.fn((child, i) => { + if (i === indexToModify) modify(); + }); + node.each(fn); + + for (let i = 0; i < elements.length; i++) { + const element = elements[i]; + const [[key, value], index] = Array.isArray(element[0]) + ? element + : [element, i]; + expect(fn).toHaveBeenNthCalledWith( + i + 1, + expect.objectContaining({ + key: expect.objectContaining({ + text: expect.objectContaining({asPlain: key}), + }), + value: expect.objectContaining({ + text: expect.objectContaining({asPlain: value}), + }), + }), + index, + ); + } + expect(fn).toHaveBeenCalledTimes(elements.length); +} diff --git a/pkg/sass-parser/lib/src/expression/map.ts b/pkg/sass-parser/lib/src/expression/map.ts new file mode 100644 index 000000000..fe68ab75b --- /dev/null +++ b/pkg/sass-parser/lib/src/expression/map.ts @@ -0,0 +1,349 @@ +// 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 {Container} from '../container'; +import {LazySource} from '../lazy-source'; +import {Node, NodeProps} from '../node'; +import type * as sassInternal from '../sass-internal'; +import * as utils from '../utils'; +import {Expression} from '.'; +import {convertExpression} from './convert'; +import {MapEntry, MapEntryProps} from './map-entry'; + +/** + * The initializer properties for {@link MapExpression}. + * + * @category Expression + */ +export interface MapExpressionProps extends NodeProps { + raws?: MapExpressionRaws; + nodes: Array; +} + +// TODO: Parse strings. +/** + * The type of new node pairs that can be passed into a map expression. + * + * @category Expression + */ +export type NewNodeForMapExpression = + | MapEntry + | MapEntryProps + | ReadonlyArray + | ReadonlyArray + | undefined; + +/** + * Raws indicating how to precisely serialize a {@link MapExpression}. + * + * @category Expression + */ +export interface MapExpressionRaws { + /** + * The whitespace between the opening parenthesis and the first expression. + */ + afterOpen?: string; + + /** + * The whitespace between the last comma and the closing bracket. + * + * This is only set automatically for maps with trailing commas. + */ + beforeClose?: string; + + /** + * Whether this map has a trailing comma. + * + * Ignored if the expression has zero elements. + */ + trailingComma?: boolean; +} + +/** + * Raws indicating how to precisely serialize a single key/value pair in a + * {@link MapExpression}. + * + * @category Expression + */ +export interface MapExpressionPairRaws { + /** The whitespace before the pair's key. */ + before?: string; + + /** The text (including the colon) between the pair's key and value. */ + between?: string; + + /** The whitespace after the pair's value and before the comma. */ + after?: string; +} + +/** + * An expression representing a map literal in Sass. + * + * @category Expression + */ +export class MapExpression + extends Expression + implements Container +{ + readonly sassType = 'map' as const; + declare raws: MapExpressionRaws; + + get nodes(): ReadonlyArray { + return this._nodes!; + } + /** @hidden */ + set nodes(nodes: Array) { + // This *should* only ever be called by the superclass constructor. + this._nodes = nodes; + } + private declare _nodes?: Array; + + /** + * Iterators that are currently active within this map. Their indices refer + * to the last position that has already been sent to the callback, and are + * updated when {@link _nodes} is modified. + */ + readonly #iterators: Array<{index: number}> = []; + + constructor(defaults: MapExpressionProps); + /** @hidden */ + constructor(_: undefined, inner: sassInternal.MapExpression); + constructor(defaults?: object, inner?: sassInternal.MapExpression) { + super(defaults); + if (inner) { + this.source = new LazySource(inner); + this.nodes = []; + for (const pair of inner.pairs) { + this.append([convertExpression(pair._0), convertExpression(pair._1)]); + } + } + } + + clone(overrides?: Partial): this { + return utils.cloneNode(this, overrides, ['nodes', 'raws']); + } + + toJSON(): object; + /** @hidden */ + toJSON(_: string, inputs: Map): object; + toJSON(_?: string, inputs?: Map): object { + return utils.toJSON(this, ['nodes'], inputs); + } + + append(...nodes: NewNodeForMapExpression[]): this { + // TODO - postcss/postcss#1957: Mark this as dirty + this._nodes!.push(...this._normalizeList(nodes)); + return this; + } + + each( + callback: (node: MapEntry, index: number) => false | void, + ): false | undefined { + const iterator = {index: 0}; + this.#iterators.push(iterator); + + try { + while (iterator.index < this.nodes.length) { + const result = callback(this.nodes[iterator.index], iterator.index); + if (result === false) return false; + iterator.index += 1; + } + return undefined; + } finally { + this.#iterators.splice(this.#iterators.indexOf(iterator), 1); + } + } + + every( + condition: ( + node: MapEntry, + index: number, + nodes: ReadonlyArray, + ) => boolean, + ): boolean { + return this.nodes.every(condition); + } + + index(child: MapEntry | number): number { + return typeof child === 'number' ? child : this.nodes.indexOf(child); + } + + insertAfter( + oldNode: MapEntry | number, + newNode: NewNodeForMapExpression, + ): this { + // TODO - postcss/postcss#1957: Mark this as dirty + const index = this.index(oldNode); + const normalized = this._normalize(newNode); + this._nodes!.splice(index + 1, 0, ...normalized); + + for (const iterator of this.#iterators) { + if (iterator.index > index) iterator.index += normalized.length; + } + + return this; + } + + insertBefore( + oldNode: MapEntry | number, + newNode: NewNodeForMapExpression, + ): this { + // TODO - postcss/postcss#1957: Mark this as dirty + const index = this.index(oldNode); + const normalized = this._normalize(newNode); + this._nodes!.splice(index, 0, ...normalized); + + for (const iterator of this.#iterators) { + if (iterator.index >= index) iterator.index += normalized.length; + } + + return this; + } + + prepend(...nodes: NewNodeForMapExpression[]): this { + // TODO - postcss/postcss#1957: Mark this as dirty + const normalized = this._normalizeList(nodes); + this._nodes!.unshift(...normalized); + + for (const iterator of this.#iterators) { + iterator.index += normalized.length; + } + + return this; + } + + push(child: MapEntry): this { + return this.append(child); + } + + removeAll(): this { + // TODO - postcss/postcss#1957: Mark this as dirty + for (const node of this.nodes) { + node.parent = undefined; + } + this._nodes!.length = 0; + return this; + } + + removeChild(child: MapEntry | number): this { + // TODO - postcss/postcss#1957: Mark this as dirty + const index = this.index(child); + const node = this._nodes![index]; + if (node) node.parent = undefined; + this._nodes!.splice(index, 1); + + for (const iterator of this.#iterators) { + if (iterator.index >= index) iterator.index--; + } + + return this; + } + + some( + condition: ( + node: MapEntry, + index: number, + nodes: ReadonlyArray, + ) => boolean, + ): boolean { + return this.nodes.some(condition); + } + + get first(): MapEntry | undefined { + return this.nodes[0]; + } + + get last(): MapEntry | undefined { + return this.nodes[this.nodes.length - 1]; + } + + /** @hidden */ + toString(): string { + let result = ''; + + result += '(' + (this.raws?.afterOpen ?? ''); + + for (let i = 0; i < this.nodes.length; i++) { + const entry = this.nodes[i]; + result += + (entry.raws.before ?? (i > 0 ? ' ' : '')) + + entry + + (entry.raws.after ?? '') + + (i < this.nodes.length - 1 ? ',' : ''); + } + + if (this.raws.trailingComma && this.nodes.length > 0) result += ','; + result += (this.raws?.beforeClose ?? '') + ')'; + return result; + } + + /** + * Normalizes a single argument declaration or map of arguments. + */ + private _normalize(nodes: NewNodeForMapExpression): Array { + if (nodes === undefined) return []; + const normalized: Array = []; + // We need a lot of weird casts here because TypeScript gets confused by the + // way these types overlap. + const nodesArray: Array = Array.isArray(nodes) + ? // nodes is now + // | [Expression | ExpressionProps, Expression | ExpressionProps] + // | ReadonlyArray + // | ReadonlyArray + // ReadonlyArray + isMapEntry(nodes[0]) || + // ReadonlyArray when the first entry is + // [Expression | ExpressionProps, Expression | ExpressionProps]. + Array.isArray(nodes[0]) || + // ReadonlyArray when the first entry is + // MapEntryObjectProps. + ('key' in nodes[0] && 'value' in nodes[0]) + ? (nodes as unknown as Array) + : // If it's not one of the above patterns, it must be a raw MapEntryProps + // of the form [Expression | ExpressionProps, Expression | + // ExpressionProps]. + [nodes] + : [nodes as MapEntryProps]; + for (const node of nodesArray) { + if (node === undefined) { + continue; + } else if ('sassType' in node) { + if (!isMapEntry(node)) { + throw new Error( + `Unexpected "${(node as unknown as Node).sassType}", expected "map-entry".`, + ); + } + node.parent = this; + normalized.push(node); + } else { + const entry = new MapEntry(node); + entry.parent = this; + normalized.push(entry); + } + } + return normalized; + } + + /** Like {@link _normalize}, but also flattens a map of nodes. */ + private _normalizeList( + nodes: ReadonlyArray, + ): Array { + const result: Array = []; + for (const node of nodes) { + result.push(...this._normalize(node)); + } + return result; + } + + /** @hidden */ + get nonStatementChildren(): ReadonlyArray { + return this.nodes; + } +} + +function isMapEntry(value: object): value is MapEntry { + return 'sassType' in value && value.sassType === 'map-entry'; +} diff --git a/pkg/sass-parser/lib/src/node.d.ts b/pkg/sass-parser/lib/src/node.d.ts index f63de46e8..8b4818674 100644 --- a/pkg/sass-parser/lib/src/node.d.ts +++ b/pkg/sass-parser/lib/src/node.d.ts @@ -28,6 +28,7 @@ export type NodeType = | 'dynamic-import' | 'import-list' | 'interpolation' + | 'map-entry' | 'parameter' | 'parameter-list' | 'static-import'; diff --git a/pkg/sass-parser/lib/src/sass-internal.ts b/pkg/sass-parser/lib/src/sass-internal.ts index d7333f2cf..ecc47a865 100644 --- a/pkg/sass-parser/lib/src/sass-internal.ts +++ b/pkg/sass-parser/lib/src/sass-internal.ts @@ -40,6 +40,11 @@ export interface DartMap { _unique: 'DartMap'; } +export interface DartPair { + _0: E1; + _1: E2; +} + // There may be a better way to declare this, but I can't figure it out. // eslint-disable-next-line @typescript-eslint/no-namespace declare namespace SassInternal { @@ -335,6 +340,10 @@ declare namespace SassInternal { readonly separator: ' ' | ',' | '/' | null | undefined; } + class MapExpression extends Expression { + readonly pairs: DartPair[]; + } + class StringExpression extends Expression { readonly text: Interpolation; readonly hasQuotes: boolean; @@ -402,6 +411,7 @@ export type Expression = SassInternal.Expression; export type BinaryOperationExpression = SassInternal.BinaryOperationExpression; export type ListExpression = SassInternal.ListExpression; export type ListSeparator = SassInternal.ListSeparator; +export type MapExpression = SassInternal.MapExpression; export type StringExpression = SassInternal.StringExpression; export type BooleanExpression = SassInternal.BooleanExpression; export type ColorExpression = SassInternal.ColorExpression; @@ -441,6 +451,7 @@ export interface ExpressionVisitorObject { visitBooleanExpression(node: BooleanExpression): T; visitColorExpression(node: ColorExpression): T; visitListExpression(node: ListExpression): T; + visitMapExpression(node: MapExpression): T; visitNumberExpression(node: NumberExpression): T; }