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;
}