From be864bc9578c76f520fb19df4c40f9f573d56050 Mon Sep 17 00:00:00 2001 From: Natalie Weizenbaum Date: Fri, 26 Sep 2025 17:08:45 -0700 Subject: [PATCH 1/2] Parse selectors in the sass-parser package This adds a separate "interpolated selector" AST that parallels the existing selector AST, but includes the interpolation that's present in the source files. This AST isn't used by the Sass implementation itself; it's just made available so that tools can gracefully interact with selectors as an AST in unevaluated Sass files. --- CHANGELOG.md | 4 + lib/src/ast/sass.dart | 15 + lib/src/ast/sass/interpolated_selector.dart | 20 + .../sass/interpolated_selector/attribute.dart | 71 ++ .../ast/sass/interpolated_selector/class.dart | 32 + .../sass/interpolated_selector/complex.dart | 48 + .../complex_component.dart | 38 + .../sass/interpolated_selector/compound.dart | 38 + .../ast/sass/interpolated_selector/id.dart | 32 + .../ast/sass/interpolated_selector/list.dart | 39 + .../sass/interpolated_selector/parent.dart | 32 + .../interpolated_selector/placeholder.dart | 32 + .../sass/interpolated_selector/pseudo.dart | 72 ++ .../interpolated_selector/qualified_name.dart | 35 + .../sass/interpolated_selector/simple.dart | 13 + .../ast/sass/interpolated_selector/type.dart | 33 + .../sass/interpolated_selector/universal.dart | 32 + lib/src/ast/sass/statement/style_rule.dart | 27 +- lib/src/ast/sass/statement/stylesheet.dart | 39 +- lib/src/ast/selector/attribute.dart | 2 + lib/src/js/parser.dart | 72 +- lib/src/js/visitor/simple_selector.dart | 54 ++ lib/src/parse/css.dart | 2 +- lib/src/parse/parser.dart | 7 +- lib/src/parse/sass.dart | 2 +- lib/src/parse/scss.dart | 2 +- lib/src/parse/selector.dart | 14 +- lib/src/parse/stylesheet.dart | 494 +++++++++- lib/src/visitor/ast_search.dart | 56 +- lib/src/visitor/async_evaluate.dart | 4 +- lib/src/visitor/evaluate.dart | 6 +- .../interface/interpolated_selector.dart | 24 + lib/src/visitor/recursive_ast.dart | 64 +- pkg/sass-parser/CHANGELOG.md | 4 + pkg/sass-parser/README.md | 6 +- pkg/sass-parser/lib/index.ts | 77 ++ .../lib/src/expression/list.test.ts | 4 +- pkg/sass-parser/lib/src/expression/list.ts | 1 - pkg/sass-parser/lib/src/interpolation.ts | 2 +- pkg/sass-parser/lib/src/lazy-source.ts | 5 + pkg/sass-parser/lib/src/node.d.ts | 18 + pkg/sass-parser/lib/src/sass-internal.ts | 125 ++- .../__snapshots__/attribute.test.ts.snap | 54 ++ .../selector/__snapshots__/class.test.ts.snap | 17 + .../complex-component.test.ts.snap | 34 + .../__snapshots__/complex.test.ts.snap | 39 + .../__snapshots__/compound.test.ts.snap | 20 + .../selector/__snapshots__/id.test.ts.snap | 17 + .../selector/__snapshots__/list.test.ts.snap | 20 + .../__snapshots__/parent.test.ts.snap | 32 + .../__snapshots__/placeholder.test.ts.snap | 17 + .../__snapshots__/pseudo.test.ts.snap | 107 +++ .../__snapshots__/qualified-name.test.ts.snap | 34 + .../selector/__snapshots__/type.test.ts.snap | 17 + .../__snapshots__/universal.test.ts.snap | 32 + .../lib/src/selector/attribute.test.ts | 781 ++++++++++++++++ pkg/sass-parser/lib/src/selector/attribute.ts | 224 +++++ .../lib/src/selector/class.test.ts | 146 +++ pkg/sass-parser/lib/src/selector/class.ts | 91 ++ .../src/selector/complex-component.test.ts | 234 +++++ .../lib/src/selector/complex-component.ts | 138 +++ .../lib/src/selector/complex.test.ts | 852 ++++++++++++++++++ pkg/sass-parser/lib/src/selector/complex.ts | 352 ++++++++ .../lib/src/selector/compound.test.ts | 655 ++++++++++++++ pkg/sass-parser/lib/src/selector/compound.ts | 292 ++++++ pkg/sass-parser/lib/src/selector/convert.ts | 33 + .../lib/src/selector/from-props.ts | 27 + pkg/sass-parser/lib/src/selector/id.test.ts | 132 +++ pkg/sass-parser/lib/src/selector/id.ts | 89 ++ pkg/sass-parser/lib/src/selector/index.ts | 72 ++ pkg/sass-parser/lib/src/selector/list.test.ts | 817 +++++++++++++++++ pkg/sass-parser/lib/src/selector/list.ts | 322 +++++++ .../lib/src/selector/parent.test.ts | 178 ++++ pkg/sass-parser/lib/src/selector/parent.ts | 105 +++ .../lib/src/selector/placeholder.test.ts | 151 ++++ .../lib/src/selector/placeholder.ts | 90 ++ .../lib/src/selector/pseudo.test.ts | 736 +++++++++++++++ pkg/sass-parser/lib/src/selector/pseudo.ts | 228 +++++ .../lib/src/selector/qualified-name.test.ts | 290 ++++++ .../lib/src/selector/qualified-name.ts | 142 +++ pkg/sass-parser/lib/src/selector/type.test.ts | 119 +++ pkg/sass-parser/lib/src/selector/type.ts | 92 ++ .../lib/src/selector/universal.test.ts | 186 ++++ pkg/sass-parser/lib/src/selector/universal.ts | 98 ++ .../statement/__snapshots__/rule.test.ts.snap | 8 +- .../lib/src/statement/at-root-rule.test.ts | 2 +- .../lib/src/statement/container.test.ts | 76 +- .../lib/src/statement/declaration.ts | 1 - .../lib/src/statement/generic-at-rule.test.ts | 10 +- pkg/sass-parser/lib/src/statement/index.ts | 2 +- .../lib/src/statement/rule.test.ts | 132 ++- pkg/sass-parser/lib/src/statement/rule.ts | 53 +- pkg/sass-parser/lib/src/stringifier.ts | 4 +- pkg/sass-parser/lib/src/utils.ts | 2 +- pkg/sass-parser/package.json | 2 +- pkg/sass-parser/test/setup.ts | 157 +++- pkg/sass-parser/test/utils.ts | 34 + pkg/sass_api/CHANGELOG.md | 14 + pkg/sass_api/pubspec.yaml | 4 +- pubspec.yaml | 2 +- 100 files changed, 10054 insertions(+), 258 deletions(-) create mode 100644 lib/src/ast/sass/interpolated_selector.dart create mode 100644 lib/src/ast/sass/interpolated_selector/attribute.dart create mode 100644 lib/src/ast/sass/interpolated_selector/class.dart create mode 100644 lib/src/ast/sass/interpolated_selector/complex.dart create mode 100644 lib/src/ast/sass/interpolated_selector/complex_component.dart create mode 100644 lib/src/ast/sass/interpolated_selector/compound.dart create mode 100644 lib/src/ast/sass/interpolated_selector/id.dart create mode 100644 lib/src/ast/sass/interpolated_selector/list.dart create mode 100644 lib/src/ast/sass/interpolated_selector/parent.dart create mode 100644 lib/src/ast/sass/interpolated_selector/placeholder.dart create mode 100644 lib/src/ast/sass/interpolated_selector/pseudo.dart create mode 100644 lib/src/ast/sass/interpolated_selector/qualified_name.dart create mode 100644 lib/src/ast/sass/interpolated_selector/simple.dart create mode 100644 lib/src/ast/sass/interpolated_selector/type.dart create mode 100644 lib/src/ast/sass/interpolated_selector/universal.dart create mode 100644 lib/src/js/visitor/simple_selector.dart create mode 100644 lib/src/visitor/interface/interpolated_selector.dart create mode 100644 pkg/sass-parser/lib/src/selector/__snapshots__/attribute.test.ts.snap create mode 100644 pkg/sass-parser/lib/src/selector/__snapshots__/class.test.ts.snap create mode 100644 pkg/sass-parser/lib/src/selector/__snapshots__/complex-component.test.ts.snap create mode 100644 pkg/sass-parser/lib/src/selector/__snapshots__/complex.test.ts.snap create mode 100644 pkg/sass-parser/lib/src/selector/__snapshots__/compound.test.ts.snap create mode 100644 pkg/sass-parser/lib/src/selector/__snapshots__/id.test.ts.snap create mode 100644 pkg/sass-parser/lib/src/selector/__snapshots__/list.test.ts.snap create mode 100644 pkg/sass-parser/lib/src/selector/__snapshots__/parent.test.ts.snap create mode 100644 pkg/sass-parser/lib/src/selector/__snapshots__/placeholder.test.ts.snap create mode 100644 pkg/sass-parser/lib/src/selector/__snapshots__/pseudo.test.ts.snap create mode 100644 pkg/sass-parser/lib/src/selector/__snapshots__/qualified-name.test.ts.snap create mode 100644 pkg/sass-parser/lib/src/selector/__snapshots__/type.test.ts.snap create mode 100644 pkg/sass-parser/lib/src/selector/__snapshots__/universal.test.ts.snap create mode 100644 pkg/sass-parser/lib/src/selector/attribute.test.ts create mode 100644 pkg/sass-parser/lib/src/selector/attribute.ts create mode 100644 pkg/sass-parser/lib/src/selector/class.test.ts create mode 100644 pkg/sass-parser/lib/src/selector/class.ts create mode 100644 pkg/sass-parser/lib/src/selector/complex-component.test.ts create mode 100644 pkg/sass-parser/lib/src/selector/complex-component.ts create mode 100644 pkg/sass-parser/lib/src/selector/complex.test.ts create mode 100644 pkg/sass-parser/lib/src/selector/complex.ts create mode 100644 pkg/sass-parser/lib/src/selector/compound.test.ts create mode 100644 pkg/sass-parser/lib/src/selector/compound.ts create mode 100644 pkg/sass-parser/lib/src/selector/convert.ts create mode 100644 pkg/sass-parser/lib/src/selector/from-props.ts create mode 100644 pkg/sass-parser/lib/src/selector/id.test.ts create mode 100644 pkg/sass-parser/lib/src/selector/id.ts create mode 100644 pkg/sass-parser/lib/src/selector/index.ts create mode 100644 pkg/sass-parser/lib/src/selector/list.test.ts create mode 100644 pkg/sass-parser/lib/src/selector/list.ts create mode 100644 pkg/sass-parser/lib/src/selector/parent.test.ts create mode 100644 pkg/sass-parser/lib/src/selector/parent.ts create mode 100644 pkg/sass-parser/lib/src/selector/placeholder.test.ts create mode 100644 pkg/sass-parser/lib/src/selector/placeholder.ts create mode 100644 pkg/sass-parser/lib/src/selector/pseudo.test.ts create mode 100644 pkg/sass-parser/lib/src/selector/pseudo.ts create mode 100644 pkg/sass-parser/lib/src/selector/qualified-name.test.ts create mode 100644 pkg/sass-parser/lib/src/selector/qualified-name.ts create mode 100644 pkg/sass-parser/lib/src/selector/type.test.ts create mode 100644 pkg/sass-parser/lib/src/selector/type.ts create mode 100644 pkg/sass-parser/lib/src/selector/universal.test.ts create mode 100644 pkg/sass-parser/lib/src/selector/universal.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index fa81ef2f0..4584e2f4d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +## 1.93.4 + +* No user-visible changes. + ## 1.93.3 * Fix a performance regression that was introduced in 1.92.0. diff --git a/lib/src/ast/sass.dart b/lib/src/ast/sass.dart index f4deef43b..7509fc400 100644 --- a/lib/src/ast/sass.dart +++ b/lib/src/ast/sass.dart @@ -29,6 +29,21 @@ export 'sass/expression/variable.dart'; export 'sass/import.dart'; export 'sass/import/dynamic.dart'; export 'sass/import/static.dart'; +export 'sass/interpolated_selector.dart'; +export 'sass/interpolated_selector/attribute.dart'; +export 'sass/interpolated_selector/class.dart'; +export 'sass/interpolated_selector/complex_component.dart'; +export 'sass/interpolated_selector/complex.dart'; +export 'sass/interpolated_selector/compound.dart'; +export 'sass/interpolated_selector/id.dart'; +export 'sass/interpolated_selector/list.dart'; +export 'sass/interpolated_selector/parent.dart'; +export 'sass/interpolated_selector/placeholder.dart'; +export 'sass/interpolated_selector/pseudo.dart'; +export 'sass/interpolated_selector/qualified_name.dart'; +export 'sass/interpolated_selector/simple.dart'; +export 'sass/interpolated_selector/type.dart'; +export 'sass/interpolated_selector/universal.dart'; export 'sass/interpolation.dart'; export 'sass/node.dart'; export 'sass/parameter.dart'; diff --git a/lib/src/ast/sass/interpolated_selector.dart b/lib/src/ast/sass/interpolated_selector.dart new file mode 100644 index 000000000..cbe41a908 --- /dev/null +++ b/lib/src/ast/sass/interpolated_selector.dart @@ -0,0 +1,20 @@ +// 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 '../../visitor/interface/interpolated_selector.dart'; +import 'node.dart'; + +// Note: this has to be a concrete class so we can expose its accept() function +// to the JS parser. + +/// A simple selector before interoplation is resolved. +/// +/// Unlike [Selector], this is parsed during the initial stylesheet parse +/// when `parseSelectors: true` is passed to [Stylesheet.parse]. +/// +/// {@category AST} +abstract base class InterpolatedSelector implements SassNode { + /// Calls the appropriate visit method on [visitor]. + T accept(InterpolatedSelectorVisitor visitor); +} diff --git a/lib/src/ast/sass/interpolated_selector/attribute.dart b/lib/src/ast/sass/interpolated_selector/attribute.dart new file mode 100644 index 000000000..7e0e323a5 --- /dev/null +++ b/lib/src/ast/sass/interpolated_selector/attribute.dart @@ -0,0 +1,71 @@ +// 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 'package:source_span/source_span.dart'; + +import '../../../visitor/interface/interpolated_selector.dart'; +import '../../css/value.dart'; +import '../../sass/interpolation.dart'; +import '../../selector.dart'; +import 'qualified_name.dart'; +import 'simple.dart'; + +/// An attribute selector. +/// +/// Unlike [AttributeSelector], this is parsed during the initial stylesheet +/// parse when `parseSelectors: true` is passed to [Stylesheet.parse]. +/// +/// {@category AST} +final class InterpolatedAttributeSelector extends InterpolatedSimpleSelector { + /// The name of the attribute being selected for. + final InterpolatedQualifiedName name; + + /// The operator that defines the semantics of [value]. + /// + /// This is `null` if and only if [value] is `null`. + final CssValue? op; + + /// An assertion about the value of [name]. + /// + /// This is `null` if and only if [op] is `null`. + final Interpolation? value; + + /// The modifier which indicates how the attribute selector should be + /// processed. + /// + /// If [op] is `null`, this is always `null` as well. + final Interpolation? modifier; + + final FileSpan span; + + /// Creates an attribute selector that matches any element with a property of + /// the given name. + InterpolatedAttributeSelector(this.name, this.span) + : op = null, + value = null, + modifier = null; + + /// Creates an attribute selector that matches an element with a property + /// named [name], whose value matches [value] based on the semantics of [op]. + InterpolatedAttributeSelector.withOperator( + this.name, + this.op, + this.value, + this.span, { + this.modifier, + }); + + /// Calls the appropriate visit method on [visitor]. + T accept(InterpolatedSelectorVisitor visitor) => + visitor.visitAttributeSelector(this); + + String toString() { + var result = '[$name'; + if (op != null) { + result += '$op$value'; + if (modifier != null) result += ' $modifier'; + } + return result; + } +} diff --git a/lib/src/ast/sass/interpolated_selector/class.dart b/lib/src/ast/sass/interpolated_selector/class.dart new file mode 100644 index 000000000..9d0bb84fd --- /dev/null +++ b/lib/src/ast/sass/interpolated_selector/class.dart @@ -0,0 +1,32 @@ +// 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 'package:source_span/source_span.dart'; + +import '../../../visitor/interface/interpolated_selector.dart'; +import '../../sass/interpolation.dart'; +import '../../selector.dart'; +import 'simple.dart'; + +/// A class selector. +/// +/// Unlike [ClassSelector], this is parsed during the initial stylesheet parse +/// when `parseSelectors: true` is passed to [Stylesheet.parse]. +/// +/// {@category AST} +final class InterpolatedClassSelector extends InterpolatedSimpleSelector { + /// The class name this selects for. + final Interpolation name; + + FileSpan get span => + name.span.file.span(name.span.start.offset - 1, name.span.end.offset); + + InterpolatedClassSelector(this.name); + + /// Calls the appropriate visit method on [visitor]. + T accept(InterpolatedSelectorVisitor visitor) => + visitor.visitClassSelector(this); + + String toString() => '.$name'; +} diff --git a/lib/src/ast/sass/interpolated_selector/complex.dart b/lib/src/ast/sass/interpolated_selector/complex.dart new file mode 100644 index 000000000..2d8059bc9 --- /dev/null +++ b/lib/src/ast/sass/interpolated_selector/complex.dart @@ -0,0 +1,48 @@ +// 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 'package:source_span/source_span.dart'; + +import '../../../visitor/interface/interpolated_selector.dart'; +import '../../css/value.dart'; +import '../../selector.dart'; +import '../interpolated_selector.dart'; +import 'complex_component.dart'; + +/// A complex selector before interoplation is resolved. +/// +/// Unlike [ComplexSelector], this is parsed during the initial stylesheet parse +/// when `parseSelectors: true` is passed to [Stylesheet.parse]. +/// +/// {@category AST} +final class InterpolatedComplexSelector extends InterpolatedSelector { + /// This selector's leading combinator. + /// + /// If this is null, that indicates that it has no leading combinator. It's only null if + final CssValue? leadingCombinator; + + /// The components of this selector. + /// + /// This is only empty if [leadingCombinators] is not null. + final List components; + + final FileSpan span; + + InterpolatedComplexSelector( + Iterable components, this.span, + {this.leadingCombinator}) + : components = List.unmodifiable(components) { + if (leadingCombinator == null && this.components.isEmpty) { + throw ArgumentError( + "components may not be empty if leadingCombinator is null.", + ); + } + } + + /// Calls the appropriate visit method on [visitor]. + T accept(InterpolatedSelectorVisitor visitor) => + visitor.visitComplexSelector(this); + + String toString() => components.join(' '); +} diff --git a/lib/src/ast/sass/interpolated_selector/complex_component.dart b/lib/src/ast/sass/interpolated_selector/complex_component.dart new file mode 100644 index 000000000..b82092017 --- /dev/null +++ b/lib/src/ast/sass/interpolated_selector/complex_component.dart @@ -0,0 +1,38 @@ +// 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 'package:source_span/source_span.dart'; + +import '../../css/value.dart'; +import '../../selector.dart'; +import '../node.dart'; +import 'compound.dart'; + +/// A component of a [InterpolatedComplexSelector]. +/// +/// Unlike [ComplexSelectorComponent], this is parsed during the initial +/// stylesheet parse when `parseSelectors: true` is passed to +/// [Stylesheet.parse]. +/// +/// {@category AST} +final class InterpolatedComplexSelectorComponent implements SassNode { + /// This component's compound selector. + final InterpolatedCompoundSelector selector; + + /// This selector's combinator. + /// + /// If this is null, that indicates that it has an implicit descendent + /// combinator. + final CssValue? combinator; + + final FileSpan span; + + InterpolatedComplexSelectorComponent(this.selector, this.span, + {this.combinator}); + + String toString() => switch (combinator) { + var combinator? => '$selector $combinator', + _ => selector.toString() + }; +} diff --git a/lib/src/ast/sass/interpolated_selector/compound.dart b/lib/src/ast/sass/interpolated_selector/compound.dart new file mode 100644 index 000000000..d1eb67493 --- /dev/null +++ b/lib/src/ast/sass/interpolated_selector/compound.dart @@ -0,0 +1,38 @@ +// 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 'package:source_span/source_span.dart'; + +import '../../../visitor/interface/interpolated_selector.dart'; +import '../../selector.dart'; +import '../interpolated_selector.dart'; +import 'simple.dart'; + +/// A compound selector before interoplation is resolved. +/// +/// Unlike [CompoundSelector], this is parsed during the initial stylesheet parse +/// when `parseSelectors: true` is passed to [Stylesheet.parse]. +/// +/// {@category AST} +final class InterpolatedCompoundSelector extends InterpolatedSelector { + /// The components of this selector. + final List components; + + FileSpan get span => components.length == 1 + ? components.first.span + : components.first.span.expand(components.last.span); + + InterpolatedCompoundSelector(Iterable components) + : components = List.unmodifiable(components) { + if (this.components.isEmpty) { + throw ArgumentError("components may not be empty."); + } + } + + /// Calls the appropriate visit method on [visitor]. + T accept(InterpolatedSelectorVisitor visitor) => + visitor.visitCompoundSelector(this); + + String toString() => components.join(''); +} diff --git a/lib/src/ast/sass/interpolated_selector/id.dart b/lib/src/ast/sass/interpolated_selector/id.dart new file mode 100644 index 000000000..7a529771c --- /dev/null +++ b/lib/src/ast/sass/interpolated_selector/id.dart @@ -0,0 +1,32 @@ +// 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 'package:source_span/source_span.dart'; + +import '../../../visitor/interface/interpolated_selector.dart'; +import '../../sass/interpolation.dart'; +import '../../selector.dart'; +import 'simple.dart'; + +/// An ID selector. +/// +/// Unlike [IDSelector], this is parsed during the initial stylesheet parse when +/// `parseSelectors: true` is passed to [Stylesheet.parse]. +/// +/// {@category AST} +final class InterpolatedIDSelector extends InterpolatedSimpleSelector { + /// The id name this selects for. + final Interpolation name; + + FileSpan get span => + name.span.file.span(name.span.start.offset - 1, name.span.end.offset); + + InterpolatedIDSelector(this.name); + + /// Calls the appropriate visit method on [visitor]. + T accept(InterpolatedSelectorVisitor visitor) => + visitor.visitIDSelector(this); + + String toString() => '#$name'; +} diff --git a/lib/src/ast/sass/interpolated_selector/list.dart b/lib/src/ast/sass/interpolated_selector/list.dart new file mode 100644 index 000000000..ab7b69bc1 --- /dev/null +++ b/lib/src/ast/sass/interpolated_selector/list.dart @@ -0,0 +1,39 @@ +// 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 'package:source_span/source_span.dart'; + +import '../../../visitor/interface/interpolated_selector.dart'; +import '../interpolated_selector.dart'; +import 'complex.dart'; + +/// A selector list before interoplation is resolved. +/// +/// Unlike [SelectorList], this is parsed during the initial stylesheet parse +/// when `parseSelectors: true` is passed to [Stylesheet.parse]. +/// +/// {@category AST} +final class InterpolatedSelectorList extends InterpolatedSelector { + /// The components of this selector. + /// + /// This is never empty. + final List components; + + FileSpan get span => components.length == 1 + ? components.first.span + : components.first.span.expand(components.last.span); + + InterpolatedSelectorList(Iterable components) + : components = List.unmodifiable(components) { + if (this.components.isEmpty) { + throw ArgumentError("components may not be empty."); + } + } + + /// Calls the appropriate visit method on [visitor]. + T accept(InterpolatedSelectorVisitor visitor) => + visitor.visitSelectorList(this); + + String toString() => components.join(', '); +} diff --git a/lib/src/ast/sass/interpolated_selector/parent.dart b/lib/src/ast/sass/interpolated_selector/parent.dart new file mode 100644 index 000000000..96215facf --- /dev/null +++ b/lib/src/ast/sass/interpolated_selector/parent.dart @@ -0,0 +1,32 @@ +// 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 'package:source_span/source_span.dart'; + +import '../../../visitor/interface/interpolated_selector.dart'; +import '../../sass/interpolation.dart'; +import '../../selector.dart'; +import 'simple.dart'; + +/// A parent selector. +/// +/// Unlike [ParentSelector], this is parsed during the initial stylesheet parse +/// when `parseSelectors: true` is passed to [Stylesheet.parse]. +/// +/// {@category AST} +final class InterpolatedParentSelector extends InterpolatedSimpleSelector { + /// The suffix that will be added to the parent selector after it's been + /// resolved. + final Interpolation? suffix; + + final FileSpan span; + + InterpolatedParentSelector(this.span, {this.suffix}); + + /// Calls the appropriate visit method on [visitor]. + T accept(InterpolatedSelectorVisitor visitor) => + visitor.visitParentSelector(this); + + String toString() => switch (suffix) { var suffix? => '&$suffix', _ => '&' }; +} diff --git a/lib/src/ast/sass/interpolated_selector/placeholder.dart b/lib/src/ast/sass/interpolated_selector/placeholder.dart new file mode 100644 index 000000000..0493de78f --- /dev/null +++ b/lib/src/ast/sass/interpolated_selector/placeholder.dart @@ -0,0 +1,32 @@ +// 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 'package:source_span/source_span.dart'; + +import '../../../visitor/interface/interpolated_selector.dart'; +import '../../sass/interpolation.dart'; +import '../../selector.dart'; +import 'simple.dart'; + +/// A placeholder selector. +/// +/// Unlike [PlaceholderSelector], this is parsed during the initial stylesheet +/// parse when `parseSelectors: true` is passed to [Stylesheet.parse]. +/// +/// {@category AST} +final class InterpolatedPlaceholderSelector extends InterpolatedSimpleSelector { + /// The name of the placeholder. + final Interpolation name; + + FileSpan get span => + name.span.file.span(name.span.start.offset - 1, name.span.end.offset); + + InterpolatedPlaceholderSelector(this.name); + + /// Calls the appropriate visit method on [visitor]. + T accept(InterpolatedSelectorVisitor visitor) => + visitor.visitPlaceholderSelector(this); + + String toString() => '%$name'; +} diff --git a/lib/src/ast/sass/interpolated_selector/pseudo.dart b/lib/src/ast/sass/interpolated_selector/pseudo.dart new file mode 100644 index 000000000..f251e95d0 --- /dev/null +++ b/lib/src/ast/sass/interpolated_selector/pseudo.dart @@ -0,0 +1,72 @@ +// 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 'package:source_span/source_span.dart'; + +import '../../../visitor/interface/interpolated_selector.dart'; +import '../../sass/interpolation.dart'; +import '../../selector.dart'; +import 'list.dart'; +import 'simple.dart'; + +/// A psueod-class or pseudo-element selector. +/// +/// Unlike [PseudoSelector], this is parsed during the initial stylesheet parse +/// when `parseSelectors: true` is passed to [Stylesheet.parse]. +/// +/// {@category AST} +final class InterpolatedPseudoSelector extends InterpolatedSimpleSelector { + /// The name of this selector (including any vendor prefixes). + final Interpolation name; + + /// Whether this is syntactically a pseudo-class selector. + /// + /// This is `true` if and only if [isSyntacticElement] is `false`. + final bool isSyntacticClass; + + /// Whether this is syntactically a pseudo-element selector. + /// + /// This is `true` if and only if [isSyntacticClass] is `false`. + bool get isSyntacticElement => !isSyntacticClass; + + /// The non-selector argument passed to this selector. + /// + /// This is `null` if there's no argument. If [argument] and [selector] are + /// both non-`null`, the selector follows the argument. + final Interpolation? argument; + + /// The selector argument passed to this selector. + /// + /// This is `null` if there's no selector. If [argument] and [selector] are + /// both non-`null`, the selector follows the argument. + final InterpolatedSelectorList? selector; + + final FileSpan span; + + InterpolatedPseudoSelector( + this.name, + this.span, { + bool element = false, + this.argument, + this.selector, + }) : isSyntacticClass = !element; + + /// Calls the appropriate visit method on [visitor]. + T accept(InterpolatedSelectorVisitor visitor) => + visitor.visitPseudoSelector(this); + + String toString() { + var result = '${isSyntacticClass ? ':' : '::'}$name'; + if (argument != null || selector != null) { + result += '('; + if (argument case var argument?) { + result += argument.toString(); + if (selector != null) result += ' '; + } + if (selector case var selector?) result += selector.toString(); + result += ')'; + } + return result; + } +} diff --git a/lib/src/ast/sass/interpolated_selector/qualified_name.dart b/lib/src/ast/sass/interpolated_selector/qualified_name.dart new file mode 100644 index 000000000..f31d2d7ba --- /dev/null +++ b/lib/src/ast/sass/interpolated_selector/qualified_name.dart @@ -0,0 +1,35 @@ +// 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 'package:source_span/source_span.dart'; + +import '../../sass/interpolation.dart'; +import '../../selector.dart'; +import '../node.dart'; + +/// A component of a [InterpolatedComplexSelector]. +/// +/// Unlike [ComplexSelectorComponent], this is parsed during the initial +/// stylesheet parse when `parseSelectors: true` is passed to +/// [Stylesheet.parse]. +/// +/// {@category AST} +final class InterpolatedQualifiedName implements SassNode { + /// The identifier name. + final Interpolation name; + + final FileSpan span; + + /// The namespace name. + final Interpolation? namespace; + + /// Creates an attribute selector that matches any element with a property of + /// the given name. + InterpolatedQualifiedName(this.name, this.span, {this.namespace}); + + String toString() => switch (namespace) { + var namespace? => '$namespace|$name', + _ => name.toString() + }; +} diff --git a/lib/src/ast/sass/interpolated_selector/simple.dart b/lib/src/ast/sass/interpolated_selector/simple.dart new file mode 100644 index 000000000..e77e6f362 --- /dev/null +++ b/lib/src/ast/sass/interpolated_selector/simple.dart @@ -0,0 +1,13 @@ +// 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 '../interpolated_selector.dart'; + +/// A simple selector before interoplation is resolved. +/// +/// Unlike [SimpleSelector], this is parsed during the initial stylesheet parse +/// when `parseSelectors: true` is passed to [Stylesheet.parse]. +/// +/// {@category AST} +abstract base class InterpolatedSimpleSelector extends InterpolatedSelector {} diff --git a/lib/src/ast/sass/interpolated_selector/type.dart b/lib/src/ast/sass/interpolated_selector/type.dart new file mode 100644 index 000000000..24391573f --- /dev/null +++ b/lib/src/ast/sass/interpolated_selector/type.dart @@ -0,0 +1,33 @@ +// 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 'package:source_span/source_span.dart'; + +import '../../../visitor/interface/interpolated_selector.dart'; +import '../../selector.dart'; +import 'qualified_name.dart'; +import 'simple.dart'; + +/// An type selector. +/// +/// Unlike [TypeSelector], this is parsed during the initial stylesheet +/// parse when `parseSelectors: true` is passed to [Stylesheet.parse]. +/// +/// {@category AST} +final class InterpolatedTypeSelector extends InterpolatedSimpleSelector { + /// The element name being selected for. + final InterpolatedQualifiedName name; + + FileSpan get span => name.span; + + /// Creates a type selector that matches any element with a property of + /// the given name. + InterpolatedTypeSelector(this.name); + + /// Calls the appropriate visit method on [visitor]. + T accept(InterpolatedSelectorVisitor visitor) => + visitor.visitTypeSelector(this); + + String toString() => name.toString(); +} diff --git a/lib/src/ast/sass/interpolated_selector/universal.dart b/lib/src/ast/sass/interpolated_selector/universal.dart new file mode 100644 index 000000000..ff5e5c183 --- /dev/null +++ b/lib/src/ast/sass/interpolated_selector/universal.dart @@ -0,0 +1,32 @@ +// 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 'package:source_span/source_span.dart'; + +import '../../../visitor/interface/interpolated_selector.dart'; +import '../../sass/interpolation.dart'; +import '../../selector.dart'; +import 'simple.dart'; + +/// A universal selector. +/// +/// Unlike [UniversalSelector], this is parsed during the initial stylesheet +/// parse when `parseSelectors: true` is passed to [Stylesheet.parse]. +/// +/// {@category AST} +final class InterpolatedUniversalSelector extends InterpolatedSimpleSelector { + /// The selector namespace. + final Interpolation? namespace; + + final FileSpan span; + + InterpolatedUniversalSelector(this.span, {this.namespace}); + + /// Calls the appropriate visit method on [visitor]. + T accept(InterpolatedSelectorVisitor visitor) => + visitor.visitUniversalSelector(this); + + String toString() => + switch (namespace) { var namespace? => '$namespace|*', _ => '*' }; +} diff --git a/lib/src/ast/sass/statement/style_rule.dart b/lib/src/ast/sass/statement/style_rule.dart index 32031c762..1b21ff7a4 100644 --- a/lib/src/ast/sass/statement/style_rule.dart +++ b/lib/src/ast/sass/statement/style_rule.dart @@ -5,6 +5,7 @@ import 'package:source_span/source_span.dart'; import '../../../visitor/interface/statement.dart'; +import '../interpolated_selector/list.dart'; import '../interpolation.dart'; import '../statement.dart'; import 'parent.dart'; @@ -17,15 +18,33 @@ import 'parent.dart'; final class StyleRule extends ParentStatement> { /// The selector to which the declaration will be applied. /// - /// This is only parsed after the interpolation has been resolved. - final Interpolation selector; + /// This is only parsed after the interpolation has been resolved. This is + /// null if and only if [interpolatedSelector] is not null. + final Interpolation? selector; + + /// Like [selector], but with as much of the selector parsed as possible. + /// + /// This isn't used by Sass's internal logic, and is only set when + /// `parseSelectors: true` is passed to [Stylesheet.parse]. This is null if + /// and only if [selector] is not null. + final InterpolatedSelectorList? parsedSelector; final FileSpan span; + /// Constructs a style rule with [selector] set and [interpolatedSelector] + /// null. StyleRule(this.selector, Iterable children, this.span) - : super(List.unmodifiable(children)); + : parsedSelector = null, + super(List.unmodifiable(children)); + + /// Constructs a style rule with [interpolatedSelector] set and [selector] + /// null. + StyleRule.withParsedSelector( + this.parsedSelector, Iterable children, this.span) + : selector = null, + super(List.unmodifiable(children)); T accept(StatementVisitor visitor) => visitor.visitStyleRule(this); - String toString() => "$selector {${children.join(" ")}}"; + String toString() => "${selector ?? parsedSelector} {${children.join(" ")}}"; } diff --git a/lib/src/ast/sass/statement/stylesheet.dart b/lib/src/ast/sass/statement/stylesheet.dart index 2076abd5f..139371571 100644 --- a/lib/src/ast/sass/statement/stylesheet.dart +++ b/lib/src/ast/sass/statement/stylesheet.dart @@ -100,16 +100,23 @@ final class Stylesheet extends ParentStatement> { /// /// If passed, [url] is the name of the file from which [contents] comes. /// + /// If [parseSelectors] is true, this parses [StyleRule.parsedSelector]s + /// rather than [StyleRule.selector]s. + /// /// Throws a [SassFormatException] if parsing fails. - factory Stylesheet.parse(String contents, Syntax syntax, {Object? url}) { + factory Stylesheet.parse(String contents, Syntax syntax, + {Object? url, bool parseSelectors = false}) { try { switch (syntax) { case Syntax.sass: - return Stylesheet.parseSass(contents, url: url); + return Stylesheet.parseSass(contents, + url: url, parseSelectors: parseSelectors); case Syntax.scss: - return Stylesheet.parseScss(contents, url: url); + return Stylesheet.parseScss(contents, + url: url, parseSelectors: parseSelectors); case Syntax.css: - return Stylesheet.parseCss(contents, url: url); + return Stylesheet.parseCss(contents, + url: url, parseSelectors: parseSelectors); } } on SassException catch (error, stackTrace) { var url = error.span.sourceUrl; @@ -127,25 +134,37 @@ final class Stylesheet extends ParentStatement> { /// /// If passed, [url] is the name of the file from which [contents] comes. /// + /// If [parseSelectors] is true, this parses [StyleRule.parsedSelector]s + /// rather than [StyleRule.selector]s. + /// /// Throws a [SassFormatException] if parsing fails. - factory Stylesheet.parseSass(String contents, {Object? url}) => - SassParser(contents, url: url).parse(); + factory Stylesheet.parseSass(String contents, + {Object? url, bool parseSelectors = false}) => + SassParser(contents, url: url, parseSelectors: parseSelectors).parse(); /// Parses an SCSS stylesheet from [contents]. /// /// If passed, [url] is the name of the file from which [contents] comes. /// + /// If [parseSelectors] is true, this parses [StyleRule.parsedSelector]s + /// rather than [StyleRule.selector]s. + /// /// Throws a [SassFormatException] if parsing fails. - factory Stylesheet.parseScss(String contents, {Object? url}) => - ScssParser(contents, url: url).parse(); + factory Stylesheet.parseScss(String contents, + {Object? url, bool parseSelectors = false}) => + ScssParser(contents, url: url, parseSelectors: parseSelectors).parse(); /// Parses a plain CSS stylesheet from [contents]. /// /// If passed, [url] is the name of the file from which [contents] comes. /// + /// If [parseSelectors] is true, this parses [StyleRule.parsedSelector]s + /// rather than [StyleRule.selector]s. + /// /// Throws a [SassFormatException] if parsing fails. - factory Stylesheet.parseCss(String contents, {Object? url}) => - CssParser(contents, url: url).parse(); + factory Stylesheet.parseCss(String contents, + {Object? url, bool parseSelectors = false}) => + CssParser(contents, url: url, parseSelectors: parseSelectors).parse(); T accept(StatementVisitor visitor) => visitor.visitStylesheet(this); diff --git a/lib/src/ast/selector/attribute.dart b/lib/src/ast/selector/attribute.dart index 6fd1439f8..7e1ab069d 100644 --- a/lib/src/ast/selector/attribute.dart +++ b/lib/src/ast/selector/attribute.dart @@ -74,6 +74,8 @@ final class AttributeSelector extends SimpleSelector { } /// An operator that defines the semantics of an [AttributeSelector]. +/// +/// {@category AST} enum AttributeOperator { /// The attribute value exactly equals the given value. equal('='), diff --git a/lib/src/js/parser.dart b/lib/src/js/parser.dart index 0323f5db1..a8ef171ce 100644 --- a/lib/src/js/parser.dart +++ b/lib/src/js/parser.dart @@ -9,14 +9,18 @@ import 'package:js/js.dart'; import 'package:path/path.dart' as p; import 'package:source_span/source_span.dart'; +import '../ast/node.dart'; import '../ast/sass.dart'; import '../exception.dart'; +import '../js/visitor/simple_selector.dart'; import '../parse/parser.dart'; import '../syntax.dart'; import '../util/nullable.dart'; import '../util/span.dart'; +import '../util/lazy_file_span.dart'; import '../util/string.dart'; import '../visitor/interface/expression.dart'; +import '../visitor/interface/interpolated_selector.dart'; import '../visitor/interface/statement.dart'; import 'reflection.dart'; import 'set.dart'; @@ -33,6 +37,8 @@ class ParserExports { required Function toCssIdentifier, required Function createExpressionVisitor, required Function createStatementVisitor, + required Function createSimpleSelectorVisitor, + required Function createSourceFile, required Function setToJS, required Function mapToRecord, }); @@ -56,6 +62,7 @@ final _expression = NullExpression(bogusSpan); /// Loads and returns all the exports needed for the `sass-parser` package. ParserExports loadParserExports() { + _updateLazyFileSpanPrototype(); _updateAstPrototypes(); return ParserExports( parse: allowInterop(_parse), @@ -67,11 +74,27 @@ ParserExports loadParserExports() { createStatementVisitor: allowInterop( (JSStatementVisitorObject inner) => JSStatementVisitor(inner), ), + createSimpleSelectorVisitor: allowInterop( + (JSSimpleSelectorVisitorObject inner) => JSSimpleSelectorVisitor(inner), + ), + createSourceFile: allowInterop( + (String text) => SourceFile.fromString(text), + ), setToJS: allowInterop((Set set) => JSSet([...set])), mapToRecord: allowInterop(mapToObject), ); } +/// Updates the prototype of [LazyFileSpan] to provide access to JS. +void _updateLazyFileSpanPrototype() { + var span = LazyFileSpan(() => bogusSpan); + getJSClass(span).defineGetters({ + 'file': (LazyFileSpan span) => span.file, + 'length': (LazyFileSpan span) => span.length, + 'sourceUrl': (LazyFileSpan span) => span.sourceUrl, + }); +} + /// Modifies the prototypes of the Sass AST classes to provide access to JS. /// /// This API is not intended to be used directly by end users and is subject to @@ -81,10 +104,11 @@ void _updateAstPrototypes() { // We don't need explicit getters for field names, because dart2js preserves // them as-is, so we actually need to expose very little to JS manually. var file = SourceFile.fromString(''); - getJSClass(file).defineMethod( - 'getText', - (SourceFile self, int start, [int? end]) => self.getText(start, end), - ); + getJSClass(file).defineMethods({ + 'getText': (SourceFile self, int start, [int? end]) => + self.getText(start, end), + 'span': (SourceFile self, int start, [int? end]) => self.span(start, end), + }); getJSClass( file, ).defineGetter('codeUnits', (SourceFile self) => self.codeUnits); @@ -102,6 +126,13 @@ void _updateAstPrototypes() { (Expression self, ExpressionVisitor visitor) => self.accept(visitor), ); + var selector = InterpolatedParentSelector(bogusSpan); + getJSClass(selector).superclass.defineMethod( + 'accept', + (InterpolatedSelector self, + InterpolatedSelectorVisitor visitor) => + self.accept(visitor), + ); var arguments = ArgumentList([], {}, bogusSpan); getJSClass( IncludeRule('a', arguments, bogusSpan), @@ -122,13 +153,26 @@ void _updateAstPrototypes() { _addSupportsConditionToInterpolation(); + var klass = InterpolatedClassSelector(_interpolation); + var compound = InterpolatedCompoundSelector([klass]); for (var node in [ string, BinaryOperationExpression(BinaryOperator.plus, string, string), SupportsExpression(SupportsAnything(_interpolation, bogusSpan)), LoudComment(_interpolation), + klass, + InterpolatedIDSelector(_interpolation), + InterpolatedPlaceholderSelector(_interpolation), + InterpolatedTypeSelector( + InterpolatedQualifiedName(_interpolation, bogusSpan)), + compound, + InterpolatedSelectorList([ + InterpolatedComplexSelector( + [InterpolatedComplexSelectorComponent(compound, bogusSpan)], + bogusSpan) + ]), ]) { - getJSClass(node).defineGetter('span', (SassNode self) => self.span); + getJSClass(node).defineGetter('span', (AstNode self) => self.span); } } @@ -155,14 +199,16 @@ void _addSupportsConditionToInterpolation() { /// A JavaScript-friendly method to parse a stylesheet. Stylesheet _parse(String css, String syntax, String? path) => Stylesheet.parse( - css, - switch (syntax) { - 'scss' => Syntax.scss, - 'sass' => Syntax.sass, - 'css' => Syntax.css, - _ => throw UnsupportedError('Unknown syntax "$syntax"'), - }, - url: path.andThen(p.toUri)); + css, + switch (syntax) { + 'scss' => Syntax.scss, + 'sass' => Syntax.sass, + 'css' => Syntax.css, + _ => throw UnsupportedError('Unknown syntax "$syntax"'), + }, + url: path.andThen(p.toUri), + parseSelectors: true, + ); /// A JavaScript-friendly method to parse an identifier to its semantic value. /// diff --git a/lib/src/js/visitor/simple_selector.dart b/lib/src/js/visitor/simple_selector.dart new file mode 100644 index 000000000..abb9d6ea6 --- /dev/null +++ b/lib/src/js/visitor/simple_selector.dart @@ -0,0 +1,54 @@ +// 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 'package:js/js.dart'; + +import '../../ast/sass.dart'; +import '../../visitor/interface/interpolated_selector.dart'; + +/// A wrapper around a JS object that implements the [SelectorVisitor] methods +/// for simple selectors. +class JSSimpleSelectorVisitor implements InterpolatedSelectorVisitor { + final JSSimpleSelectorVisitorObject _inner; + + JSSimpleSelectorVisitor(this._inner); + + Object? visitAttributeSelector(InterpolatedAttributeSelector node) => + _inner.visitAttributeSelector(node); + Object? visitClassSelector(InterpolatedClassSelector node) => + _inner.visitClassSelector(node); + Object? visitIDSelector(InterpolatedIDSelector node) => + _inner.visitIDSelector(node); + Object? visitParentSelector(InterpolatedParentSelector node) => + _inner.visitParentSelector(node); + Object? visitPlaceholderSelector(InterpolatedPlaceholderSelector node) => + _inner.visitPlaceholderSelector(node); + Object? visitPseudoSelector(InterpolatedPseudoSelector node) => + _inner.visitPseudoSelector(node); + Object? visitTypeSelector(InterpolatedTypeSelector node) => + _inner.visitTypeSelector(node); + Object? visitUniversalSelector(InterpolatedUniversalSelector node) => + _inner.visitUniversalSelector(node); + + Never visitSelectorList(_) => _simpleSelectorError(); + Never visitComplexSelector(_) => _simpleSelectorError(); + Never visitCompoundSelector(_) => _simpleSelectorError(); + + /// Throws an error for non-simple selectors. + Never _simpleSelectorError() => throw UnsupportedError( + "SimpleSelectorVisitor only supports SimpleSelectors"); +} + +@JS() +class JSSimpleSelectorVisitorObject { + external Object? visitAttributeSelector(InterpolatedAttributeSelector node); + external Object? visitClassSelector(InterpolatedClassSelector node); + external Object? visitIDSelector(InterpolatedIDSelector node); + external Object? visitParentSelector(InterpolatedParentSelector node); + external Object? visitPlaceholderSelector( + InterpolatedPlaceholderSelector node); + external Object? visitPseudoSelector(InterpolatedPseudoSelector node); + external Object? visitTypeSelector(InterpolatedTypeSelector node); + external Object? visitUniversalSelector(InterpolatedUniversalSelector node); +} diff --git a/lib/src/parse/css.dart b/lib/src/parse/css.dart index cf2ac5503..bd23fde74 100644 --- a/lib/src/parse/css.dart +++ b/lib/src/parse/css.dart @@ -37,7 +37,7 @@ final _disallowedFunctionNames = class CssParser extends ScssParser { bool get plainCss => true; - CssParser(super.contents, {super.url}); + CssParser(super.contents, {super.url, super.parseSelectors}); bool silentComment() { if (inExpression) return false; diff --git a/lib/src/parse/parser.dart b/lib/src/parse/parser.dart index e4a76e86b..5d35d7083 100644 --- a/lib/src/parse/parser.dart +++ b/lib/src/parse/parser.dart @@ -250,8 +250,9 @@ class Parser { @protected String string() { // NOTE: this logic is largely duplicated in - // StylesheetParser.interpolatedString. Most changes here should be mirrored - // there. + // [StylesheetParser.interpolatedString] and + // [StylesheetParser.interpolatedStringToken]. Most changes here should be + // mirrored there. var quote = scanner.readChar(); if (quote != $single_quote && quote != $double_quote) { @@ -615,7 +616,7 @@ class Parser { /// Returns whether an identifier whose name exactly matches [text] is at the /// current scanner position. /// - /// This doesn't move the scan pointer forward + /// This doesn't move the scan pointer forward. @protected bool matchesIdentifier(String text, {bool caseSensitive = false}) { if (!lookingAtIdentifier()) return false; diff --git a/lib/src/parse/sass.dart b/lib/src/parse/sass.dart index 765748c8b..b70c2fd46 100644 --- a/lib/src/parse/sass.dart +++ b/lib/src/parse/sass.dart @@ -38,7 +38,7 @@ class SassParser extends StylesheetParser { bool get indented => true; - SassParser(super.contents, {super.url}); + SassParser(super.contents, {super.url, super.parseSelectors}); Interpolation styleRuleSelector() { var start = scanner.state; diff --git a/lib/src/parse/scss.dart b/lib/src/parse/scss.dart index 166941b2d..5fa1cc80f 100644 --- a/lib/src/parse/scss.dart +++ b/lib/src/parse/scss.dart @@ -15,7 +15,7 @@ class ScssParser extends StylesheetParser { bool get indented => false; int get currentIndentation => 0; - ScssParser(super.contents, {super.url}); + ScssParser(super.contents, {super.url, super.parseSelectors}); Interpolation styleRuleSelector() => almostAnyValue(); diff --git a/lib/src/parse/selector.dart b/lib/src/parse/selector.dart index 9c051f3c9..8af164466 100644 --- a/lib/src/parse/selector.dart +++ b/lib/src/parse/selector.dart @@ -3,6 +3,7 @@ // https://opensource.org/licenses/MIT. import 'package:charcode/charcode.dart'; +import 'package:meta/meta.dart'; import '../ast/css/value.dart'; import '../ast/selector.dart'; @@ -11,7 +12,8 @@ import '../utils.dart'; import 'parser.dart'; /// Pseudo-class selectors that take unadorned selectors as arguments. -final _selectorPseudoClasses = { +@internal +final selectorPseudoClasses = { "not", "is", "matches", @@ -24,9 +26,13 @@ final _selectorPseudoClasses = { }; /// Pseudo-element selectors that take unadorned selectors as arguments. -final _selectorPseudoElements = {"slotted"}; +@internal +final selectorPseudoElements = {"slotted"}; /// A parser for selectors. +/// +/// This class is largely duplicated between here and [SelectorParser]. Most +/// changes here should be mirrored there and vice versa. class SelectorParser extends Parser { /// Whether this parser allows the parent selector `&`. final bool _allowParent; @@ -402,12 +408,12 @@ class SelectorParser extends Parser { String? argument; SelectorList? selector; if (element) { - if (_selectorPseudoElements.contains(unvendored)) { + if (selectorPseudoElements.contains(unvendored)) { selector = _selectorList(); } else { argument = declarationValue(allowEmpty: true); } - } else if (_selectorPseudoClasses.contains(unvendored)) { + } else if (selectorPseudoClasses.contains(unvendored)) { selector = _selectorList(); } else if (unvendored == "nth-child" || unvendored == "nth-last-child") { argument = _aNPlusB(); diff --git a/lib/src/parse/stylesheet.dart b/lib/src/parse/stylesheet.dart index db7932796..a109b33fc 100644 --- a/lib/src/parse/stylesheet.dart +++ b/lib/src/parse/stylesheet.dart @@ -8,7 +8,10 @@ import 'package:path/path.dart' as p; import 'package:source_span/source_span.dart'; import 'package:string_scanner/string_scanner.dart'; +import '../ast/node.dart'; import '../ast/sass.dart'; +import '../ast/selector.dart'; +import '../ast/css/value.dart'; import '../color_names.dart'; import '../deprecation.dart'; import '../exception.dart'; @@ -19,6 +22,7 @@ import '../util/multi_span.dart'; import '../util/nullable.dart'; import '../value.dart'; import 'parser.dart'; +import 'selector.dart' show selectorPseudoClasses, selectorPseudoElements; /// The base class for both the SCSS and indented syntax parsers. /// @@ -32,6 +36,10 @@ import 'parser.dart'; /// private, except where they have to be public for subclasses to refer to /// them. abstract class StylesheetParser extends Parser { + /// Whether to parse the selectors in [StyleRules] as [InterpolatedSelector]s + /// rather than raw [Interpolation]s. + final bool _parseSelectors; + /// Whether we've consumed a rule other than `@charset`, `@forward`, or /// `@use`. var _isUseAllowed = true; @@ -79,7 +87,8 @@ abstract class StylesheetParser extends Parser { @protected SilentComment? lastSilentComment; - StylesheetParser(super.contents, {super.url}); + StylesheetParser(super.contents, {super.url, bool parseSelectors = false}) + : _parseSelectors = parseSelectors; // ## Statements @@ -513,13 +522,37 @@ abstract class StylesheetParser extends Parser { _isUseAllowed = false; var start = start_ ?? scanner.state; // dart-lang/sdk#45348 - var interpolation = styleRuleSelector(); - if (buffer != null) { - buffer.addInterpolation(interpolation); - interpolation = buffer.interpolation(spanFrom(start)); + if (_parseSelectors) { + if (start_ != null) scanner.state = start; + var selector = _selectorList(); + return _withStyleRuleChildren( + selector, + start, + (children, span) => StyleRule.withParsedSelector( + selector, children, spanFrom(start))); + } else { + var interpolation = styleRuleSelector(); + if (buffer != null) { + buffer.addInterpolation(interpolation); + interpolation = buffer.interpolation(spanFrom(start)); + } + if (interpolation.contents.isEmpty) scanner.error('expected "}".'); + + return _withStyleRuleChildren( + interpolation, + start, + (children, span) => + StyleRule(interpolation, children, spanFrom(start))); } - if (interpolation.contents.isEmpty) scanner.error('expected "}".'); + } + /// Consumes the children of a style rule and passes them, as well as the span + /// from [start] to the end of the child block, to [create]. + T _withStyleRuleChildren( + AstNode nodeWithSpan, + LineScannerState start, + T create(List children, FileSpan span), + ) { var wasInStyleRule = _inStyleRule; _inStyleRule = true; @@ -529,13 +562,12 @@ abstract class StylesheetParser extends Parser { deprecation: null, message: "This selector doesn't have any properties and won't be " "rendered.", - span: interpolation.span, + span: nodeWithSpan.span, )); } _inStyleRule = wasInStyleRule; - - return StyleRule(interpolation, children, spanFrom(start)); + return create(children, span); }); } @@ -3242,6 +3274,9 @@ abstract class StylesheetParser extends Parser { /// /// If [allowOpenBrace] is `false`, this stops at opening curly braces. /// + /// If [endAfterOf] is `true`, this stops *after consuming* a top-level + /// identifier with the value "of". + /// /// If [silentComments] is `true`, this will parse silent comments as /// comments. Otherwise, it will preserve two adjacent slashes and emit them /// to CSS. @@ -3250,12 +3285,15 @@ abstract class StylesheetParser extends Parser { /// as whitespace. It should only be set to `true` in positions when a /// statement can't end. /// + /// If [ + /// /// Unlike [declarationValue], this allows interpolation. Interpolation _interpolatedDeclarationValue({ bool allowEmpty = false, bool allowSemicolon = false, bool allowColon = true, bool allowOpenBrace = true, + bool endAfterOf = false, bool silentComments = true, bool consumeNewlines = false, }) { @@ -3365,6 +3403,17 @@ abstract class StylesheetParser extends Parser { } wroteNewline = false; + case $o || $O: + if (endAfterOf && brackets.isEmpty) { + var of = rawText(() => scanIdentifier("of", caseSensitive: false)); + if (of != "") { + buffer.write(of); + break loop; + } + } + buffer.writeCharCode(scanner.readChar()); + wroteNewline = false; + case null: break loop; @@ -3394,7 +3443,7 @@ abstract class StylesheetParser extends Parser { if (scanner.scanChar($dash)) { buffer.writeCharCode($dash); - _interpolatedIdentifierBody(buffer); + _interpolatedIdentifierBodyHelper(buffer); return buffer.interpolation(spanFrom(start)); } } @@ -3413,13 +3462,22 @@ abstract class StylesheetParser extends Parser { scanner.error("Expected identifier."); } - _interpolatedIdentifierBody(buffer); + _interpolatedIdentifierBodyHelper(buffer); return buffer.interpolation(spanFrom(start)); } /// Consumes a chunk of a possibly-interpolated CSS identifier after the name - /// start, and adds the contents to the [buffer] buffer. - void _interpolatedIdentifierBody(InterpolationBuffer buffer) { + /// start. + Interpolation _interpolatedIdentifierBody() { + var start = scanner.state; + var text = InterpolationBuffer(); + _interpolatedIdentifierBodyHelper(text); + if (text.isEmpty) scanner.error("Expected identifier body."); + return text.interpolation(spanFrom(start)); + } + + /// Like [_interpolatedIdentifierBody], but parses the body into [buffer]. + void _interpolatedIdentifierBodyHelper(InterpolationBuffer buffer) { loop: while (true) { switch (scanner.peekChar()) { @@ -3456,6 +3514,416 @@ abstract class StylesheetParser extends Parser { return (contents, span); } + // ## Selectors + + // What follows is largely duplicated between here and [SelectorParser]. Most + // changes here should be mirrored there and vice versa. + + /// Consumes a selector list. + InterpolatedSelectorList _selectorList() { + var previousLine = scanner.line; + var components = [_complexSelector()]; + + whitespace(consumeNewlines: false); + while (scanner.scanChar($comma)) { + whitespace(consumeNewlines: true); + if (scanner.peekChar() == $comma) continue; + if (scanner.isDone) break; + + var lineBreak = scanner.line != previousLine; + if (lineBreak) previousLine = scanner.line; + components.add(_complexSelector(lineBreak: lineBreak)); + } + + return InterpolatedSelectorList(components); + } + + /// Consumes a complex selector. + /// + /// If [lineBreak] is `true`, that indicates that there was a line break + /// before this selector. + InterpolatedComplexSelector _complexSelector( + {bool allowLeadingCombinator = true, + bool allowTrailingCombinator = true, + bool lineBreak = false}) { + var start = scanner.state; + + var componentStart = scanner.state; + InterpolatedCompoundSelector? lastCompound; + CssValue? combinator; + + CssValue? leadingCombinator; + var components = []; + + loop: + while (true) { + whitespace(consumeNewlines: false); + + var allowCombinator = combinator == null && + (allowLeadingCombinator || lastCompound != null); + switch (scanner.peekChar()) { + case $plus when allowCombinator: + var combinatorStart = scanner.state; + scanner.readChar(); + combinator = + CssValue(Combinator.nextSibling, spanFrom(combinatorStart)); + + case $gt when allowCombinator: + var combinatorStart = scanner.state; + scanner.readChar(); + combinator = CssValue(Combinator.child, spanFrom(combinatorStart)); + + case $tilde when allowCombinator: + var combinatorStart = scanner.state; + scanner.readChar(); + combinator = + CssValue(Combinator.followingSibling, spanFrom(combinatorStart)); + + case null: + break loop; + + case $lbracket || + $dot || + $hash || + $percent || + $colon || + $ampersand || + $asterisk || + $pipe: + case _ when _lookingAtInterpolatedIdentifier(): + if (lastCompound != null) { + components.add( + InterpolatedComplexSelectorComponent( + lastCompound, + spanFrom(componentStart), + combinator: combinator, + ), + ); + } else if (combinator != null) { + assert(leadingCombinator == null); + leadingCombinator = combinator; + componentStart = scanner.state; + } + + lastCompound = _compoundSelector(); + combinator = null; + if (scanner.peekChar() == $ampersand) { + scanner.error( + '"&" may only used at the beginning of a compound selector.', + ); + } + + case _: + break loop; + } + } + + if (combinator != null && (plainCss || !allowTrailingCombinator)) { + scanner.error("expected selector."); + } else if (lastCompound != null) { + components.add( + InterpolatedComplexSelectorComponent( + lastCompound, + spanFrom(componentStart), + combinator: combinator, + ), + ); + } else if (combinator != null) { + leadingCombinator = combinator; + } else { + scanner.error("expected selector."); + } + + return InterpolatedComplexSelector( + components, + spanFrom(start), + leadingCombinator: leadingCombinator, + ); + } + + /// Consumes a compound selector. + InterpolatedCompoundSelector _compoundSelector() { + var components = [_simpleSelector()]; + + while (_isSimpleSelectorStart(scanner.peekChar())) { + components.add(_simpleSelector(allowParent: plainCss)); + } + + return InterpolatedCompoundSelector(components); + } + + /// Consumes a simple selector. + /// + /// If [allowParent] is passed, it controls whether the parent selector `&` is + /// allowed. Otherwise, it defaults to [_allowParent]. + InterpolatedSimpleSelector _simpleSelector({bool allowParent = true}) { + var start = scanner.state; + switch (scanner.peekChar()) { + case $lbracket: + return _attributeSelector(); + case $dot: + return _classSelector(); + case $hash when scanner.peekChar(1) != $lbrace: + return _idSelector(); + case $percent: + var selector = _placeholderSelector(); + if (plainCss) { + error( + "Placeholder selectors aren't allowed in plain CSS.", + spanFrom(start), + ); + } + return selector; + case $colon: + return _pseudoSelector(); + case $ampersand: + var selector = _parentSelector(); + if (!allowParent) { + error( + "Parent selectors aren't allowed here.", + spanFrom(start), + ); + } + return selector; + + default: + return _typeOrUniversalSelector(); + } + } + + /// Consumes an attribute selector. + InterpolatedAttributeSelector _attributeSelector() { + var start = scanner.state; + scanner.expectChar($lbracket); + whitespace(consumeNewlines: true); + + var name = _attributeName(); + + whitespace(consumeNewlines: true); + if (scanner.scanChar($rbracket)) { + return InterpolatedAttributeSelector(name, spanFrom(start)); + } + + var operator = _attributeOperator(); + whitespace(consumeNewlines: true); + + var next = scanner.peekChar(); + var value = next == $single_quote || next == $double_quote + ? interpolatedStringToken() + : interpolatedIdentifier(); + whitespace(consumeNewlines: true); + + var modifier = + _lookingAtInterpolatedIdentifier() ? interpolatedIdentifier() : null; + whitespace(consumeNewlines: true); + + scanner.expectChar($rbracket); + return InterpolatedAttributeSelector.withOperator( + name, + operator, + value, + spanFrom(start), + modifier: modifier, + ); + } + + /// Consumes a qualified name as part of an attribute selector. + InterpolatedQualifiedName _attributeName() { + var start = scanner.state; + if (scanner.scanChar($asterisk)) { + var namespace = Interpolation.plain("*", spanFrom(start)); + scanner.expectChar($pipe); + return InterpolatedQualifiedName( + interpolatedIdentifier(), spanFrom(start), + namespace: namespace); + } + + if (scanner.scanChar($pipe)) { + var namespace = Interpolation.plain("", spanFrom(start, start)); + return InterpolatedQualifiedName( + interpolatedIdentifier(), spanFrom(start), + namespace: namespace); + } + + var nameOrNamespace = interpolatedIdentifier(); + if (scanner.peekChar() != $pipe || scanner.peekChar(1) == $equal) { + return InterpolatedQualifiedName(nameOrNamespace, spanFrom(start)); + } + + scanner.readChar(); + return InterpolatedQualifiedName(interpolatedIdentifier(), spanFrom(start), + namespace: nameOrNamespace); + } + + /// Consumes an attribute selector's operator. + CssValue _attributeOperator() { + var start = scanner.state; + AttributeOperator op; + switch (scanner.readChar()) { + case $equal: + op = AttributeOperator.equal; + + case $tilde: + scanner.expectChar($equal); + op = AttributeOperator.include; + + case $pipe: + scanner.expectChar($equal); + op = AttributeOperator.dash; + + case $caret: + scanner.expectChar($equal); + op = AttributeOperator.prefix; + + case $dollar: + scanner.expectChar($equal); + op = AttributeOperator.suffix; + + case $asterisk: + scanner.expectChar($equal); + op = AttributeOperator.substring; + + default: + scanner.error('Expected "]".', position: start.position); + } + return CssValue(op, spanFrom(start)); + } + + /// Consumes a class selector. + InterpolatedClassSelector _classSelector() { + scanner.expectChar($dot); + var name = interpolatedIdentifier(); + return InterpolatedClassSelector(name); + } + + /// Consumes an ID selector. + InterpolatedIDSelector _idSelector() { + scanner.expectChar($hash); + var name = interpolatedIdentifier(); + return InterpolatedIDSelector(name); + } + + /// Consumes a placeholder selector. + InterpolatedPlaceholderSelector _placeholderSelector() { + scanner.expectChar($percent); + var name = interpolatedIdentifier(); + return InterpolatedPlaceholderSelector(name); + } + + /// Consumes a parent selector. + InterpolatedParentSelector _parentSelector() { + var start = scanner.state; + scanner.expectChar($ampersand); + var suffix = _lookingAtInterpolatedIdentifierBody() + ? _interpolatedIdentifierBody() + : null; + if (plainCss && suffix != null) { + scanner.error( + "Parent selectors can't have suffixes in plain CSS.", + position: start.position, + length: scanner.position - start.position, + ); + } + + return InterpolatedParentSelector(spanFrom(start), suffix: suffix); + } + + /// Consumes a pseudo selector. + InterpolatedPseudoSelector _pseudoSelector() { + var start = scanner.state; + scanner.expectChar($colon); + var element = scanner.scanChar($colon); + var name = interpolatedIdentifier(); + + if (!scanner.scanChar($lparen)) { + return InterpolatedPseudoSelector(name, spanFrom(start), + element: element); + } + whitespace(consumeNewlines: true); + + var unvendored = name.asPlain.andThen(unvendor); + Interpolation? argument; + InterpolatedSelectorList? selector; + if (element) { + if (selectorPseudoElements.contains(unvendored)) { + selector = _selectorList(); + } else { + argument = _interpolatedDeclarationValue(allowEmpty: true); + } + } else if (selectorPseudoClasses.contains(unvendored)) { + selector = _selectorList(); + } else if (unvendored == "nth-child" || unvendored == "nth-last-child") { + argument = _interpolatedDeclarationValue( + endAfterOf: true, consumeNewlines: true); + if (scanner.peekChar() != $rparen) selector = _selectorList(); + } else { + argument = _interpolatedDeclarationValue(allowEmpty: true); + } + scanner.expectChar($rparen); + + return InterpolatedPseudoSelector( + name, + spanFrom(start), + element: element, + argument: argument, + selector: selector, + ); + } + + /// Consumes a type selector or a universal selector. + /// + /// These are combined because either one could start with `*`. + InterpolatedSimpleSelector _typeOrUniversalSelector() { + var start = scanner.state; + if (scanner.scanChar($asterisk)) { + var afterAsterisk = scanner.state; + if (!scanner.scanChar($pipe)) { + return InterpolatedUniversalSelector(spanFrom(start)); + } + var namespace = Interpolation.plain("*", spanFrom(start, afterAsterisk)); + return scanner.scanChar($asterisk) + ? InterpolatedUniversalSelector(spanFrom(start), namespace: namespace) + : InterpolatedTypeSelector(InterpolatedQualifiedName( + interpolatedIdentifier(), spanFrom(start), + namespace: namespace)); + } else if (scanner.scanChar($pipe)) { + var namespace = Interpolation.plain("", spanFrom(start, start)); + return scanner.scanChar($asterisk) + ? InterpolatedUniversalSelector(spanFrom(start), namespace: namespace) + : InterpolatedTypeSelector(InterpolatedQualifiedName( + interpolatedIdentifier(), spanFrom(start), + namespace: namespace)); + } + + var nameOrNamespace = interpolatedIdentifier(); + if (!scanner.scanChar($pipe)) { + return InterpolatedTypeSelector( + InterpolatedQualifiedName(nameOrNamespace, spanFrom(start))); + } else if (scanner.scanChar($asterisk)) { + return InterpolatedUniversalSelector(spanFrom(start), + namespace: nameOrNamespace); + } else { + return InterpolatedTypeSelector(InterpolatedQualifiedName( + interpolatedIdentifier(), spanFrom(start), + namespace: nameOrNamespace)); + } + } + + // Returns whether [character] can start a simple selector in the middle of a + // compound selector. + bool _isSimpleSelectorStart(int? character) => switch (character) { + $asterisk || + $lbracket || + $dot || + $hash || + $percent || + $colon || + $hash => + true, + $ampersand => plainCss, + _ => false, + }; + // ## Media Queries /// Consumes a list of media queries. diff --git a/lib/src/visitor/ast_search.dart b/lib/src/visitor/ast_search.dart index 649defdae..50dd4a49c 100644 --- a/lib/src/visitor/ast_search.dart +++ b/lib/src/visitor/ast_search.dart @@ -8,20 +8,21 @@ import '../ast/sass.dart'; import '../util/iterable.dart'; import '../util/nullable.dart'; import 'interface/expression.dart'; -import 'recursive_statement.dart'; +import 'interface/interpolated_selector.dart'; import 'statement_search.dart'; /// A visitor that recursively traverses each statement and expression in a Sass /// AST whose `visit*` methods default to returning `null`, but which returns /// the first non-`null` value returned by any method. /// -/// This extends [RecursiveStatementVisitor] to traverse each expression in -/// addition to each statement. It supports the same additional methods as -/// [RecursiveAstVisitor]. +/// This extends [StatementSearchVisitor] to traverse each expression in +/// addition to each statement, as well as each selector for ASTs where +/// `parseSelectors: true` was passed to [Stylesheet.parse]. It supports the +/// same additional methods as [RecursiveAstVisitor]. /// /// {@category Visitor} mixin AstSearchVisitor on StatementSearchVisitor - implements ExpressionVisitor { + implements ExpressionVisitor, InterpolatedSelectorVisitor { T? visitAtRootRule(AtRootRule node) => node.query.andThen(visitInterpolation) ?? super.visitAtRootRule(node); @@ -84,7 +85,7 @@ mixin AstSearchVisitor on StatementSearchVisitor T? visitReturnRule(ReturnRule node) => visitExpression(node.expression); T? visitStyleRule(StyleRule node) => - visitInterpolation(node.selector) ?? super.visitStyleRule(node); + node.selector.andThen(visitInterpolation) ?? super.visitStyleRule(node); T? visitSupportsRule(SupportsRule node) => visitSupportsCondition(node.condition) ?? super.visitSupportsRule(node); @@ -146,6 +147,40 @@ mixin AstSearchVisitor on StatementSearchVisitor T? visitVariableExpression(VariableExpression node) => null; + T? visitAttributeSelector(InterpolatedAttributeSelector node) => + visitQualifiedName(node.name) ?? + node.value.andThen(visitInterpolation) ?? + node.modifier.andThen(visitInterpolation); + + T? visitClassSelector(InterpolatedClassSelector node) => + visitInterpolation(node.name); + + T? visitComplexSelector(InterpolatedComplexSelector node) => node.components + .search((component) => visitCompoundSelector(component.selector)); + + T? visitIDSelector(InterpolatedIDSelector node) => + visitInterpolation(node.name); + + T? visitParentSelector(InterpolatedParentSelector node) => + node.suffix.andThen(visitInterpolation); + + T? visitPlaceholderSelector(InterpolatedPlaceholderSelector node) => + visitInterpolation(node.name); + + T? visitPseudoSelector(InterpolatedPseudoSelector node) => + visitInterpolation(node.name) ?? + node.argument.andThen(visitInterpolation) ?? + node.selector.andThen(visitSelectorList); + + T? visitSelectorList(InterpolatedSelectorList node) => + node.components.search((component) => visitComplexSelector(component)); + + T? visitTypeSelector(InterpolatedTypeSelector node) => + visitQualifiedName(node.name); + + T? visitUniverssalSelector(InterpolatedUniversalSelector node) => + node.namespace.andThen(visitInterpolation); + @protected T? visitCallableDeclaration(CallableDeclaration node) => node.parameters.parameters.search( @@ -190,4 +225,13 @@ mixin AstSearchVisitor on StatementSearchVisitor @protected T? visitInterpolation(Interpolation interpolation) => interpolation.contents .search((node) => node is Expression ? visitExpression(node) : null); + + /// Visits each expression in [node]. + /// + /// The default implementation of the visit methods call this to visit any + /// qualified names in a selector. + @protected + T? visitQualifiedName(InterpolatedQualifiedName node) => + node.namespace.andThen(visitInterpolation) ?? + visitInterpolation(node.name); } diff --git a/lib/src/visitor/async_evaluate.dart b/lib/src/visitor/async_evaluate.dart index a14f2cfb5..95d7a506e 100644 --- a/lib/src/visitor/async_evaluate.dart +++ b/lib/src/visitor/async_evaluate.dart @@ -2326,7 +2326,7 @@ final class _EvaluateVisitor } var (selectorText, selectorMap) = await _performInterpolationWithMap( - node.selector, + node.selector!, warnForColor: true, ); @@ -2339,7 +2339,7 @@ final class _EvaluateVisitor interpolationMap: selectorMap, ).parse(); var rule = ModifiableCssKeyframeBlock( - CssValue(List.unmodifiable(parsedSelector), node.selector.span), + CssValue(List.unmodifiable(parsedSelector), node.selector!.span), node.span, ); await _withParent( diff --git a/lib/src/visitor/evaluate.dart b/lib/src/visitor/evaluate.dart index 2d5d59eba..b7e7daf55 100644 --- a/lib/src/visitor/evaluate.dart +++ b/lib/src/visitor/evaluate.dart @@ -5,7 +5,7 @@ // DO NOT EDIT. This file was generated from async_evaluate.dart. // See tool/grind/synchronize.dart for details. // -// Checksum: deb9d00931542dae02ed23607cb17f1273849432 +// Checksum: 02945a8ba2cb3edbec87fc839774006493379dae // // ignore_for_file: unused_import @@ -2333,7 +2333,7 @@ final class _EvaluateVisitor } var (selectorText, selectorMap) = _performInterpolationWithMap( - node.selector, + node.selector!, warnForColor: true, ); @@ -2346,7 +2346,7 @@ final class _EvaluateVisitor interpolationMap: selectorMap, ).parse(); var rule = ModifiableCssKeyframeBlock( - CssValue(List.unmodifiable(parsedSelector), node.selector.span), + CssValue(List.unmodifiable(parsedSelector), node.selector!.span), node.span, ); _withParent( diff --git a/lib/src/visitor/interface/interpolated_selector.dart b/lib/src/visitor/interface/interpolated_selector.dart new file mode 100644 index 000000000..18db1724f --- /dev/null +++ b/lib/src/visitor/interface/interpolated_selector.dart @@ -0,0 +1,24 @@ +// 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 '../../ast/sass.dart'; + +/// An interface for [visitors] that traverse interpolated selectors. +/// +/// [visitors]: https://en.wikipedia.org/wiki/Visitor_pattern +/// +/// {@category Visitor} +abstract interface class InterpolatedSelectorVisitor { + T visitAttributeSelector(InterpolatedAttributeSelector attribute); + T visitClassSelector(InterpolatedClassSelector klass); + T visitComplexSelector(InterpolatedComplexSelector complex); + T visitCompoundSelector(InterpolatedCompoundSelector compound); + T visitIDSelector(InterpolatedIDSelector id); + T visitParentSelector(InterpolatedParentSelector placeholder); + T visitPlaceholderSelector(InterpolatedPlaceholderSelector placeholder); + T visitPseudoSelector(InterpolatedPseudoSelector pseudo); + T visitSelectorList(InterpolatedSelectorList list); + T visitTypeSelector(InterpolatedTypeSelector type); + T visitUniversalSelector(InterpolatedUniversalSelector universal); +} diff --git a/lib/src/visitor/recursive_ast.dart b/lib/src/visitor/recursive_ast.dart index b8d960887..7aabb5942 100644 --- a/lib/src/visitor/recursive_ast.dart +++ b/lib/src/visitor/recursive_ast.dart @@ -7,6 +7,7 @@ import 'package:meta/meta.dart'; import '../util/nullable.dart'; import '../ast/sass.dart'; import 'interface/expression.dart'; +import 'interface/interpolated_selector.dart'; import 'recursive_statement.dart'; /// A visitor that recursively traverses each statement and expression in a Sass @@ -18,10 +19,11 @@ import 'recursive_statement.dart'; /// * [visitArgumentList] /// * [visitSupportsCondition] /// * [visitInterpolation] +/// * [visitQualifiedname] /// /// {@category Visitor} mixin RecursiveAstVisitor on RecursiveStatementVisitor - implements ExpressionVisitor { + implements ExpressionVisitor, InterpolatedSelectorVisitor { void visitAtRootRule(AtRootRule node) { node.query.andThen(visitInterpolation); super.visitAtRootRule(node); @@ -109,7 +111,7 @@ mixin RecursiveAstVisitor on RecursiveStatementVisitor } void visitStyleRule(StyleRule node) { - visitInterpolation(node.selector); + node.selector.andThen(visitInterpolation); super.visitStyleRule(node); } @@ -210,6 +212,54 @@ mixin RecursiveAstVisitor on RecursiveStatementVisitor void visitVariableExpression(VariableExpression node) {} + void visitAttributeSelector(InterpolatedAttributeSelector node) { + visitQualifiedName(node.name); + node.value.andThen(visitInterpolation); + node.modifier.andThen(visitInterpolation); + } + + void visitClassSelector(InterpolatedClassSelector node) { + visitInterpolation(node.name); + } + + void visitComplexSelector(InterpolatedComplexSelector node) { + for (var component in node.components) { + visitCompoundSelector(component.selector); + } + } + + void visitIDSelector(InterpolatedIDSelector node) { + visitInterpolation(node.name); + } + + void visitParentSelector(InterpolatedParentSelector node) { + node.suffix.andThen(visitInterpolation); + } + + void visitPlaceholderSelector(InterpolatedPlaceholderSelector node) { + visitInterpolation(node.name); + } + + void visitPseudoSelector(InterpolatedPseudoSelector node) { + visitInterpolation(node.name); + node.argument.andThen(visitInterpolation); + node.selector.andThen(visitSelectorList); + } + + void visitSelectorList(InterpolatedSelectorList node) { + for (var component in node.components) { + visitComplexSelector(component); + } + } + + void visitTypeSelector(InterpolatedTypeSelector node) { + visitQualifiedName(node.name); + } + + void visitUniverssalSelector(InterpolatedUniversalSelector node) { + node.namespace.andThen(visitInterpolation); + } + @protected void visitCallableDeclaration(CallableDeclaration node) { for (var parameter in node.parameters.parameters) { @@ -264,4 +314,14 @@ mixin RecursiveAstVisitor on RecursiveStatementVisitor if (node is Expression) visitExpression(node); } } + + /// Visits each interpolatoin in [node]. + /// + /// The default implementation of the visit methods calls this to visit any + /// qualified names in a selector. + @protected + void visitQualifiedName(InterpolatedQualifiedName node) { + node.namespace.andThen(visitInterpolation); + visitInterpolation(node.name); + } } diff --git a/pkg/sass-parser/CHANGELOG.md b/pkg/sass-parser/CHANGELOG.md index fad954c13..aa525a4e2 100644 --- a/pkg/sass-parser/CHANGELOG.md +++ b/pkg/sass-parser/CHANGELOG.md @@ -1,3 +1,7 @@ +## 0.4.33 + +* No user-visible changes. + ## 0.4.32 * No user-visible changes. diff --git a/pkg/sass-parser/README.md b/pkg/sass-parser/README.md index bfb2b3520..7c75cfffe 100644 --- a/pkg/sass-parser/README.md +++ b/pkg/sass-parser/README.md @@ -166,9 +166,9 @@ In addition to supporting the standard PostCSS properties like `Declaration.value` and `Rule.selector`, `sass-parser` provides more detailed parsed values. For example, `sassParser.Declaration.valueExpression` provides the declaration's value as a fully-parsed syntax tree rather than a string, and -`sassParser.Rule.selectorInterpolation` provides access to any interpolated -expressions as in `.prefix-#{$variable} { /*...*/ }`. These parsed values are -automatically kept up-to-date with the standard PostCSS properties. +`sassParser.Rule.parsedSelector` provides access to the fully-parsed selectr. +These parsed values are automatically kept up-to-date with the standard PostCSS +properties. ### Expression API diff --git a/pkg/sass-parser/lib/index.ts b/pkg/sass-parser/lib/index.ts index bb7e2decf..6efd32c74 100644 --- a/pkg/sass-parser/lib/index.ts +++ b/pkg/sass-parser/lib/index.ts @@ -150,6 +150,83 @@ export { ParameterProps, Parameter, } from './src/parameter'; +export { + AnySimpleSelector, + SimpleSelectorType, + SimpleSelectorProps, + SimpleSelector, +} from './src/selector'; +export { + AttributeSelectorOperator, + AttributeSelectorProps, + AttributeSelectorRaws, + AttributeSelector, +} from './src/selector/attribute'; +export { + ClassSelectorProps, + ClassSelectorRaws, + ClassSelector, +} from './src/selector/class'; +export { + ComplexSelectorComponentObjectProps, + ComplexSelectorComponentProps, + ComplexSelectorComponentRaws, + ComplexSelectorComponent, +} from './src/selector/complex-component'; +export { + ComplexSelectorObjectProps, + ComplexSelectorProps, + NewNodeForComplexSelector, + ComplexSelectorRaws, + ComplexSelector, + SelectorCombinator, +} from './src/selector/complex'; +export { + CompoundSelectorObjectProps, + CompoundSelectorProps, + NewNodeForCompoundSelector, + CompoundSelectorRaws, + CompoundSelector, +} from './src/selector/compound'; +export {IDSelectorProps, IDSelectorRaws, IDSelector} from './src/selector/id'; +export { + SelectorListObjectProps, + SelectorListProps, + NewNodeForSelectorList, + SelectorListRaws, + SelectorList, +} from './src/selector/list'; +export { + ParentSelectorProps, + ParentSelectorRaws, + ParentSelector, +} from './src/selector/parent'; +export { + PlaceholderSelectorProps, + PlaceholderSelectorRaws, + PlaceholderSelector, +} from './src/selector/placeholder'; +export { + PseudoSelectorProps, + PseudoSelectorRaws, + PseudoSelector, +} from './src/selector/pseudo'; +export { + QualifiedNameObjectProps, + QualifiedNameProps, + QualifiedNameRaws, + QualifiedName, +} from './src/selector/qualified-name'; +export { + TypeSelectorProps, + TypeSelectorRaws, + TypeSelector, +} from './src/selector/type'; +export { + UniversalSelectorProps, + UniversalSelectorRaws, + UniversalSelector, +} from './src/selector/universal'; export { ContentRule, ContentRuleProps, diff --git a/pkg/sass-parser/lib/src/expression/list.test.ts b/pkg/sass-parser/lib/src/expression/list.test.ts index 5e3a7f2c6..280eac901 100644 --- a/pkg/sass-parser/lib/src/expression/list.test.ts +++ b/pkg/sass-parser/lib/src/expression/list.test.ts @@ -877,7 +877,7 @@ describe('a list expression', () => { }), ).toHaveStringExpression('first', 'foo')); - it('returns undefined for an empty interpolation', () => + it('returns undefined for an empty list', () => expect( new ListExpression({separator: null, nodes: []}).first, ).toBeUndefined()); @@ -892,7 +892,7 @@ describe('a list expression', () => { }), ).toHaveStringExpression('last', 'baz')); - it('returns undefined for an empty interpolation', () => + it('returns undefined for an empty list', () => expect( new ListExpression({separator: null, nodes: []}).last, ).toBeUndefined()); diff --git a/pkg/sass-parser/lib/src/expression/list.ts b/pkg/sass-parser/lib/src/expression/list.ts index f5fd0cd3a..0d36c8c3e 100644 --- a/pkg/sass-parser/lib/src/expression/list.ts +++ b/pkg/sass-parser/lib/src/expression/list.ts @@ -372,7 +372,6 @@ export class ListExpression constructed.parent = this; normalized.push(constructed); } - node.parent = this; } return normalized; } diff --git a/pkg/sass-parser/lib/src/interpolation.ts b/pkg/sass-parser/lib/src/interpolation.ts index 74491c314..b2c65a626 100644 --- a/pkg/sass-parser/lib/src/interpolation.ts +++ b/pkg/sass-parser/lib/src/interpolation.ts @@ -45,7 +45,7 @@ export interface InterpolationObjectProps extends NodeProps { } /** - * The initializer properties for {@link Interpolation} passed. + * The initializer properties for {@link Interpolation}. * * A plain string is interpreted as a plain-text interpolation. * diff --git a/pkg/sass-parser/lib/src/lazy-source.ts b/pkg/sass-parser/lib/src/lazy-source.ts index bcf93bfcd..ceb694b14 100644 --- a/pkg/sass-parser/lib/src/lazy-source.ts +++ b/pkg/sass-parser/lib/src/lazy-source.ts @@ -23,6 +23,11 @@ export class LazySource implements postcss.Source { this.#inner = inner; } + /** @hidden */ + get dartSpan(): sassInternal.FileSpan { + return this.#inner.span; + } + get start(): postcss.Position | undefined { if (this.#start === 0) { this.#start = locationToPosition(this.#inner.span.start); diff --git a/pkg/sass-parser/lib/src/node.d.ts b/pkg/sass-parser/lib/src/node.d.ts index b4026e362..6b952864d 100644 --- a/pkg/sass-parser/lib/src/node.d.ts +++ b/pkg/sass-parser/lib/src/node.d.ts @@ -16,14 +16,24 @@ import {Interpolation} from './interpolation'; import {Parameter} from './parameter'; import {ParameterList} from './parameter-list'; import {AnyStatement, StatementType} from './statement'; +import {AnySimpleSelector, SimpleSelectorType} from './selector'; +import {CompoundSelector} from './selector/compound'; +import {ComplexSelector} from './selector/complex'; +import {ComplexSelectorComponent} from './selector/complex-component'; +import {SelectorList} from './selector/list'; +import {QualifiedName} from './selector/qualified-name'; import {StaticImport} from './static-import'; /** The union type of all Sass nodes. */ export type AnyNode = | AnyExpression + | AnySimpleSelector | AnyStatement | Argument | ArgumentList + | ComplexSelector + | ComplexSelectorComponent + | CompoundSelector | Configuration | ConfiguredVariable | DynamicImport @@ -32,6 +42,8 @@ export type AnyNode = | MapEntry | Parameter | ParameterList + | QualifiedName + | SelectorList | StaticImport; /** @@ -44,8 +56,12 @@ export type AnyNode = export type NodeType = | StatementType | ExpressionType + | SimpleSelectorType | 'argument' | 'argument-list' + | 'complex-selector' + | 'complex-selector-component' + | 'compound-selector' | 'configuration' | 'configured-variable' | 'dynamic-import' @@ -54,6 +70,8 @@ export type NodeType = | 'map-entry' | 'parameter' | 'parameter-list' + | 'qualified-name' + | 'selector-list' | 'static-import'; /** The constructor properties shared by all Sass AST nodes. */ diff --git a/pkg/sass-parser/lib/src/sass-internal.ts b/pkg/sass-parser/lib/src/sass-internal.ts index cfc5f6e51..5e1e2642a 100644 --- a/pkg/sass-parser/lib/src/sass-internal.ts +++ b/pkg/sass-parser/lib/src/sass-internal.ts @@ -21,9 +21,13 @@ export interface SourceFile { /** Node-only extension that we use to avoid re-creating inputs. */ _postcssInput?: postcss.Input; + length: number; + readonly codeUnits: number[]; getText(start: number, end?: number): string; + + span(start: number, end?: number): FileSpan; } export interface DartSet { @@ -51,11 +55,15 @@ export interface DartPair { declare namespace SassInternal { function parse(css: string, syntax: Syntax, path?: string): Stylesheet; + function parseSelectorList(contents: string, path?: string): SelectorList; + function parseIdentifier( identifier: string, logger?: sass.Logger, ): string | null; + function createSourceFile(text: string, path?: string): SourceFile; + function toCssIdentifier(text: string): string; function setToJS(set: DartSet): Set; @@ -63,7 +71,7 @@ declare namespace SassInternal { function mapToRecord(set: DartMap): Record; class StatementVisitor { - private _fakePropertyToMakeThisAUniqueType1: T; + private _fakePropertyToMakeStatementVisitorAUniqueType: T; } function createStatementVisitor( @@ -71,7 +79,7 @@ declare namespace SassInternal { ): StatementVisitor; class ExpressionVisitor { - private _fakePropertyToMakeThisAUniqueType2: T; + private _fakePropertyToMakeExpressionVisitorAUniqueType: T; } function createExpressionVisitor( @@ -82,6 +90,10 @@ declare namespace SassInternal { readonly span: FileSpan; } + class CssValue extends SassNode { + readonly value: T; + } + class ArgumentList extends SassNode { readonly positional: Expression[]; readonly named: DartMap; @@ -90,6 +102,7 @@ declare namespace SassInternal { } class Interpolation extends SassNode { + spans: (FileSpan | undefined)[]; contents: (string | Expression)[]; get asPlain(): string | undefined; } @@ -234,7 +247,7 @@ declare namespace SassInternal { class Stylesheet extends ParentStatement {} class StyleRule extends ParentStatement { - readonly selector: Interpolation; + readonly parsedSelector: SelectorList; } class SupportsRule extends ParentStatement { @@ -402,6 +415,81 @@ declare namespace SassInternal { readonly namespace?: string | null; readonly name: string; } + + // Selectors + + class SimpleSelectorVisitor { + private _fakePropertyToMakeSimpleSelectorVisitorAUniqueType: T; + } + + function createSimpleSelectorVisitor( + inner: SimpleSelectorVisitorObject, + ): SimpleSelectorVisitor; + + class SimpleSelector extends SassNode { + accept(visitor: SimpleSelectorVisitor): T; + } + + class AttributeSelector extends SimpleSelector { + readonly name: QualifiedName; + readonly op: object; + readonly value: Interpolation | null | undefined; + readonly modifier: Interpolation | null | undefined; + } + + class ClassSelector extends SimpleSelector { + readonly name: Interpolation; + } + + class ComplexSelector extends SassNode { + readonly leadingCombinator: CssValue | null | undefined; + readonly components: ComplexSelectorComponent[]; + } + + class ComplexSelectorComponent extends SassNode { + readonly selector: CompoundSelector; + readonly combinator: CssValue | null | undefined; + } + + class CompoundSelector extends SassNode { + readonly components: SimpleSelector[]; + } + + class IDSelector extends SimpleSelector { + readonly name: Interpolation; + } + + class SelectorList extends SassNode { + readonly components: ComplexSelector[]; + } + + class ParentSelector extends SimpleSelector { + readonly suffix: Interpolation | undefined; + } + + class PlaceholderSelector extends SimpleSelector { + readonly name: Interpolation; + } + + class PseudoSelector extends SimpleSelector { + readonly name: Interpolation; + readonly isSyntacticClass: boolean; + readonly argument: Interpolation | null | undefined; + readonly selector: SelectorList | null | undefined; + } + + class QualifiedName extends SassNode { + readonly name: Interpolation; + readonly namespace: Interpolation | null | undefined; + } + + class TypeSelector extends SimpleSelector { + readonly name: QualifiedName; + } + + class UniversalSelector extends SimpleSelector { + readonly namespace: Interpolation | null | undefined; + } } const sassInternal = ( @@ -468,6 +556,22 @@ export type SupportsExpression = SassInternal.SupportsExpression; export type UnaryOperationExpression = SassInternal.UnaryOperationExpression; export type VariableExpression = SassInternal.VariableExpression; +// Selectors +export type SimpleSelector = SassInternal.SimpleSelector; +export type AttributeSelector = SassInternal.AttributeSelector; +export type ClassSelector = SassInternal.ClassSelector; +export type ComplexSelector = SassInternal.ComplexSelector; +export type ComplexSelectorComponent = SassInternal.ComplexSelectorComponent; +export type CompoundSelector = SassInternal.CompoundSelector; +export type IDSelector = SassInternal.IDSelector; +export type SelectorList = SassInternal.SelectorList; +export type ParentSelector = SassInternal.ParentSelector; +export type PlaceholderSelector = SassInternal.PlaceholderSelector; +export type PseudoSelector = SassInternal.PseudoSelector; +export type QualifiedName = SassInternal.QualifiedName; +export type TypeSelector = SassInternal.TypeSelector; +export type UniversalSelector = SassInternal.UniversalSelector; + export interface StatementVisitorObject { visitAtRootRule(node: AtRootRule): T; visitAtRule(node: AtRule): T; @@ -515,10 +619,25 @@ export interface ExpressionVisitorObject { visitVariableExpression(node: VariableExpression): T; } +export interface SimpleSelectorVisitorObject { + visitAttributeSelector(node: AttributeSelector): T; + visitClassSelector(node: ClassSelector): T; + visitIDSelector(node: IDSelector): T; + visitParentSelector(node: ParentSelector): T; + visitPlaceholderSelector(node: PlaceholderSelector): T; + visitPseudoSelector(node: PseudoSelector): T; + visitTypeSelector(node: TypeSelector): T; + visitUniversalSelector(node: UniversalSelector): T; +} + export const createExpressionVisitor = sassInternal.createExpressionVisitor; +export const createSimpleSelectorVisitor = + sassInternal.createSimpleSelectorVisitor; +export const createSourceFile = sassInternal.createSourceFile; export const createStatementVisitor = sassInternal.createStatementVisitor; export const mapToRecord = sassInternal.mapToRecord; export const parse = sassInternal.parse; export const parseIdentifier = sassInternal.parseIdentifier; +export const parseSelectorList = sassInternal.parseSelectorList; export const setToJS = sassInternal.setToJS; export const toCssIdentifier = sassInternal.toCssIdentifier; diff --git a/pkg/sass-parser/lib/src/selector/__snapshots__/attribute.test.ts.snap b/pkg/sass-parser/lib/src/selector/__snapshots__/attribute.test.ts.snap new file mode 100644 index 000000000..77f921c69 --- /dev/null +++ b/pkg/sass-parser/lib/src/selector/__snapshots__/attribute.test.ts.snap @@ -0,0 +1,54 @@ +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing + +exports[`an attribute selector toJSON with a modifier 1`] = ` +{ + "attribute": , + "inputs": [ + { + "css": "[foo=bar s] {}", + "hasBOM": false, + "id": "", + }, + ], + "modifier": , + "operator": "=", + "raws": {}, + "sassType": "attribute", + "source": <1:1-1:12 in 0>, + "value": , +} +`; + +exports[`an attribute selector toJSON with a value 1`] = ` +{ + "attribute": , + "inputs": [ + { + "css": "[foo=bar] {}", + "hasBOM": false, + "id": "", + }, + ], + "operator": "=", + "raws": {}, + "sassType": "attribute", + "source": <1:1-1:10 in 0>, + "value": , +} +`; + +exports[`an attribute selector toJSON with no value 1`] = ` +{ + "attribute": , + "inputs": [ + { + "css": "[foo] {}", + "hasBOM": false, + "id": "", + }, + ], + "raws": {}, + "sassType": "attribute", + "source": <1:1-1:6 in 0>, +} +`; diff --git a/pkg/sass-parser/lib/src/selector/__snapshots__/class.test.ts.snap b/pkg/sass-parser/lib/src/selector/__snapshots__/class.test.ts.snap new file mode 100644 index 000000000..be14325c0 --- /dev/null +++ b/pkg/sass-parser/lib/src/selector/__snapshots__/class.test.ts.snap @@ -0,0 +1,17 @@ +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing + +exports[`a class selector toJSON 1`] = ` +{ + "class": , + "inputs": [ + { + "css": ".foo {}", + "hasBOM": false, + "id": "", + }, + ], + "raws": {}, + "sassType": "class", + "source": <1:1-1:5 in 0>, +} +`; diff --git a/pkg/sass-parser/lib/src/selector/__snapshots__/complex-component.test.ts.snap b/pkg/sass-parser/lib/src/selector/__snapshots__/complex-component.test.ts.snap new file mode 100644 index 000000000..08405f978 --- /dev/null +++ b/pkg/sass-parser/lib/src/selector/__snapshots__/complex-component.test.ts.snap @@ -0,0 +1,34 @@ +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing + +exports[`a complex selector component toJSON with a combinator 1`] = ` +{ + "combinator": "+", + "compound": <.foo>, + "inputs": [ + { + "css": ".foo + {}", + "hasBOM": false, + "id": "", + }, + ], + "raws": {}, + "sassType": "complex-selector-component", + "source": <1:1-1:8 in 0>, +} +`; + +exports[`a complex selector component toJSON with no combinator 1`] = ` +{ + "compound": <.foo>, + "inputs": [ + { + "css": ".foo {}", + "hasBOM": false, + "id": "", + }, + ], + "raws": {}, + "sassType": "complex-selector-component", + "source": <1:1-1:6 in 0>, +} +`; diff --git a/pkg/sass-parser/lib/src/selector/__snapshots__/complex.test.ts.snap b/pkg/sass-parser/lib/src/selector/__snapshots__/complex.test.ts.snap new file mode 100644 index 000000000..a9a1982a9 --- /dev/null +++ b/pkg/sass-parser/lib/src/selector/__snapshots__/complex.test.ts.snap @@ -0,0 +1,39 @@ +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing + +exports[`a complex selector toJSON with a leading combinator 1`] = ` +{ + "inputs": [ + { + "css": "+ .foo {}", + "hasBOM": false, + "id": "", + }, + ], + "leadingCombinator": "+", + "nodes": [ + <.foo>, + ], + "raws": {}, + "sassType": "complex-selector", + "source": <1:1-1:8 in 0>, +} +`; + +exports[`a complex selector toJSON with no leading combinator 1`] = ` +{ + "inputs": [ + { + "css": ".foo .bar {}", + "hasBOM": false, + "id": "", + }, + ], + "nodes": [ + <.foo>, + <.bar>, + ], + "raws": {}, + "sassType": "complex-selector", + "source": <1:1-1:11 in 0>, +} +`; diff --git a/pkg/sass-parser/lib/src/selector/__snapshots__/compound.test.ts.snap b/pkg/sass-parser/lib/src/selector/__snapshots__/compound.test.ts.snap new file mode 100644 index 000000000..7244ea56d --- /dev/null +++ b/pkg/sass-parser/lib/src/selector/__snapshots__/compound.test.ts.snap @@ -0,0 +1,20 @@ +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing + +exports[`a compound selector toJSON 1`] = ` +{ + "inputs": [ + { + "css": ".foo.bar {}", + "hasBOM": false, + "id": "", + }, + ], + "nodes": [ + <.foo>, + <.bar>, + ], + "raws": {}, + "sassType": "compound-selector", + "source": <1:1-1:9 in 0>, +} +`; diff --git a/pkg/sass-parser/lib/src/selector/__snapshots__/id.test.ts.snap b/pkg/sass-parser/lib/src/selector/__snapshots__/id.test.ts.snap new file mode 100644 index 000000000..6dd6c0ba2 --- /dev/null +++ b/pkg/sass-parser/lib/src/selector/__snapshots__/id.test.ts.snap @@ -0,0 +1,17 @@ +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing + +exports[`an ID selector toJSON 1`] = ` +{ + "id": , + "inputs": [ + { + "css": "#foo {}", + "hasBOM": false, + "id": "", + }, + ], + "raws": {}, + "sassType": "id", + "source": <1:1-1:5 in 0>, +} +`; diff --git a/pkg/sass-parser/lib/src/selector/__snapshots__/list.test.ts.snap b/pkg/sass-parser/lib/src/selector/__snapshots__/list.test.ts.snap new file mode 100644 index 000000000..16cb99887 --- /dev/null +++ b/pkg/sass-parser/lib/src/selector/__snapshots__/list.test.ts.snap @@ -0,0 +1,20 @@ +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing + +exports[`a complex selector toJSON 1`] = ` +{ + "inputs": [ + { + "css": ".foo, .bar {}", + "hasBOM": false, + "id": "", + }, + ], + "nodes": [ + <.foo>, + <.bar>, + ], + "raws": {}, + "sassType": "selector-list", + "source": <1:1-1:12 in 0>, +} +`; diff --git a/pkg/sass-parser/lib/src/selector/__snapshots__/parent.test.ts.snap b/pkg/sass-parser/lib/src/selector/__snapshots__/parent.test.ts.snap new file mode 100644 index 000000000..6a693de3f --- /dev/null +++ b/pkg/sass-parser/lib/src/selector/__snapshots__/parent.test.ts.snap @@ -0,0 +1,32 @@ +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing + +exports[`a parent selector toJSON with a suffix 1`] = ` +{ + "inputs": [ + { + "css": "&foo {}", + "hasBOM": false, + "id": "", + }, + ], + "raws": {}, + "sassType": "parent", + "source": <1:1-1:5 in 0>, + "suffix": , +} +`; + +exports[`a parent selector toJSON with no suffix 1`] = ` +{ + "inputs": [ + { + "css": "& {}", + "hasBOM": false, + "id": "", + }, + ], + "raws": {}, + "sassType": "parent", + "source": <1:1-1:2 in 0>, +} +`; diff --git a/pkg/sass-parser/lib/src/selector/__snapshots__/placeholder.test.ts.snap b/pkg/sass-parser/lib/src/selector/__snapshots__/placeholder.test.ts.snap new file mode 100644 index 000000000..1824c98d2 --- /dev/null +++ b/pkg/sass-parser/lib/src/selector/__snapshots__/placeholder.test.ts.snap @@ -0,0 +1,17 @@ +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing + +exports[`a placeholder selector toJSON 1`] = ` +{ + "inputs": [ + { + "css": "%foo {}", + "hasBOM": false, + "id": "", + }, + ], + "placeholder": , + "raws": {}, + "sassType": "placeholder", + "source": <1:1-1:5 in 0>, +} +`; diff --git a/pkg/sass-parser/lib/src/selector/__snapshots__/pseudo.test.ts.snap b/pkg/sass-parser/lib/src/selector/__snapshots__/pseudo.test.ts.snap new file mode 100644 index 000000000..f8a0148cd --- /dev/null +++ b/pkg/sass-parser/lib/src/selector/__snapshots__/pseudo.test.ts.snap @@ -0,0 +1,107 @@ +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing + +exports[`a pseudo selector toJSON a fake pseudo-element 1`] = ` +{ + "inputs": [ + { + "css": ":after {}", + "hasBOM": false, + "id": "", + }, + ], + "isElement": false, + "pseudo": , + "raws": {}, + "sassType": "pseudo", + "source": <1:1-1:7 in 0>, +} +`; + +exports[`a pseudo selector toJSON a pseudo-element 1`] = ` +{ + "inputs": [ + { + "css": "::foo {}", + "hasBOM": false, + "id": "", + }, + ], + "isElement": true, + "pseudo": , + "raws": {}, + "sassType": "pseudo", + "source": <1:1-1:6 in 0>, +} +`; + +exports[`a pseudo selector toJSON with a selector and no argument 1`] = ` +{ + "inputs": [ + { + "css": ":is(.foo) {}", + "hasBOM": false, + "id": "", + }, + ], + "isElement": false, + "pseudo": , + "raws": {}, + "sassType": "pseudo", + "selector": <.foo>, + "source": <1:1-1:10 in 0>, +} +`; + +exports[`a pseudo selector toJSON with an argument and a selector 1`] = ` +{ + "argument": <2n of>, + "inputs": [ + { + "css": ":nth-child(2n of .foo) {}", + "hasBOM": false, + "id": "", + }, + ], + "isElement": false, + "pseudo": , + "raws": {}, + "sassType": "pseudo", + "selector": <.foo>, + "source": <1:1-1:23 in 0>, +} +`; + +exports[`a pseudo selector toJSON with an argument and no selector 1`] = ` +{ + "argument": <&^*#>, + "inputs": [ + { + "css": ":foo(&^*#) {}", + "hasBOM": false, + "id": "", + }, + ], + "isElement": false, + "pseudo": , + "raws": {}, + "sassType": "pseudo", + "source": <1:1-1:11 in 0>, +} +`; + +exports[`a pseudo selector toJSON with no argument or selector 1`] = ` +{ + "inputs": [ + { + "css": ":foo {}", + "hasBOM": false, + "id": "", + }, + ], + "isElement": false, + "pseudo": , + "raws": {}, + "sassType": "pseudo", + "source": <1:1-1:5 in 0>, +} +`; diff --git a/pkg/sass-parser/lib/src/selector/__snapshots__/qualified-name.test.ts.snap b/pkg/sass-parser/lib/src/selector/__snapshots__/qualified-name.test.ts.snap new file mode 100644 index 000000000..478805363 --- /dev/null +++ b/pkg/sass-parser/lib/src/selector/__snapshots__/qualified-name.test.ts.snap @@ -0,0 +1,34 @@ +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing + +exports[`a qualified name toJSON with a namespace 1`] = ` +{ + "inputs": [ + { + "css": "foo|bar {}", + "hasBOM": false, + "id": "", + }, + ], + "name": , + "namespace": , + "raws": {}, + "sassType": "qualified-name", + "source": <1:1-1:8 in 0>, +} +`; + +exports[`a qualified name toJSON without a namespace 1`] = ` +{ + "inputs": [ + { + "css": "foo {}", + "hasBOM": false, + "id": "", + }, + ], + "name": , + "raws": {}, + "sassType": "qualified-name", + "source": <1:1-1:4 in 0>, +} +`; diff --git a/pkg/sass-parser/lib/src/selector/__snapshots__/type.test.ts.snap b/pkg/sass-parser/lib/src/selector/__snapshots__/type.test.ts.snap new file mode 100644 index 000000000..403bc3edb --- /dev/null +++ b/pkg/sass-parser/lib/src/selector/__snapshots__/type.test.ts.snap @@ -0,0 +1,17 @@ +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing + +exports[`a type selector toJSON 1`] = ` +{ + "inputs": [ + { + "css": "foo {}", + "hasBOM": false, + "id": "", + }, + ], + "raws": {}, + "sassType": "type", + "source": <1:1-1:4 in 0>, + "type": , +} +`; diff --git a/pkg/sass-parser/lib/src/selector/__snapshots__/universal.test.ts.snap b/pkg/sass-parser/lib/src/selector/__snapshots__/universal.test.ts.snap new file mode 100644 index 000000000..72b08a9d5 --- /dev/null +++ b/pkg/sass-parser/lib/src/selector/__snapshots__/universal.test.ts.snap @@ -0,0 +1,32 @@ +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing + +exports[`a universal selector toJSON with a namespace 1`] = ` +{ + "inputs": [ + { + "css": "foo|* {}", + "hasBOM": false, + "id": "", + }, + ], + "namespace": , + "raws": {}, + "sassType": "universal", + "source": <1:1-1:6 in 0>, +} +`; + +exports[`a universal selector toJSON with no namespace 1`] = ` +{ + "inputs": [ + { + "css": "* {}", + "hasBOM": false, + "id": "", + }, + ], + "raws": {}, + "sassType": "universal", + "source": <1:1-1:2 in 0>, +} +`; diff --git a/pkg/sass-parser/lib/src/selector/attribute.test.ts b/pkg/sass-parser/lib/src/selector/attribute.test.ts new file mode 100644 index 000000000..b6e3c07e5 --- /dev/null +++ b/pkg/sass-parser/lib/src/selector/attribute.test.ts @@ -0,0 +1,781 @@ +// 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 {AttributeSelector, Interpolation, QualifiedName} from '../..'; +import { + fromSimpleSelectorProps, + parseSimpleSelector, +} from '../../../test/utils'; + +describe('an attribute selector', () => { + let node: AttributeSelector; + + describe('with no value', () => { + describe('without a namespace', () => { + function describeNode( + description: string, + create: () => AttributeSelector, + ): void { + describe(description, () => { + beforeEach(() => void (node = create())); + + it('has sassType attribute', () => + expect(node.sassType).toBe('attribute')); + + it('has an attribute', () => + expect(node).toHaveNode('attribute', 'foo', 'qualified-name')); + + it('has no operator', () => expect(node.operator).toBeUndefined()); + + it('has no value', () => expect(node.value).toBeUndefined()); + + it('has no modifier', () => expect(node.modifier).toBeUndefined()); + }); + } + + describeNode('parsed', () => parseSimpleSelector('[foo]')); + + describeNode( + 'constructed manually', + () => new AttributeSelector({attribute: 'foo'}), + ); + + describeNode('from props', () => + fromSimpleSelectorProps({attribute: 'foo'}), + ); + }); + + describe('with a namespace', () => { + function describeNode( + description: string, + create: () => AttributeSelector, + ): void { + describe(description, () => { + beforeEach(() => void (node = create())); + + it('has sassType attribute', () => + expect(node.sassType).toBe('attribute')); + + it('has an attribute', () => + expect(node).toHaveNode('attribute', 'foo|bar', 'qualified-name')); + + it('has no operator', () => expect(node.operator).toBeUndefined()); + + it('has no value', () => expect(node.value).toBeUndefined()); + + it('has no modifier', () => expect(node.modifier).toBeUndefined()); + }); + } + + describeNode('parsed', () => parseSimpleSelector('[foo|bar]')); + + describeNode( + 'constructed manually', + () => + new AttributeSelector({attribute: {namespace: 'foo', name: 'bar'}}), + ); + + describeNode('from props', () => + fromSimpleSelectorProps({attribute: {namespace: 'foo', name: 'bar'}}), + ); + }); + + describe('with a universal namespace', () => { + function describeNode( + description: string, + create: () => AttributeSelector, + ): void { + describe(description, () => { + beforeEach(() => void (node = create())); + + it('has sassType attribute', () => + expect(node.sassType).toBe('attribute')); + + it('has an attribute', () => + expect(node).toHaveNode('attribute', '*|foo', 'qualified-name')); + + it('has no operator', () => expect(node.operator).toBeUndefined()); + + it('has no value', () => expect(node.value).toBeUndefined()); + + it('has no modifier', () => expect(node.modifier).toBeUndefined()); + }); + } + + describeNode('parsed', () => parseSimpleSelector('[*|foo]')); + + describeNode( + 'constructed manually', + () => new AttributeSelector({attribute: {namespace: '*', name: 'foo'}}), + ); + + describeNode('from props', () => + fromSimpleSelectorProps({attribute: {namespace: '*', name: 'foo'}}), + ); + }); + + describe('with an empty namespace', () => { + function describeNode( + description: string, + create: () => AttributeSelector, + ): void { + describe(description, () => { + beforeEach(() => void (node = create())); + + it('has sassType attribute', () => + expect(node.sassType).toBe('attribute')); + + it('has an attribute', () => + expect(node).toHaveNode('attribute', '|foo', 'qualified-name')); + + it('has no operator', () => expect(node.operator).toBeUndefined()); + + it('has no value', () => expect(node.value).toBeUndefined()); + + it('has no modifier', () => expect(node.modifier).toBeUndefined()); + }); + } + + describeNode('parsed', () => parseSimpleSelector('[|foo]')); + + describeNode( + 'constructed manually', + () => new AttributeSelector({attribute: {namespace: '', name: 'foo'}}), + ); + + describeNode('from props', () => + fromSimpleSelectorProps({attribute: {namespace: '', name: 'foo'}}), + ); + }); + + describe('with interpolation', () => { + function describeNode( + description: string, + create: () => AttributeSelector, + ): void { + describe(description, () => { + beforeEach(() => void (node = create())); + + it('has sassType attribute', () => + expect(node.sassType).toBe('attribute')); + + it('has an attribute', () => + expect(node).toHaveNode( + 'attribute', + '#{foo}|#{bar}', + 'qualified-name', + )); + + it('has no operator', () => expect(node.operator).toBeUndefined()); + + it('has no value', () => expect(node.value).toBeUndefined()); + + it('has no modifier', () => expect(node.modifier).toBeUndefined()); + }); + } + + describeNode('parsed', () => parseSimpleSelector('[#{foo}|#{bar}]')); + + describeNode( + 'constructed manually', + () => + new AttributeSelector({ + attribute: {namespace: [{text: 'foo'}], name: [{text: 'bar'}]}, + }), + ); + + describeNode('from props', () => + fromSimpleSelectorProps({ + attribute: {namespace: [{text: 'foo'}], name: [{text: 'bar'}]}, + }), + ); + }); + }); + + describe('with an identifier value', () => { + function describeNode( + description: string, + create: () => AttributeSelector, + ): void { + describe(description, () => { + beforeEach(() => void (node = create())); + + it('has sassType attribute', () => + expect(node.sassType).toBe('attribute')); + + it('has an attribute', () => + expect(node).toHaveNode('attribute', 'foo')); + + it('has an operator', () => expect(node.operator).toBe('=')); + + it('has a value', () => + expect(node).toHaveInterpolation('value', 'bar')); + + it('has no modifier', () => expect(node.modifier).toBeUndefined()); + }); + } + + describeNode('parsed', () => parseSimpleSelector('[foo=bar]')); + + describeNode( + 'constructed manually', + () => + new AttributeSelector({attribute: 'foo', operator: '=', value: 'bar'}), + ); + + describeNode('from props', () => + fromSimpleSelectorProps({attribute: 'foo', operator: '=', value: 'bar'}), + ); + }); + + describe('with a string value', () => { + function describeNode( + description: string, + create: () => AttributeSelector, + ): void { + describe(description, () => { + beforeEach(() => void (node = create())); + + it('has sassType attribute', () => + expect(node.sassType).toBe('attribute')); + + it('has an attribute', () => + expect(node).toHaveNode('attribute', 'foo')); + + it('has an operator', () => expect(node.operator).toBe('=')); + + it('has a value', () => + expect(node).toHaveInterpolation('value', '"\\0a"')); + + it('has no modifier', () => expect(node.modifier).toBeUndefined()); + }); + } + + describeNode('parsed', () => parseSimpleSelector('[foo="\\0a"]')); + + describeNode( + 'constructed manually', + () => + new AttributeSelector({ + attribute: 'foo', + operator: '=', + value: '"\\0a"', + }), + ); + + describeNode('from props', () => + fromSimpleSelectorProps({ + attribute: 'foo', + operator: '=', + value: '"\\0a"', + }), + ); + }); + + describe('with a modifier', () => { + function describeNode( + description: string, + create: () => AttributeSelector, + ): void { + describe(description, () => { + beforeEach(() => void (node = create())); + + it('has sassType attribute', () => + expect(node.sassType).toBe('attribute')); + + it('has an attribute', () => + expect(node).toHaveNode('attribute', 'foo')); + + it('has an operator', () => expect(node.operator).toBe('=')); + + it('has no value', () => + expect(node).toHaveInterpolation('value', 'bar')); + + it('has no modifier', () => + expect(node).toHaveInterpolation('modifier', 'baz')); + }); + } + + describeNode('parsed', () => parseSimpleSelector('[foo=bar baz]')); + + describeNode( + 'constructed manually', + () => + new AttributeSelector({ + attribute: 'foo', + operator: '=', + value: 'bar', + modifier: 'baz', + }), + ); + + describeNode('from props', () => + fromSimpleSelectorProps({ + attribute: 'foo', + operator: '=', + value: 'bar', + modifier: 'baz', + }), + ); + }); + + describe('with interpolation', () => { + function describeNode( + description: string, + create: () => AttributeSelector, + ): void { + describe(description, () => { + beforeEach(() => void (node = create())); + + it('has sassType attribute', () => + expect(node.sassType).toBe('attribute')); + + it('has an attribute', () => + expect(node).toHaveNode('attribute', 'foo')); + + it('has an operator', () => expect(node.operator).toBe('=')); + + it('has a value', () => + expect(node.value).toHaveStringExpression(0, 'bar')); + + it('has a modifier', () => + expect(node.modifier).toHaveStringExpression(0, 'baz')); + }); + } + + describeNode('parsed', () => parseSimpleSelector('[foo=#{bar} #{baz}]')); + + describeNode( + 'constructed manually', + () => + new AttributeSelector({ + attribute: 'foo', + operator: '=', + value: [{text: 'bar'}], + modifier: [{text: 'baz'}], + }), + ); + + describeNode('from props', () => + fromSimpleSelectorProps({ + attribute: 'foo', + operator: '=', + value: [{text: 'bar'}], + modifier: [{text: 'baz'}], + }), + ); + }); + + describe('with quoted interpolation', () => { + function describeNode( + description: string, + create: () => AttributeSelector, + ): void { + describe(description, () => { + beforeEach(() => void (node = create())); + + it('has sassType attribute', () => + expect(node.sassType).toBe('attribute')); + + it('has an attribute', () => + expect(node).toHaveNode('attribute', 'foo')); + + it('has an operator', () => expect(node.operator).toBe('=')); + + it('has a value', () => { + expect(node.value?.nodes?.[0]).toEqual('"b'); + expect(node.value).toHaveStringExpression(1, 'a'); + expect(node.value?.nodes?.[2]).toEqual('r"'); + }); + + it('has no modifier', () => expect(node.modifier).toBeUndefined()); + }); + } + + describeNode('parsed', () => parseSimpleSelector('[foo="b#{a}r"]')); + + describeNode( + 'constructed manually', + () => + new AttributeSelector({ + attribute: 'foo', + operator: '=', + value: ['"b', {text: 'a'}, 'r"'], + }), + ); + + describeNode('from props', () => + fromSimpleSelectorProps({ + attribute: 'foo', + operator: '=', + value: ['"b', {text: 'a'}, 'r"'], + }), + ); + }); + + describe('assigned new', () => { + beforeEach(() => void (node = parseSimpleSelector('[foo=bar baz]'))); + + describe('operator', () => { + it('defined', () => { + node.operator = '~='; + expect(node.operator).toEqual('~='); + }); + + it('undefined', () => { + node.operator = undefined; + expect(node.operator).toBeUndefined(); + }); + }); + + describe('attribute', () => { + it("removes the old attribute's parent", () => { + const oldAttribute = node.attribute; + node.attribute = 'qux'; + expect(oldAttribute.parent).toBeUndefined(); + }); + + it('assigns attribute explicitly', () => { + const attribute = new QualifiedName('qux'); + node.attribute = attribute; + expect(node.attribute).toBe(attribute); + expect(node.attribute.parent).toBe(node); + }); + + it('assigns attribute as Interpolation', () => { + const attribute = new Interpolation('qux'); + node.attribute = attribute; + expect(node.attribute.sassType).toEqual('qualified-name'); + expect(node.attribute.toString()).toEqual('qux'); + expect(node.attribute.parent).toBe(node); + }); + + it('assigns attribute as InterpolationProps', () => { + node.attribute = 'qux'; + expect(node).toHaveNode('attribute', 'qux'); + }); + }); + + describe('value', () => { + it("removes the old value's parent", () => { + const oldValue = node.value; + node.value = 'qux'; + expect(oldValue!.parent).toBeUndefined(); + }); + + it('assigns value explicitly', () => { + const value = new Interpolation('qux'); + node.value = value; + expect(node.value).toBe(value); + expect(node).toHaveInterpolation('value', 'qux'); + }); + + it('assigns value as InterpolationProps', () => { + node.value = 'qux'; + expect(node).toHaveInterpolation('value', 'qux'); + }); + + it('assigns undefined value', () => { + const oldValue = node.value; + node.value = undefined; + expect(oldValue!.parent).toBeUndefined(); + expect(node.value).toBeUndefined(); + }); + }); + + describe('modifier', () => { + it("removes the old modifier's parent", () => { + const oldModifier = node.modifier; + node.modifier = 'qux'; + expect(oldModifier!.parent).toBeUndefined(); + }); + + it('assigns modifier explicitly', () => { + const modifier = new Interpolation('qux'); + node.modifier = modifier; + expect(node.modifier).toBe(modifier); + expect(node).toHaveInterpolation('modifier', 'qux'); + }); + + it('assigns modifier as InterpolationProps', () => { + node.modifier = 'qux'; + expect(node).toHaveInterpolation('modifier', 'qux'); + }); + + it('assigns undefined modifier', () => { + const oldModifier = node.modifier; + node.modifier = undefined; + expect(oldModifier!.parent).toBeUndefined(); + expect(node.modifier).toBeUndefined(); + }); + }); + }); + + describe('stringifies', () => { + describe('with no value', () => { + beforeEach(() => { + node = new AttributeSelector({attribute: 'foo'}); + }); + + it('with no raws', () => expect(node.toString()).toBe('[foo]')); + + it('with afterOpen', () => { + node.raws.afterOpen = ' '; + expect(node.toString()).toBe('[ foo]'); + }); + + it('with beforeClose', () => { + node.raws.beforeClose = ' '; + expect(node.toString()).toBe('[foo ]'); + }); + + it('ignores beforeOperator', () => { + node.raws.beforeOperator = ' '; + expect(node.toString()).toBe('[foo]'); + }); + + it('ignores afterOperator', () => { + node.raws.afterOperator = ' '; + expect(node.toString()).toBe('[foo]'); + }); + + it('ignores afterValue', () => { + node.raws.afterValue = ' '; + expect(node.toString()).toBe('[foo]'); + }); + }); + + describe('with a value', () => { + beforeEach(() => { + node = new AttributeSelector({ + attribute: 'foo', + operator: '=', + value: 'bar', + }); + }); + + it('with no raws', () => { + expect(node.toString()).toBe('[foo=bar]'); + }); + + it('with afterOpen', () => { + node.raws.afterOpen = ' '; + expect(node.toString()).toBe('[ foo=bar]'); + }); + + it('with beforeClose', () => { + node.raws.beforeClose = ' '; + expect(node.toString()).toBe('[foo=bar ]'); + }); + + it('with beforeOperator', () => { + node.raws.beforeOperator = ' '; + expect(node.toString()).toBe('[foo =bar]'); + }); + + it('with afterOperator', () => { + node.raws.afterOperator = ' '; + expect(node.toString()).toBe('[foo= bar]'); + }); + + it('with afterValue', () => { + node.raws.afterValue = ' '; + expect(node.toString()).toBe('[foo=bar ]'); + }); + + it('with afterValue and beforeClose', () => { + node.raws.afterValue = ' '; + node.raws.beforeClose = '/**/'; + expect(node.toString()).toBe('[foo=bar /**/]'); + }); + }); + + describe('with a modifier', () => { + beforeEach(() => { + node = new AttributeSelector({ + attribute: 'foo', + operator: '=', + value: 'bar', + modifier: 's', + }); + }); + + it('with no raws', () => expect(node.toString()).toBe('[foo=bar s]')); + + it('with afterOpen', () => { + node.raws.afterOpen = ' '; + expect(node.toString()).toBe('[ foo=bar s]'); + }); + + it('with beforeClose', () => { + node.raws.beforeClose = ' '; + expect(node.toString()).toBe('[foo=bar s ]'); + }); + + it('with beforeOperator', () => { + node.raws.beforeOperator = ' '; + expect(node.toString()).toBe('[foo =bar s]'); + }); + + it('with afterOperator', () => { + node.raws.afterOperator = ' '; + expect(node.toString()).toBe('[foo= bar s]'); + }); + + it('with afterValue', () => { + node.raws.afterValue = ' '; + expect(node.toString()).toBe('[foo=bar s]'); + }); + + it('with afterValue and beforeClose', () => { + node.raws.afterValue = ' '; + node.raws.beforeClose = '/**/'; + expect(node.toString()).toBe('[foo=bar s/**/]'); + }); + }); + + describe('with an operator but no value', () => { + it('without a modifier', () => + expect( + new AttributeSelector({attribute: 'foo', operator: '='}).toString(), + ).toBe('[foo]')); + + it('with a modifier', () => + expect( + new AttributeSelector({ + attribute: 'foo', + operator: '=', + modifier: 's', + }).toString(), + ).toBe('[foo]')); + }); + + describe('with a value but no operator', () => { + it('without a modifier', () => + expect( + new AttributeSelector({attribute: 'foo', value: 'bar'}).toString(), + ).toBe('[foo]')); + + it('with a modifier', () => + expect( + new AttributeSelector({ + attribute: 'foo', + value: 'bar', + modifier: 's', + }).toString(), + ).toBe('[foo]')); + }); + }); + + describe('clone', () => { + let original: AttributeSelector; + + beforeEach(() => { + original = parseSimpleSelector('[foo=bar baz]'); + }); + + describe('with no overrides', () => { + let clone: AttributeSelector; + + beforeEach(() => void (clone = original.clone())); + + describe('has the same properties:', () => { + it('attribute', () => expect(clone).toHaveNode('attribute', 'foo')); + + it('operator', () => expect(clone.operator).toEqual('=')); + + it('value', () => expect(clone).toHaveInterpolation('value', 'bar')); + + it('modifier', () => + expect(clone).toHaveInterpolation('modifier', 'baz')); + + it('raws', () => expect(clone.raws).toEqual({})); + + it('source', () => expect(clone.source).toBe(original.source)); + }); + + describe('creates a new', () => { + it('self', () => expect(clone).not.toBe(original)); + + for (const attr of [ + 'attribute', + 'value', + 'modifier', + 'raws', + ] as const) { + it(attr, () => expect(clone[attr]).not.toBe(original[attr])); + } + }); + }); + + describe('overrides', () => { + describe('attribute', () => { + it('defined', () => + expect(original.clone({attribute: 'qux'})).toHaveNode( + 'attribute', + 'qux', + )); + + it('undefined', () => + expect(original.clone({attribute: undefined})).toHaveNode( + 'attribute', + 'foo', + )); + }); + + describe('operator', () => { + it('defined', () => + expect(original.clone({operator: '~='}).operator).toEqual('~=')); + + it('undefined', () => + expect( + original.clone({operator: undefined}).operator, + ).toBeUndefined()); + }); + + describe('value', () => { + it('defined', () => + expect(original.clone({value: 'qux'})).toHaveInterpolation( + 'value', + 'qux', + )); + + it('undefined', () => + expect(original.clone({value: undefined}).value).toBeUndefined()); + }); + + describe('modifier', () => { + it('defined', () => + expect(original.clone({value: 'qux'})).toHaveInterpolation( + 'value', + 'qux', + )); + + it('undefined', () => + expect( + original.clone({modifier: undefined}).modifier, + ).toBeUndefined()); + }); + + describe('raws', () => { + it('defined', () => + expect(original.clone({raws: {}}).raws).toEqual({})); + + it('undefined', () => + expect(original.clone({raws: undefined}).raws).toEqual({})); + }); + }); + }); + + describe('toJSON', () => { + it('with no value', () => + expect(parseSimpleSelector('[foo]')).toMatchSnapshot()); + + it('with a value', () => + expect(parseSimpleSelector('[foo=bar]')).toMatchSnapshot()); + + it('with a modifier', () => + expect(parseSimpleSelector('[foo=bar s]')).toMatchSnapshot()); + }); +}); diff --git a/pkg/sass-parser/lib/src/selector/attribute.ts b/pkg/sass-parser/lib/src/selector/attribute.ts new file mode 100644 index 000000000..e21d846de --- /dev/null +++ b/pkg/sass-parser/lib/src/selector/attribute.ts @@ -0,0 +1,224 @@ +// 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 type {AnyNode, NodeProps} from '../node'; +import type {AnyStatement} from '../statement'; +import {Interpolation, InterpolationProps} from '../interpolation'; +import {LazySource} from '../lazy-source'; +import * as sassInternal from '../sass-internal'; +import * as utils from '../utils'; +import {QualifiedName, QualifiedNameProps} from './qualified-name'; +import {SimpleSelector} from './index'; + +/** + * An operator that defines the meaning of a {@link AttributeSelector}. + * + * @category Selector + */ +export type AttributeSelectorOperator = '=' | '~=' | '|=' | '^=' | '$=' | '*='; + +/** + * The initializer properties for {@link AttributeSelector}. + * + * @category Selector + */ +export interface AttributeSelectorProps extends NodeProps { + attribute: QualifiedName | QualifiedNameProps; + operator?: AttributeSelectorOperator; + value?: Interpolation | InterpolationProps; + modifier?: Interpolation | InterpolationProps; + raws?: AttributeSelectorRaws; +} + +/** + * Raws indicating how to precisely serialize an {@AttributeSelector}. + * + * @category Selector + */ +export interface AttributeSelectorRaws { + /** The whitespace between the opening bracket and the attribute name. */ + afterOpen?: string; + + /** + * The whitespace between the final component of the selector and the closing + * bracket. + */ + beforeClose?: string; + + /** The whitespace before the operator. */ + beforeOperator?: string; + + /** The whitespace after the operator. */ + afterOperator?: string; + + /** + * The whitespace after the value. + * + * This is only set automatically when the selector has a modifier. + */ + afterValue?: string; +} + +/** + * An attribute selector. + * + * This selects for elements with the given attribute, and optionally with a + * value matching certain conditions as well. + * + * @category Selector + */ +export class AttributeSelector extends SimpleSelector { + readonly sassType = 'attribute' as const; + declare raws: AttributeSelectorRaws; + + /** The name of the attribute being selected for. */ + get attribute(): QualifiedName { + return this._attribute; + } + set attribute(attribute: QualifiedName | QualifiedNameProps) { + if (this._attribute) this._attribute.parent = undefined; + const built = + typeof attribute === 'object' && + 'sassType' in attribute && + attribute.sassType === 'qualified-name' + ? attribute + : new QualifiedName(attribute); + built.parent = this; + this._attribute = built; + } + private declare _attribute: QualifiedName; + + /** + * The operator that defines the semantics of {@link value}. + * + * If this is `undefined`, this matches any element with the given property, + * regardless of this value. It's ignored if {@link value} is `undefined`. + */ + get operator(): AttributeSelectorOperator | undefined { + return this._operator; + } + set operator(operator: AttributeSelectorOperator | undefined) { + this._operator = operator; + } + private declare _operator: AttributeSelectorOperator | undefined; + + /** + * An assertion about the value of {@link attribute}. + * + * The precise semantics of this string are defined by {@link operator}. + * + * This may be a quoted or unquoted string. If it's quoted, the quotes and any + * escape sequences are included as part of the value's text. + * + * If this is `undefined`, this matches any element with the given property, + * regardless of this value. It's ignored if {@link operator} is `undefined`. + */ + get value(): Interpolation | undefined { + return this._value; + } + set value(value: Interpolation | InterpolationProps | undefined) { + if (this._value) this._value.parent = undefined; + const built = value + ? typeof value === 'object' && 'sassType' in value + ? value + : new Interpolation(value) + : undefined; + if (built) built.parent = this; + this._value = built; + } + private declare _value: Interpolation | undefined; + + /** + * The modifier which indicates how the attribute selector should be + * processed. + * + * See for example [case-sensitivity] modifiers. + * + * [case-sensitivity]: https://www.w3.org/TR/selectors-4/#attribute-case + * + * This is ignored if {@link operator} or {@link this.value} is `undefined`. + */ + get modifier(): Interpolation | undefined { + return this._modifier; + } + set modifier(modifier: Interpolation | InterpolationProps | undefined) { + if (this._modifier) this._modifier.parent = undefined; + const built = modifier + ? typeof modifier === 'object' && 'sassType' in modifier + ? modifier + : new Interpolation(modifier) + : undefined; + if (built) built.parent = this; + this._modifier = built; + } + private declare _modifier: Interpolation | undefined; + + constructor(defaults: AttributeSelectorProps); + /** @hidden */ + constructor(_: undefined, inner: sassInternal.AttributeSelector); + constructor(defaults?: object, inner?: sassInternal.AttributeSelector) { + super(defaults); + if (inner) { + this.source = new LazySource(inner); + this.attribute = new QualifiedName(undefined, inner.name); + this.operator = inner.op?.toString() as + | AttributeSelectorOperator + | undefined; + if (inner.value) this.value = new Interpolation(undefined, inner.value); + if (inner.modifier) { + this.modifier = new Interpolation(undefined, inner.modifier); + } + } + } + + clone(overrides?: Partial): this { + return utils.cloneNode(this, overrides, [ + 'raws', + 'attribute', + {name: 'operator', explicitUndefined: true}, + {name: 'value', explicitUndefined: true}, + {name: 'modifier', explicitUndefined: true}, + ]); + } + + toJSON(): object; + /** @hidden */ + toJSON(_: string, inputs: Map): object; + toJSON(_?: string, inputs?: Map): object { + return utils.toJSON( + this, + ['attribute', 'operator', 'value', 'modifier'], + inputs, + ); + } + + /** @hidden */ + toString(): string { + let result = `[${this.raws.afterOpen ?? ''}${this.attribute}`; + if (this.operator && this.value) { + result += + (this.raws.beforeOperator ?? '') + + this.operator + + (this.raws.afterOperator ?? '') + + this.value; + if (this.modifier) { + result += (this.raws.afterValue ?? ' ') + this.modifier; + } else { + result += this.raws.afterValue ?? ''; + } + } + result += `${this.raws.beforeClose ?? ''}]`; + return result; + } + + /** @hidden */ + get nonStatementChildren(): ReadonlyArray> { + const result: Array> = [this.attribute]; + if (this.value) result.push(this.value); + if (this.modifier) result.push(this.modifier); + return result; + } +} diff --git a/pkg/sass-parser/lib/src/selector/class.test.ts b/pkg/sass-parser/lib/src/selector/class.test.ts new file mode 100644 index 000000000..7380391d0 --- /dev/null +++ b/pkg/sass-parser/lib/src/selector/class.test.ts @@ -0,0 +1,146 @@ +// 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 {ClassSelector, Interpolation} from '../..'; +import { + fromSimpleSelectorProps, + parseSimpleSelector, +} from '../../../test/utils'; + +describe('a class selector', () => { + let node: ClassSelector; + + describe('without interpolation', () => { + function describeNode( + description: string, + create: () => ClassSelector, + ): void { + describe(description, () => { + beforeEach(() => void (node = create())); + + it('has sassType class', () => expect(node.sassType).toBe('class')); + + it('has a class', () => + expect(node).toHaveInterpolation('class', 'foo')); + }); + } + + describeNode('parsed', () => parseSimpleSelector('.foo')); + + describeNode( + 'constructed manually', + () => new ClassSelector({class: 'foo'}), + ); + + describeNode('from props', () => fromSimpleSelectorProps({class: 'foo'})); + }); + + describe('with interpolation', () => { + function describeNode( + description: string, + create: () => ClassSelector, + ): void { + describe(description, () => { + beforeEach(() => void (node = create())); + + it('has sassType class', () => expect(node.sassType).toBe('class')); + + it('has a class', () => + expect(node.class).toHaveStringExpression(0, 'foo')); + }); + } + + describeNode('parsed', () => parseSimpleSelector('.#{foo}')); + + describeNode( + 'constructed manually', + () => new ClassSelector({class: [{text: 'foo'}]}), + ); + + describeNode('from props', () => + fromSimpleSelectorProps({class: [{text: 'foo'}]}), + ); + }); + + describe('assigned new class', () => { + beforeEach(() => void (node = parseSimpleSelector('.foo'))); + + it("removes the old class's parent", () => { + const oldClass = node.class; + node.class = 'bar'; + expect(oldClass.parent).toBeUndefined(); + }); + + it('assigns class explicitly', () => { + const className = new Interpolation('bar'); + node.class = className; + expect(node.class).toBe(className); + expect(node).toHaveInterpolation('class', 'bar'); + }); + + it('assigns class as InterpolationProps', () => { + node.class = 'bar'; + expect(node).toHaveInterpolation('class', 'bar'); + }); + }); + + it('stringifies', () => + expect(parseSimpleSelector('.foo').toString()).toBe('.foo')); + + describe('clone', () => { + let original: ClassSelector; + + beforeEach(() => { + original = parseSimpleSelector('.foo'); + }); + + describe('with no overrides', () => { + let clone: ClassSelector; + + beforeEach(() => void (clone = original.clone())); + + describe('has the same properties:', () => { + it('class', () => expect(clone).toHaveInterpolation('class', 'foo')); + + it('raws', () => expect(clone.raws).toEqual({})); + + it('source', () => expect(clone.source).toBe(original.source)); + }); + + describe('creates a new', () => { + it('self', () => expect(clone).not.toBe(original)); + + for (const attr of ['class', 'raws'] as const) { + it(attr, () => expect(clone[attr]).not.toBe(original[attr])); + } + }); + }); + + describe('overrides', () => { + describe('class', () => { + it('defined', () => + expect(original.clone({class: 'bar'})).toHaveInterpolation( + 'class', + 'bar', + )); + + it('undefined', () => + expect(original.clone({class: undefined})).toHaveInterpolation( + 'class', + 'foo', + )); + }); + + describe('raws', () => { + it('defined', () => + expect(original.clone({raws: {}}).raws).toEqual({})); + + it('undefined', () => + expect(original.clone({raws: undefined}).raws).toEqual({})); + }); + }); + }); + + it('toJSON', () => expect(parseSimpleSelector('.foo')).toMatchSnapshot()); +}); diff --git a/pkg/sass-parser/lib/src/selector/class.ts b/pkg/sass-parser/lib/src/selector/class.ts new file mode 100644 index 000000000..aa1a5bdcf --- /dev/null +++ b/pkg/sass-parser/lib/src/selector/class.ts @@ -0,0 +1,91 @@ +// 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 {Interpolation, InterpolationProps} from '../interpolation'; +import {LazySource} from '../lazy-source'; +import type {AnyNode, NodeProps} from '../node'; +import type {AnyStatement} from '../statement'; +import * as sassInternal from '../sass-internal'; +import * as utils from '../utils'; +import {SimpleSelector} from './index'; + +/** + * The initializer properties for {@link ClassSelector}. + * + * @category Selector + */ +export interface ClassSelectorProps extends NodeProps { + class: Interpolation | InterpolationProps; + raws?: ClassSelectorRaws; +} + +/** + * Raws indicating how to precisely serialize an {@ClassSelector}. + * + * @category Selector + */ +// eslint-disable-next-line @typescript-eslint/no-empty-interface -- No raws for a class selector yet. +export interface ClassSelectorRaws {} + +/** + * A class selector. + * + * This selects elements whose `class` attribute contains an identifier with the + * given name. + * + * @category Selector + */ +export class ClassSelector extends SimpleSelector { + readonly sassType = 'class' as const; + declare raws: ClassSelectorRaws; + + /** The class name that this selects. */ + get class(): Interpolation { + return this._class; + } + set class(className: Interpolation | InterpolationProps) { + if (this._class) this._class.parent = undefined; + const built = + typeof className === 'object' && 'sassType' in className + ? className + : new Interpolation(className); + built.parent = this; + this._class = built; + } + private declare _class: Interpolation; + + constructor(defaults: ClassSelectorProps); + /** @hidden */ + constructor(_: undefined, inner: sassInternal.ClassSelector); + constructor(defaults?: object, inner?: sassInternal.ClassSelector) { + super(defaults); + if (inner) { + this.source = new LazySource(inner); + this.class = new Interpolation(undefined, inner.name); + } + } + + clone(overrides?: Partial): this { + return utils.cloneNode(this, overrides, ['raws', 'class']); + } + + toJSON(): object; + /** @hidden */ + toJSON(_: string, inputs: Map): object; + toJSON(_?: string, inputs?: Map): object { + return utils.toJSON(this, ['class'], inputs); + } + + /** @hidden */ + toString(): string { + return `.${this.class}`; + } + + /** @hidden */ + get nonStatementChildren(): ReadonlyArray> { + return [this.class]; + } +} diff --git a/pkg/sass-parser/lib/src/selector/complex-component.test.ts b/pkg/sass-parser/lib/src/selector/complex-component.test.ts new file mode 100644 index 000000000..143a79414 --- /dev/null +++ b/pkg/sass-parser/lib/src/selector/complex-component.test.ts @@ -0,0 +1,234 @@ +// 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 { + ComplexSelector, + ComplexSelectorComponent, + ComplexSelectorComponentProps, + CompoundSelector, +} from '../..'; +import * as utils from '../../../test/utils'; + +/** Parses `text` as a single compound selector. */ +function parse(text: string): ComplexSelectorComponent { + const list = utils.parseSelector(text); + expect(list.nodes).toHaveLength(1); + const complex = list.nodes[0]; + expect(complex.nodes).toHaveLength(1); + return complex.nodes[0]; +} + +/** Loads `props` as a complex selector component. */ +function fromProps( + props: ComplexSelectorComponentProps, +): ComplexSelectorComponent { + return new ComplexSelector([props]).nodes[0]; +} + +describe('a complex selector component', () => { + let node: ComplexSelectorComponent; + + describe('without a combinator', () => { + function describeNode( + description: string, + create: () => ComplexSelectorComponent, + ): void { + describe(description, () => { + beforeEach(() => void (node = create())); + + it('has sassType complex-selector-component', () => + expect(node.sassType).toBe('complex-selector-component')); + + it('has no combinator', () => expect(node.combinator).toBeUndefined()); + + it('has a compound', () => + expect(node).toHaveNode('compound', '.foo', 'compound-selector')); + }); + } + + describeNode('parsed', () => parse('.foo')); + + describeNode( + 'constructed manually', + () => new ComplexSelectorComponent({compound: {class: 'foo'}}), + ); + + describe('from props', () => { + describeNode('as an object', () => fromProps({compound: {class: 'foo'}})); + + describeNode('as a compound selector', () => fromProps({class: 'foo'})); + }); + }); + + describe('with a combinator', () => { + function describeNode( + description: string, + create: () => ComplexSelectorComponent, + ): void { + describe(description, () => { + beforeEach(() => void (node = create())); + + it('has sassType complex-selector-component', () => + expect(node.sassType).toBe('complex-selector-component')); + + it('has a combinator', () => expect(node.combinator).toEqual('>')); + + it('has a compound', () => + expect(node).toHaveNode('compound', '.foo', 'compound-selector')); + }); + } + + describeNode('parsed', () => parse('.foo >')); + + describeNode( + 'constructed manually', + () => + new ComplexSelectorComponent({ + compound: {class: 'foo'}, + combinator: '>', + }), + ); + + describeNode('from props', () => + fromProps({compound: {class: 'foo'}, combinator: '>'}), + ); + }); + + describe('assigned new', () => { + beforeEach(() => void (node = parse('.foo >'))); + + describe('combinator', () => { + it('defined', () => { + node.combinator = '+'; + expect(node.combinator).toEqual('+'); + }); + + it('undefined', () => { + node.combinator = undefined; + expect(node.combinator).toBeUndefined(); + }); + }); + + describe('compound', () => { + it("removes the old compound's parent", () => { + const oldCompound = node.compound; + node.compound = {class: 'bar'}; + expect(oldCompound.parent).toBeUndefined(); + }); + + it('assigns compound explicitly', () => { + const compound = new CompoundSelector({class: 'bar'}); + node.compound = compound; + expect(node.compound).toBe(compound); + expect(node.compound.parent).toBe(node); + }); + + it('assigns compound as CompoundSelectorProps', () => { + node.compound = {class: 'bar'}; + expect(node).toHaveNode('compound', '.bar'); + }); + }); + }); + + describe('stringifies', () => { + describe('without a combinator', () => { + beforeEach(() => { + node = new ComplexSelectorComponent({class: 'foo'}); + }); + + it('with no raws', () => expect(node.toString()).toBe('.foo')); + + it('ignores all raws', () => { + node.raws.between = ' '; + expect(node.toString()).toBe('.foo'); + }); + }); + + describe('with a combinator', () => { + beforeEach(() => { + node = new ComplexSelectorComponent({ + combinator: '+', + compound: {class: 'foo'}, + }); + }); + + it('with no raws', () => expect(node.toString()).toBe('.foo +')); + + it('ignores all raws', () => { + node.raws.between = ' '; + expect(node.toString()).toBe('.foo +'); + }); + }); + }); + + describe('clone', () => { + let original: ComplexSelectorComponent; + + beforeEach(() => { + original = parse('.foo +'); + }); + + describe('with no overrides', () => { + let clone: ComplexSelectorComponent; + + beforeEach(() => void (clone = original.clone())); + + describe('has the same properties:', () => { + it('combinator', () => expect(clone.combinator).toEqual('+')); + + it('compound', () => expect(clone).toHaveNode('compound', '.foo')); + + it('raws', () => expect(clone.raws).toEqual({})); + + it('source', () => expect(clone.source).toBe(original.source)); + }); + + describe('creates a new', () => { + it('self', () => expect(clone).not.toBe(original)); + + for (const attr of ['compound', 'raws'] as const) { + it(attr, () => expect(clone[attr]).not.toBe(original[attr])); + } + }); + }); + + describe('overrides', () => { + describe('combinator', () => { + it('defined', () => + expect(original.clone({combinator: '>'}).combinator).toBe('>')); + + it('undefined', () => + expect( + original.clone({combinator: undefined}).combinator, + ).toBeUndefined()); + }); + + describe('selector', () => { + it('defined', () => { + const clone = original.clone({compound: {id: 'bar'}}); + expect(clone).toHaveNode('compound', '#bar'); + }); + + it('undefined', () => + expect( + original.clone({compound: undefined}).compound.toString(), + ).toEqual('.foo')); + }); + + describe('raws', () => { + it('defined', () => + expect(original.clone({raws: {}}).raws).toEqual({})); + + it('undefined', () => + expect(original.clone({raws: undefined}).raws).toEqual({})); + }); + }); + }); + + describe('toJSON', () => { + it('with no combinator', () => expect(parse('.foo')).toMatchSnapshot()); + + it('with a combinator', () => expect(parse('.foo +')).toMatchSnapshot()); + }); +}); diff --git a/pkg/sass-parser/lib/src/selector/complex-component.ts b/pkg/sass-parser/lib/src/selector/complex-component.ts new file mode 100644 index 000000000..e5dae814f --- /dev/null +++ b/pkg/sass-parser/lib/src/selector/complex-component.ts @@ -0,0 +1,138 @@ +// Copyright 2025 Google Inc. Use of this source code is governed by an +// MIT-style license that can be found in the LICENSE file or at +// https://opensource.org/licenses/MIT. + +import * as postcss from 'postcss'; + +import {LazySource} from '../lazy-source'; +import {AnyNode, Node, NodeProps} from '../node'; +import * as sassInternal from '../sass-internal'; +import {AnyStatement} from '../statement'; +import * as utils from '../utils'; +import {SelectorCombinator} from './complex'; +import {CompoundSelector, CompoundSelectorProps} from './compound'; + +/** + * The initializer properties for {@link ComplexSelectorComponent} passed as an + * options object.. + * + * @category Selector + */ +export interface ComplexSelectorComponentObjectProps extends NodeProps { + compound: CompoundSelector | CompoundSelectorProps; + combinator?: SelectorCombinator; + raws?: ComplexSelectorComponentRaws; +} + +/** + * The initializer properties for {@link ComplexSelectorComponents}. + * + * @category Selector + */ +export type ComplexSelectorComponentProps = + | ComplexSelectorComponentObjectProps + | CompoundSelector + | CompoundSelectorProps; + +/** + * Raws indicating how to precisely serialize a {@ComplexSelectorComponent}. + * + * @category Selector + */ +export interface ComplexSelectorComponentRaws { + /** + * The whitespace between the combinator and the compound selector. + * + * This is ignored unless {@link ComplexSelectorComponent.combinator} is + * defined. + */ + between?: string; +} + +/** + * A single component of a {@link ComplexSelector}, which is a compound selector + * that may or may not have a combinator before it. + * + * @category Selector + */ +export class ComplexSelectorComponent extends Node { + readonly sassType = 'complex-selector-component' as const; + declare raws: ComplexSelectorComponentRaws; + + /** + * The combinator after this component's compound selector. + * + * If this is undefined, it indicates that the component uses a descendent + * combinator, or no combinator at all if it's at the beginning of the complex + * selector. + */ + get combinator(): SelectorCombinator | undefined { + return this._combinator; + } + set combinator(combinator: SelectorCombinator | undefined) { + this._combinator = combinator; + } + private declare _combinator: SelectorCombinator | undefined; + + /** This componnet's compound selector. */ + get compound(): CompoundSelector { + return this._compound; + } + set compound(compound: CompoundSelector | CompoundSelectorProps) { + if (this._compound) this._compound.parent = undefined; + const built = + 'sassType' in compound && compound.sassType === 'compound-selector' + ? compound + : new CompoundSelector(compound); + built.parent = this; + this._compound = built; + } + private declare _compound: CompoundSelector; + + constructor(defaults: ComplexSelectorComponentProps); + /** @hidden */ + constructor(_: undefined, inner: sassInternal.ComplexSelectorComponent); + constructor( + defaults?: object, + inner?: sassInternal.ComplexSelectorComponent, + ) { + if (defaults && !('compound' in defaults)) defaults = {compound: defaults}; + super(defaults); + if (inner) { + this.source = new LazySource(inner); + this.compound = new CompoundSelector(undefined, inner.selector); + // Multiple combinators will be removed soon so we don't bother + // supporting it here. + this.combinator = inner.combinator?.toString() as SelectorCombinator; + } + } + + clone(overrides?: Partial): this { + return utils.cloneNode(this, overrides, [ + 'raws', + 'compound', + {name: 'combinator', explicitUndefined: true}, + ]); + } + + toJSON(): object; + /** @hidden */ + toJSON(_: string, inputs: Map): object; + toJSON(_?: string, inputs?: Map): object { + return utils.toJSON(this, ['compound', 'combinator'], inputs); + } + + /** @hidden */ + toString(): string { + let result = this.compound.toString(); + if (this.combinator) { + result += (this.raws.between ?? ' ') + this.combinator; + } + return result; + } + + /** @hidden */ + get nonStatementChildren(): ReadonlyArray> { + return [this.compound]; + } +} diff --git a/pkg/sass-parser/lib/src/selector/complex.test.ts b/pkg/sass-parser/lib/src/selector/complex.test.ts new file mode 100644 index 000000000..81b17db21 --- /dev/null +++ b/pkg/sass-parser/lib/src/selector/complex.test.ts @@ -0,0 +1,852 @@ +// 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 { + ClassSelector, + ComplexSelector, + ComplexSelectorComponent, + ComplexSelectorProps, + CompoundSelector, + SelectorList, +} from '../..'; +import * as utils from '../../../test/utils'; + +type EachFn = Parameters[0]; + +/** Parses `text` as a single complex selector. */ +function parse(text: string): ComplexSelector { + const list = utils.parseSelector(text); + expect(list.nodes).toHaveLength(1); + return list.nodes[0]; +} + +/** Loads `props` as a complex selector. */ +function fromProps(props: ComplexSelectorProps): ComplexSelector { + return new SelectorList({nodes: [props]}).nodes[0]; +} + +let node: ComplexSelector; +describe('a complex selector', () => { + describe('with one child', () => { + function describeNode( + description: string, + create: () => ComplexSelector, + ): void { + describe(description, () => { + beforeEach(() => void (node = create())); + + it('has sassType complex-selector', () => + expect(node.sassType).toBe('complex-selector')); + + it('has no leading combinator', () => + expect(node.leadingCombinator).toBeUndefined()); + + it('has a child', () => { + expect(node.nodes).toHaveLength(1); + expect(node).toHaveNode(0, '.foo', 'complex-selector-component'); + }); + }); + } + + describeNode('parsed', () => parse('.foo')); + + describeNode( + 'constructed manually', + () => new ComplexSelector({nodes: [{class: 'foo'}]}), + ); + + describe('from props', () => { + describe('as an object', () => { + describeNode('with simple props', () => + fromProps({nodes: [{class: 'foo'}]}), + ); + + describeNode('with compound props', () => + fromProps({nodes: [{nodes: [{class: 'foo'}]}]}), + ); + + describeNode('with component props', () => + fromProps({nodes: [{compound: {class: 'foo'}}]}), + ); + + describeNode('with a full component', () => + fromProps({nodes: [new ComplexSelectorComponent({class: 'foo'})]}), + ); + }); + + describe('as an array', () => { + describeNode('with simple props', () => fromProps([{class: 'foo'}])); + + describeNode('with compound props', () => + fromProps([{nodes: [{class: 'foo'}]}]), + ); + + describeNode('with component props', () => + fromProps([{compound: {class: 'foo'}}]), + ); + + describeNode('with a full component', () => + fromProps([new ComplexSelectorComponent({class: 'foo'})]), + ); + }); + + describeNode('as simple props', () => fromProps({class: 'foo'})); + + describeNode('as component props', () => + fromProps({compound: {class: 'foo'}}), + ); + + describeNode('as a simple selector', () => + fromProps(new ClassSelector({class: 'foo'})), + ); + + describeNode('as a compound selector', () => + fromProps(new CompoundSelector({class: 'foo'})), + ); + + describeNode('as a component', () => + fromProps(new ComplexSelectorComponent({class: 'foo'})), + ); + }); + }); + + describe('with multiple children', () => { + function describeNode( + description: string, + create: () => ComplexSelector, + ): void { + describe(description, () => { + beforeEach(() => void (node = create())); + + it('has sassType complex-selector', () => + expect(node.sassType).toBe('complex-selector')); + + it('has no leading combinator', () => + expect(node.leadingCombinator).toBeUndefined()); + + it('has children', () => { + expect(node.nodes).toHaveLength(2); + expect(node).toHaveNode(0, '.foo', 'complex-selector-component'); + expect(node).toHaveNode(1, '.bar', 'complex-selector-component'); + }); + }); + } + + describeNode('parsed', () => parse('.foo .bar')); + + describeNode( + 'constructed manually', + () => new ComplexSelector({nodes: [{class: 'foo'}, {class: 'bar'}]}), + ); + + describe('from props', () => { + describe('as an object', () => { + describeNode('with simple props', () => + fromProps({nodes: [{class: 'foo'}, {class: 'bar'}]}), + ); + + describeNode('with full components', () => + fromProps({ + nodes: [ + new ComplexSelectorComponent({class: 'foo'}), + new ComplexSelectorComponent({class: 'bar'}), + ], + }), + ); + }); + + describe('as an array', () => { + describeNode('with simple props', () => + fromProps([{class: 'foo'}, {class: 'bar'}]), + ); + + describeNode('with full components', () => + fromProps([ + new ComplexSelectorComponent({class: 'foo'}), + new ComplexSelectorComponent({class: 'bar'}), + ]), + ); + }); + }); + }); + + describe('with a leading combinator', () => { + function describeNode( + description: string, + create: () => ComplexSelector, + ): void { + describe(description, () => { + beforeEach(() => void (node = create())); + + it('has sassType complex-selector', () => + expect(node.sassType).toBe('complex-selector')); + + it('has a leading combinator', () => + expect(node.leadingCombinator).toBe('+')); + + it('has a child', () => { + expect(node.nodes).toHaveLength(1); + expect(node).toHaveNode(0, '.foo', 'complex-selector-component'); + }); + }); + } + + describeNode('parsed', () => parse('+ .foo')); + + describeNode( + 'constructed manually', + () => + new ComplexSelector({leadingCombinator: '+', nodes: [{class: 'foo'}]}), + ); + + describeNode('from props', () => + fromProps({leadingCombinator: '+', nodes: [{class: 'foo'}]}), + ); + }); + + describe('assigned new leadingCombinator', () => { + beforeEach(() => void (node = parse('> .foo'))); + + it('defined', () => { + node.leadingCombinator = '+'; + expect(node.leadingCombinator).toEqual('+'); + }); + + it('undefined', () => { + node.leadingCombinator = undefined; + expect(node.leadingCombinator).toBeUndefined(); + }); + }); + + describe('can add', () => { + beforeEach(() => void (node = new ComplexSelector())); + + it('a single component', () => { + const component = new ComplexSelectorComponent({class: 'foo'}); + node.append(component); + expect(node.nodes[0]).toBe(component); + expect(component.parent).toBe(node); + }); + + it('a list of selectors', () => { + const component1 = new ComplexSelectorComponent({class: 'foo'}); + const component2 = new ComplexSelectorComponent({class: 'bar'}); + node.append([component1, component2]); + expect(node.nodes[0]).toBe(component1); + expect(node.nodes[1]).toBe(component2); + expect(component1.parent).toBe(node); + expect(component2.parent).toBe(node); + }); + + it("a simple selector's properties", () => { + node.append({class: 'foo'}); + expect(node).toHaveNode(0, '.foo'); + }); + + it('a simple selector', () => { + node.append(new ClassSelector({class: 'foo'})); + expect(node).toHaveNode(0, '.foo'); + }); + + it("a compound selector's properties", () => { + node.append({nodes: [{class: 'foo'}]}); + expect(node).toHaveNode(0, '.foo'); + }); + + it('a compound selector', () => { + node.append(new CompoundSelector({class: 'foo'})); + expect(node).toHaveNode(0, '.foo'); + }); + + it("a component's properties", () => { + node.append({compound: {class: 'foo'}}); + expect(node).toHaveNode(0, '.foo'); + }); + + it('a component', () => { + node.append(new ComplexSelectorComponent({class: 'foo'})); + expect(node).toHaveNode(0, '.foo'); + }); + + it('a list of properties', () => { + node.append([{class: 'foo'}, {class: 'bar'}]); + expect(node).toHaveNode(0, '.foo'); + expect(node).toHaveNode(1, '.bar'); + }); + + it('undefined', () => { + node.append(undefined); + expect(node.nodes).toHaveLength(0); + }); + }); + + describe('append', () => { + beforeEach( + () => + void (node = new ComplexSelector({ + nodes: [{class: 'foo'}, {class: 'bar'}], + })), + ); + + it('adds multiple children to the end', () => { + node.append({class: 'baz'}, {class: 'qux'}); + expect(node.nodes).toHaveLength(4); + expect(node).toHaveNode(0, '.foo'); + expect(node).toHaveNode(1, '.bar'); + expect(node).toHaveNode(2, '.baz'); + expect(node).toHaveNode(3, '.qux'); + }); + + it('can be called during iteration', () => + testEachMutation(['.foo', '.bar', '.baz'], 0, () => + node.append({class: 'baz'}), + )); + + it('returns itself', () => expect(node.append()).toBe(node)); + }); + + describe('each', () => { + beforeEach( + () => + void (node = new ComplexSelector({ + nodes: [{class: 'foo'}, {class: 'bar'}], + })), + ); + + 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 ComplexSelector({ + nodes: [{class: 'foo'}, {class: 'bar'}, {class: 'baz'}], + })), + ); + + 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.toString() !== '.bar')).toBe(false)); + }); + + describe('index', () => { + beforeEach( + () => + void (node = new ComplexSelector({ + nodes: [ + {class: 'foo'}, + {class: 'bar'}, + {class: 'baz'}, + {class: 'qux'}, + ], + })), + ); + + it('returns the first index of a given selector', () => + 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 ComplexSelector({ + nodes: [{class: 'foo'}, {class: 'bar'}, {class: 'baz'}], + })), + ); + + it('inserts a node after the given element', () => { + node.insertAfter(node.nodes[1], {class: 'qux'}); + expect(node.nodes).toHaveLength(4); + expect(node).toHaveNode(0, '.foo'); + expect(node).toHaveNode(1, '.bar'); + expect(node).toHaveNode(2, '.qux'); + expect(node).toHaveNode(3, '.baz'); + }); + + it('inserts a node at the beginning', () => { + node.insertAfter(-1, {class: 'qux'}); + expect(node.nodes).toHaveLength(4); + expect(node).toHaveNode(0, '.qux'); + expect(node).toHaveNode(1, '.foo'); + expect(node).toHaveNode(2, '.bar'); + expect(node).toHaveNode(3, '.baz'); + }); + + it('inserts a node at the end', () => { + node.insertAfter(3, {class: 'qux'}); + expect(node.nodes).toHaveLength(4); + expect(node).toHaveNode(0, '.foo'); + expect(node).toHaveNode(1, '.bar'); + expect(node).toHaveNode(2, '.baz'); + expect(node).toHaveNode(3, '.qux'); + }); + + it('inserts multiple nodes', () => { + node.insertAfter(1, [{class: 'qux'}, {class: 'qax'}, {class: 'qix'}]); + expect(node.nodes).toHaveLength(6); + expect(node).toHaveNode(0, '.foo'); + expect(node).toHaveNode(1, '.bar'); + expect(node).toHaveNode(2, '.qux'); + expect(node).toHaveNode(3, '.qax'); + expect(node).toHaveNode(4, '.qix'); + expect(node).toHaveNode(5, '.baz'); + }); + + it('inserts before an iterator', () => + testEachMutation(['.foo', '.bar', ['.baz', 5]], 1, () => + node.insertAfter(0, [{class: 'qux'}, {class: 'qax'}, {class: 'qix'}]), + )); + + it('inserts after an iterator', () => + testEachMutation( + ['.foo', '.bar', '.qux', '.qax', '.qix', '.baz'], + 1, + () => + node.insertAfter(1, [{class: 'qux'}, {class: 'qax'}, {class: 'qix'}]), + )); + + it('returns itself', () => + expect(node.insertAfter(node.nodes[0], {class: 'qux'})).toBe(node)); + }); + + describe('insertBefore', () => { + beforeEach( + () => + void (node = new ComplexSelector({ + nodes: [{class: 'foo'}, {class: 'bar'}, {class: 'baz'}], + })), + ); + + it('inserts a node before the given element', () => { + node.insertBefore(node.nodes[1], {class: 'qux'}); + expect(node.nodes).toHaveLength(4); + expect(node).toHaveNode(0, '.foo'); + expect(node).toHaveNode(1, '.qux'); + expect(node).toHaveNode(2, '.bar'); + expect(node).toHaveNode(3, '.baz'); + }); + + it('inserts a node at the beginning', () => { + node.insertBefore(0, {class: 'qux'}); + expect(node.nodes).toHaveLength(4); + expect(node).toHaveNode(0, '.qux'); + expect(node).toHaveNode(1, '.foo'); + expect(node).toHaveNode(2, '.bar'); + expect(node).toHaveNode(3, '.baz'); + }); + + it('inserts a node at the end', () => { + node.insertBefore(4, {class: 'qux'}); + expect(node.nodes).toHaveLength(4); + expect(node).toHaveNode(0, '.foo'); + expect(node).toHaveNode(1, '.bar'); + expect(node).toHaveNode(2, '.baz'); + expect(node).toHaveNode(3, '.qux'); + }); + + it('inserts multiple nodes', () => { + node.insertBefore(1, [{class: 'qux'}, {class: 'qax'}, {class: 'qix'}]); + expect(node.nodes).toHaveLength(6); + expect(node).toHaveNode(0, '.foo'); + expect(node).toHaveNode(1, '.qux'); + expect(node).toHaveNode(2, '.qax'); + expect(node).toHaveNode(3, '.qix'); + expect(node).toHaveNode(4, '.bar'); + expect(node).toHaveNode(5, '.baz'); + }); + + it('inserts before an iterator', () => + testEachMutation(['.foo', '.bar', ['.baz', 5]], 1, () => + node.insertBefore(1, [{class: 'qux'}, {class: 'qax'}, {class: 'qix'}]), + )); + + it('inserts after an iterator', () => + testEachMutation( + ['.foo', '.bar', '.qux', '.qax', '.qix', '.baz'], + 1, + () => + node.insertBefore(2, [ + {class: 'qux'}, + {class: 'qax'}, + {class: 'qix'}, + ]), + )); + + it('returns itself', () => + expect(node.insertBefore(node.nodes[0], {class: 'qux'})).toBe(node)); + }); + + describe('prepend', () => { + beforeEach( + () => + void (node = new ComplexSelector({ + nodes: [{class: 'foo'}, {class: 'bar'}, {class: 'baz'}], + })), + ); + + it('inserts one node', () => { + node.prepend({class: 'qux'}); + expect(node.nodes).toHaveLength(4); + expect(node).toHaveNode(0, '.qux'); + expect(node).toHaveNode(1, '.foo'); + expect(node).toHaveNode(2, '.bar'); + expect(node).toHaveNode(3, '.baz'); + }); + + it('inserts multiple nodes', () => { + node.prepend({class: 'qux'}, {class: 'qax'}, {class: 'qix'}); + expect(node.nodes).toHaveLength(6); + expect(node).toHaveNode(0, '.qux'); + expect(node).toHaveNode(1, '.qax'); + expect(node).toHaveNode(2, '.qix'); + expect(node).toHaveNode(3, '.foo'); + expect(node).toHaveNode(4, '.bar'); + expect(node).toHaveNode(5, '.baz'); + }); + + it('inserts before an iterator', () => + testEachMutation(['.foo', '.bar', ['.baz', 5]], 1, () => + node.prepend({class: 'qux'}, {class: 'qax'}, {class: 'qix'}), + )); + + it('returns itself', () => expect(node.prepend({class: 'qux'})).toBe(node)); + }); + + describe('push', () => { + beforeEach( + () => + void (node = new ComplexSelector({ + nodes: [{class: 'foo'}, {class: 'bar'}], + })), + ); + + it('inserts one node', () => { + node.push(new ComplexSelectorComponent({class: 'baz'})); + expect(node.nodes).toHaveLength(3); + expect(node).toHaveNode(0, '.foo'); + expect(node).toHaveNode(1, '.bar'); + expect(node).toHaveNode(2, '.baz'); + }); + + it('can be called during iteration', () => + testEachMutation(['.foo', '.bar', '.baz'], 0, () => + node.push(new ComplexSelectorComponent({class: 'baz'})), + )); + + it('returns itself', () => + expect(node.push(new ComplexSelectorComponent({class: 'baz'}))).toBe( + node, + )); + }); + + describe('removeAll', () => { + beforeEach( + () => + void (node = new ComplexSelector({ + nodes: [{class: 'foo'}, {class: 'bar'}, {class: 'baz'}], + })), + ); + + it('removes all nodes', () => { + node.removeAll(); + expect(node.nodes).toHaveLength(0); + }); + + it("removes a node's parents", () => { + const component = node.nodes[1]; + node.removeAll(); + expect(component.parent).toBeUndefined(); + }); + + it('can be called during iteration', () => + testEachMutation(['.foo'], 0, () => node.removeAll())); + + it('returns itself', () => expect(node.removeAll()).toBe(node)); + }); + + describe('removeChild', () => { + beforeEach( + () => + void (node = new ComplexSelector({ + nodes: [{class: 'foo'}, {class: 'bar'}, {class: 'baz'}], + })), + ); + + 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).toHaveNode(0, '.foo'); + expect(node).toHaveNode(1, '.baz'); + }); + + 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', 1]], 1, () => + node.removeChild(1), + )); + + it('removes a node after the iterator', () => + testEachMutation(['.foo', '.bar'], 1, () => node.removeChild(2))); + + it('returns itself', () => expect(node.removeChild(0)).toBe(node)); + }); + + describe('some', () => { + beforeEach( + () => + void (node = new ComplexSelector({ + nodes: [{class: 'foo'}, {class: 'bar'}, {class: 'baz'}], + })), + ); + + 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.compound.toString() === '.bar')).toBe( + true, + )); + }); + + describe('first', () => { + it('returns the first element', () => + expect( + new ComplexSelector({ + nodes: [{class: 'foo'}, {class: 'bar'}, {class: 'baz'}], + }), + ).toHaveNode('first', '.foo')); + + it('returns undefined for an empty selector', () => + expect(new ComplexSelector().first).toBeUndefined()); + }); + + describe('last', () => { + it('returns the last element', () => + expect( + new ComplexSelector({ + nodes: [{class: 'foo'}, {class: 'bar'}, {class: 'baz'}], + }), + ).toHaveNode('last', '.baz')); + + it('returns undefined for an empty selector', () => + expect(new ComplexSelector().last).toBeUndefined()); + }); + + describe('stringifies', () => { + describe('with one child', () => { + beforeEach(() => { + node = new ComplexSelector({class: 'foo'}); + }); + + it('with no raws', () => expect(node.toString()).toBe('.foo')); + + it('ignores between', () => { + node.raws.between = ' '; + expect(node.toString()).toBe('.foo'); + }); + + it('with one component raw', () => { + node.raws.components = [' ']; + expect(node.toString()).toBe('.foo '); + }); + + it('ignores extra component raws', () => { + node.raws.components = [undefined, ' ']; + expect(node.toString()).toBe('.foo'); + }); + }); + + describe('with multiple children', () => { + beforeEach(() => { + node = new ComplexSelector([ + {class: 'foo'}, + {combinator: '+', compound: {class: 'bar'}}, + {class: 'baz'}, + ]); + }); + + it('with no raws', () => + expect(node.toString()).toBe('.foo .bar + .baz')); + + it('ignores between', () => { + node.raws.between = ' '; + expect(node.toString()).toBe('.foo .bar + .baz'); + }); + + it('with one component raw', () => { + node.raws.components = [' ']; + expect(node.toString()).toBe('.foo .bar + .baz'); + }); + + it('with the same number of component raws', () => { + node.raws.components = [' ', '/**/', '/* */']; + expect(node.toString()).toBe('.foo .bar +/**/.baz/* */'); + }); + + it('with too many component raws', () => { + node.raws.components = [' ', '/**/', '/* */', '/***/']; + expect(node.toString()).toBe('.foo .bar +/**/.baz/* */'); + }); + }); + + describe('with a leading combinator', () => { + beforeEach(() => { + node = new ComplexSelector({ + leadingCombinator: '+', + nodes: [{class: 'foo'}], + }); + }); + + it('with no raws', () => expect(node.toString()).toBe('+ .foo')); + + it('with between', () => { + node.raws.between = ' '; + expect(node.toString()).toBe('+ .foo'); + }); + }); + }); + + describe('clone', () => { + let original: ComplexSelector; + + beforeEach(() => { + original = parse('+ .foo .bar'); + }); + + describe('with no overrides', () => { + let clone: ComplexSelector; + + beforeEach(() => void (clone = original.clone())); + + describe('has the same properties:', () => { + it('leadingCombinator', () => + expect(clone.leadingCombinator).toBe('+')); + + it('nodes', () => { + expect(clone.nodes).toHaveLength(2); + expect(clone).toHaveNode(0, '.foo'); + expect(clone).toHaveNode(1, '.bar'); + }); + + it('raws', () => expect(clone.raws).toEqual({})); + + it('source', () => expect(clone.source).toBe(original.source)); + }); + + describe('creates a new', () => { + it('self', () => expect(clone).not.toBe(original)); + + for (const attr of ['nodes', 'raws'] as const) { + it(attr, () => expect(clone[attr]).not.toBe(original[attr])); + } + }); + }); + + describe('overrides', () => { + describe('leadingCombinator', () => { + it('defined', () => + expect( + original.clone({leadingCombinator: '>'}).leadingCombinator, + ).toBe('>')); + + it('undefined', () => + expect( + original.clone({leadingCombinator: undefined}).leadingCombinator, + ).toBeUndefined()); + }); + + describe('nodes', () => { + it('defined', () => { + const clone = original.clone({nodes: [{class: 'zip'}]}); + expect(clone.nodes).toHaveLength(1); + expect(clone).toHaveNode(0, '.zip'); + }); + + it('undefined', () => { + const clone = original.clone({nodes: undefined}); + expect(clone.nodes).toHaveLength(2); + expect(clone).toHaveNode(0, '.foo'); + expect(clone).toHaveNode(1, '.bar'); + }); + }); + + describe('raws', () => { + it('defined', () => + expect(original.clone({raws: {}}).raws).toEqual({})); + + it('undefined', () => + expect(original.clone({raws: undefined}).raws).toEqual({})); + }); + }); + }); + + describe('toJSON', () => { + it('with no leading combinator', () => + expect(parse('.foo .bar')).toMatchSnapshot()); + + it('with a leading combinator', () => + expect(parse('+ .foo')).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, number] | 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 [value, index] = Array.isArray(element) ? element : [element, i]; + expect(fn).toHaveBeenNthCalledWith( + i + 1, + expect.nodeWithToString(value), + index, + ); + } + expect(fn).toHaveBeenCalledTimes(elements.length); +} diff --git a/pkg/sass-parser/lib/src/selector/complex.ts b/pkg/sass-parser/lib/src/selector/complex.ts new file mode 100644 index 000000000..516c162d2 --- /dev/null +++ b/pkg/sass-parser/lib/src/selector/complex.ts @@ -0,0 +1,352 @@ +// 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 {AnyNode, Node, NodeProps} from '../node'; +import * as sassInternal from '../sass-internal'; +import {AnyStatement} from '../statement'; +import * as utils from '../utils'; +import { + ComplexSelectorComponent, + ComplexSelectorComponentProps, +} from './complex-component'; + +/** + * A selector combinator that can separate {@link CompoundSelector}s in a {@link + * ComplexSelector}. + */ +export type SelectorCombinator = '+' | '>' | '~'; + +/** + * The initializer properties for {@link ComplexSelector} passed as an options + * object. + * + * @category Selector + */ +export interface ComplexSelectorObjectProps extends NodeProps { + leadingCombinator?: SelectorCombinator | undefined; + nodes: Array; + raws?: ComplexSelectorRaws; +} + +/** + * The initializer properties for {@link ComplexSelector}. + * + * @category Selector + */ +export type ComplexSelectorProps = + | ComplexSelectorObjectProps + | ReadonlyArray + | ComplexSelectorComponent + | ComplexSelectorComponentProps; + +// TODO: Parse strings. +/** + * The type of new nodes that can be passed into a complex selector. + * + * @category Selector + */ +export type NewNodeForComplexSelector = + | ComplexSelectorComponent + | ReadonlyArray + | ComplexSelectorComponentProps + | ReadonlyArray + | undefined; + +/** + * Raws indicating how to precisely serialize an {@ComplexSelector}. + * + * @category Selector + */ +export interface ComplexSelectorRaws { + /** + * The whitespace between the leading combinator and the first component. + * + * This is ignored if {@link ComplexSelector.leadingCombinator} is undefined. + */ + between?: string; + + /** The whitespace after each component in the selector. */ + components?: Array; +} + +/** + * A complex selector. + * + * A complex selector is composed of {@link ComplexSelectorComponent}s. It + * selects elements based on selectors for other, related elements. + * + * @category Selector + */ +export class ComplexSelector + extends Node + implements Container +{ + readonly sassType = 'complex-selector' as const; + declare raws: ComplexSelectorRaws; + + /** This selector's leading combinator, if it has one. */ + get leadingCombinator(): SelectorCombinator | undefined { + return this._leadingCombinator; + } + set leadingCombinator(value: SelectorCombinator | undefined) { + this._leadingCombinator = value; + } + private declare _leadingCombinator: SelectorCombinator | undefined; + + /** The components that comprise this selector. */ + 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 selector. 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?: ComplexSelectorProps); + /** @hidden */ + constructor(_: undefined, inner: sassInternal.ComplexSelector); + constructor(defaults?: object, inner?: sassInternal.ComplexSelector) { + if (defaults) { + if ( + !Array.isArray(defaults) && + 'nodes' in defaults && + !('sassType' in defaults) + ) { + defaults.nodes = [defaults.nodes]; + } else { + // Wrap an array in an extra array because PostCSS calls + // append(...nodes). This ensures that the array is processed, as a + // unit, by [_normalize]. This in turn means that an array of arrays is + // processed as a single compound. + defaults = {nodes: [defaults]}; + } + } + + super(defaults); + this.nodes ??= []; + if (inner) { + this.source = new LazySource(inner); + // Multiple combinators will be removed soon so we don't bother + // supporting it here. + this.leadingCombinator = + inner.leadingCombinator?.toString() as SelectorCombinator; + for (const component of inner.components) { + this.append(new ComplexSelectorComponent(undefined, component)); + } + } + } + + clone(overrides?: Partial): this { + return utils.cloneNode(this, overrides, [ + 'raws', + {name: 'leadingCombinator', explicitUndefined: true}, + 'nodes', + ]); + } + + toJSON(): object; + /** @hidden */ + toJSON(_: string, inputs: Map): object; + toJSON(_?: string, inputs?: Map): object { + return utils.toJSON(this, ['leadingCombinator', 'nodes'], inputs); + } + + append(...nodes: NewNodeForComplexSelector[]): this { + // TODO - postcss/postcss#1957: Mark this as dirty + this._nodes!.push(...this._normalizeList(nodes)); + return this; + } + + each( + callback: (node: ComplexSelectorComponent, 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: ComplexSelectorComponent, + index: number, + nodes: ReadonlyArray, + ) => boolean, + ): boolean { + return this.nodes.every(condition); + } + + index(child: ComplexSelectorComponent | number): number { + return typeof child === 'number' ? child : this.nodes.indexOf(child); + } + + insertAfter( + oldNode: ComplexSelectorComponent | number, + newNode: NewNodeForComplexSelector, + ): 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: ComplexSelectorComponent | number, + newNode: NewNodeForComplexSelector, + ): 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: NewNodeForComplexSelector[]): 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: ComplexSelectorComponent): 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: ComplexSelectorComponent | number): this { + // TODO - postcss/postcss#1957: Mark this as dirty + const index = this.index(child); + const argument = this._nodes![index]; + if (argument) argument.parent = undefined; + this._nodes!.splice(index, 1); + + for (const iterator of this.#iterators) { + if (iterator.index >= index) iterator.index--; + } + + return this; + } + + some( + condition: ( + node: ComplexSelectorComponent, + index: number, + nodes: ReadonlyArray, + ) => boolean, + ): boolean { + return this.nodes.some(condition); + } + + get first(): ComplexSelectorComponent | undefined { + return this.nodes[0]; + } + + get last(): ComplexSelectorComponent | undefined { + return this.nodes[this.nodes.length - 1]; + } + + /** @hidden */ + toString(): string { + let result = ''; + if (this.leadingCombinator) { + result += this.leadingCombinator + (this.raws.between ?? ' '); + } + + const rawComponents = this.raws.components; + for (let i = 0; i < this.nodes.length; i++) { + const component = this.nodes[i]; + const raw = rawComponents?.[i]; + result += component + (raw ?? (i < this.nodes.length - 1 ? ' ' : '')); + } + return result; + } + + /** + * Normalizes a single argument declaration or list of arguments. + */ + private _normalize( + nodes: NewNodeForComplexSelector, + ): ComplexSelectorComponent[] { + if (nodes === undefined) return []; + const normalized: ComplexSelectorComponent[] = []; + for (const node of Array.isArray(nodes) ? nodes : [nodes]) { + if (node === undefined) { + continue; + } else if ( + 'sassType' in node && + node.sassType === 'complex-selector-component' + ) { + node.parent = this; + normalized.push(node); + } else { + const constructed = new ComplexSelectorComponent(node); + constructed.parent = this; + normalized.push(constructed); + } + } + return normalized; + } + + /** Like {@link _normalize}, but also flattens a list of nodes. */ + private _normalizeList( + nodes: ReadonlyArray, + ): ComplexSelectorComponent[] { + const result: Array = []; + for (const node of nodes) { + result.push(...this._normalize(node)); + } + return result; + } + + /** @hidden */ + get nonStatementChildren(): ReadonlyArray> { + return this.nodes; + } +} diff --git a/pkg/sass-parser/lib/src/selector/compound.test.ts b/pkg/sass-parser/lib/src/selector/compound.test.ts new file mode 100644 index 000000000..8f061bc47 --- /dev/null +++ b/pkg/sass-parser/lib/src/selector/compound.test.ts @@ -0,0 +1,655 @@ +// 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 { + ClassSelector, + ComplexSelectorComponent, + CompoundSelector, + CompoundSelectorProps, +} from '../..'; +import * as utils from '../../../test/utils'; + +type EachFn = Parameters[0]; + +/** Parses `text` as a single compound selector. */ +function parse(text: string): CompoundSelector { + const list = utils.parseSelector(text); + expect(list.nodes).toHaveLength(1); + const complex = list.nodes[0]; + expect(complex.nodes).toHaveLength(1); + const component = complex.nodes[0]; + expect(component.combinator).toBeUndefined(); + return component.compound; +} + +/** Loads `props` as a compound selector. */ +function fromProps(props: CompoundSelectorProps): CompoundSelector { + return new ComplexSelectorComponent({compound: props}).compound; +} + +let node: CompoundSelector; +describe('a compound selector', () => { + describe('with one child', () => { + function describeNode( + description: string, + create: () => CompoundSelector, + ): void { + describe(description, () => { + beforeEach(() => void (node = create())); + + it('has sassType compound-selector', () => + expect(node.sassType).toBe('compound-selector')); + + it('has a child', () => { + expect(node.nodes).toHaveLength(1); + expect(node).toHaveNode(0, '.foo', 'class'); + }); + }); + } + + describeNode('parsed', () => parse('.foo')); + + describeNode( + 'constructed manually', + () => new CompoundSelector({nodes: [{class: 'foo'}]}), + ); + + describe('from props', () => { + describe('as an object', () => { + describeNode('with simple props', () => + fromProps({nodes: [{class: 'foo'}]}), + ); + + describeNode('with a full selector', () => + fromProps({nodes: [new ClassSelector({class: 'foo'})]}), + ); + }); + + describe('as an array', () => { + describeNode('with simple props', () => fromProps([{class: 'foo'}])); + + describeNode('with a full selector', () => + fromProps([new ClassSelector({class: 'foo'})]), + ); + }); + + describeNode('as simple props', () => fromProps({class: 'foo'})); + + describeNode('as a simple selector', () => + fromProps(new ClassSelector({class: 'foo'})), + ); + }); + }); + + describe('with multiple children', () => { + function describeNode( + description: string, + create: () => CompoundSelector, + ): void { + describe(description, () => { + beforeEach(() => void (node = create())); + + it('has sassType compound-selector', () => + expect(node.sassType).toBe('compound-selector')); + + it('has children', () => { + expect(node.nodes).toHaveLength(2); + expect(node).toHaveNode(0, '.foo', 'class'); + expect(node).toHaveNode(1, '.bar', 'class'); + }); + }); + } + + describeNode('parsed', () => parse('.foo.bar')); + + describeNode( + 'constructed manually', + () => new CompoundSelector({nodes: [{class: 'foo'}, {class: 'bar'}]}), + ); + + describe('from props', () => { + describe('as an object', () => { + describeNode('with simple props', () => + fromProps({nodes: [{class: 'foo'}, {class: 'bar'}]}), + ); + + describeNode('with a full selector', () => + fromProps({ + nodes: [ + new ClassSelector({class: 'foo'}), + new ClassSelector({class: 'bar'}), + ], + }), + ); + }); + + describe('as an array', () => { + describeNode('with simple props', () => + fromProps([{class: 'foo'}, {class: 'bar'}]), + ); + + describeNode('with a full selector', () => + fromProps([ + new ClassSelector({class: 'foo'}), + new ClassSelector({class: 'bar'}), + ]), + ); + }); + }); + }); + + describe('can add', () => { + beforeEach(() => void (node = new CompoundSelector())); + + it('a single selector', () => { + const classSelector = new ClassSelector({class: 'foo'}); + node.append(classSelector); + expect(node.nodes[0]).toBe(classSelector); + expect(classSelector.parent).toBe(node); + }); + + it('a list of selectors', () => { + const classSelector1 = new ClassSelector({class: 'foo'}); + const classSelector2 = new ClassSelector({class: 'bar'}); + node.append([classSelector1, classSelector2]); + expect(node.nodes[0]).toBe(classSelector1); + expect(node.nodes[1]).toBe(classSelector2); + expect(classSelector1.parent).toBe(node); + expect(classSelector2.parent).toBe(node); + }); + + it("a single selector's properties", () => { + node.append({class: 'foo'}); + expect(node).toHaveNode(0, '.foo'); + }); + + it('a list of properties', () => { + node.append([{class: 'foo'}, {class: 'bar'}]); + expect(node).toHaveNode(0, '.foo'); + expect(node).toHaveNode(1, '.bar'); + }); + + it('undefined', () => { + node.append(undefined); + expect(node.nodes).toHaveLength(0); + }); + }); + + describe('append', () => { + beforeEach( + () => + void (node = new CompoundSelector({ + nodes: [{class: 'foo'}, {class: 'bar'}], + })), + ); + + it('adds multiple children to the end', () => { + node.append({class: 'baz'}, {class: 'qux'}); + expect(node.nodes).toHaveLength(4); + expect(node).toHaveNode(0, '.foo'); + expect(node).toHaveNode(1, '.bar'); + expect(node).toHaveNode(2, '.baz'); + expect(node).toHaveNode(3, '.qux'); + }); + + it('can be called during iteration', () => + testEachMutation(['foo', 'bar', 'baz'], 0, () => + node.append({class: 'baz'}), + )); + + it('returns itself', () => expect(node.append()).toBe(node)); + }); + + describe('each', () => { + beforeEach( + () => + void (node = new CompoundSelector({ + nodes: [{class: 'foo'}, {class: 'bar'}], + })), + ); + + 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 CompoundSelector({ + nodes: [{class: 'foo'}, {class: 'bar'}, {class: 'baz'}], + })), + ); + + 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 as ClassSelector).class.asPlain !== 'bar', + ), + ).toBe(false)); + }); + + describe('index', () => { + beforeEach( + () => + void (node = new CompoundSelector({ + nodes: [ + {class: 'foo'}, + {class: 'bar'}, + {class: 'baz'}, + {class: 'qux'}, + ], + })), + ); + + it('returns the first index of a given selector', () => + 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 CompoundSelector({ + nodes: [{class: 'foo'}, {class: 'bar'}, {class: 'baz'}], + })), + ); + + it('inserts a node after the given element', () => { + node.insertAfter(node.nodes[1], {class: 'qux'}); + expect(node.nodes).toHaveLength(4); + expect(node).toHaveNode(0, '.foo'); + expect(node).toHaveNode(1, '.bar'); + expect(node).toHaveNode(2, '.qux'); + expect(node).toHaveNode(3, '.baz'); + }); + + it('inserts a node at the beginning', () => { + node.insertAfter(-1, {class: 'qux'}); + expect(node.nodes).toHaveLength(4); + expect(node).toHaveNode(0, '.qux'); + expect(node).toHaveNode(1, '.foo'); + expect(node).toHaveNode(2, '.bar'); + expect(node).toHaveNode(3, '.baz'); + }); + + it('inserts a node at the end', () => { + node.insertAfter(3, {class: 'qux'}); + expect(node.nodes).toHaveLength(4); + expect(node).toHaveNode(0, '.foo'); + expect(node).toHaveNode(1, '.bar'); + expect(node).toHaveNode(2, '.baz'); + expect(node).toHaveNode(3, '.qux'); + }); + + it('inserts multiple nodes', () => { + node.insertAfter(1, [{class: 'qux'}, {class: 'qax'}, {class: 'qix'}]); + expect(node.nodes).toHaveLength(6); + expect(node).toHaveNode(0, '.foo'); + expect(node).toHaveNode(1, '.bar'); + expect(node).toHaveNode(2, '.qux'); + expect(node).toHaveNode(3, '.qax'); + expect(node).toHaveNode(4, '.qix'); + expect(node).toHaveNode(5, '.baz'); + }); + + it('inserts before an iterator', () => + testEachMutation(['foo', 'bar', ['baz', 5]], 1, () => + node.insertAfter(0, [{class: 'qux'}, {class: 'qax'}, {class: 'qix'}]), + )); + + it('inserts after an iterator', () => + testEachMutation(['foo', 'bar', 'qux', 'qax', 'qix', 'baz'], 1, () => + node.insertAfter(1, [{class: 'qux'}, {class: 'qax'}, {class: 'qix'}]), + )); + + it('returns itself', () => + expect(node.insertAfter(node.nodes[0], {class: 'qux'})).toBe(node)); + }); + + describe('insertBefore', () => { + beforeEach( + () => + void (node = new CompoundSelector({ + nodes: [{class: 'foo'}, {class: 'bar'}, {class: 'baz'}], + })), + ); + + it('inserts a node before the given element', () => { + node.insertBefore(node.nodes[1], {class: 'qux'}); + expect(node.nodes).toHaveLength(4); + expect(node).toHaveNode(0, '.foo'); + expect(node).toHaveNode(1, '.qux'); + expect(node).toHaveNode(2, '.bar'); + expect(node).toHaveNode(3, '.baz'); + }); + + it('inserts a node at the beginning', () => { + node.insertBefore(0, {class: 'qux'}); + expect(node.nodes).toHaveLength(4); + expect(node).toHaveNode(0, '.qux'); + expect(node).toHaveNode(1, '.foo'); + expect(node).toHaveNode(2, '.bar'); + expect(node).toHaveNode(3, '.baz'); + }); + + it('inserts a node at the end', () => { + node.insertBefore(4, {class: 'qux'}); + expect(node.nodes).toHaveLength(4); + expect(node).toHaveNode(0, '.foo'); + expect(node).toHaveNode(1, '.bar'); + expect(node).toHaveNode(2, '.baz'); + expect(node).toHaveNode(3, '.qux'); + }); + + it('inserts multiple nodes', () => { + node.insertBefore(1, [{class: 'qux'}, {class: 'qax'}, {class: 'qix'}]); + expect(node.nodes).toHaveLength(6); + expect(node).toHaveNode(0, '.foo'); + expect(node).toHaveNode(1, '.qux'); + expect(node).toHaveNode(2, '.qax'); + expect(node).toHaveNode(3, '.qix'); + expect(node).toHaveNode(4, '.bar'); + expect(node).toHaveNode(5, '.baz'); + }); + + it('inserts before an iterator', () => + testEachMutation(['foo', 'bar', ['baz', 5]], 1, () => + node.insertBefore(1, [{class: 'qux'}, {class: 'qax'}, {class: 'qix'}]), + )); + + it('inserts after an iterator', () => + testEachMutation(['foo', 'bar', 'qux', 'qax', 'qix', 'baz'], 1, () => + node.insertBefore(2, [{class: 'qux'}, {class: 'qax'}, {class: 'qix'}]), + )); + + it('returns itself', () => + expect(node.insertBefore(node.nodes[0], {class: 'qux'})).toBe(node)); + }); + + describe('prepend', () => { + beforeEach( + () => + void (node = new CompoundSelector({ + nodes: [{class: 'foo'}, {class: 'bar'}, {class: 'baz'}], + })), + ); + + it('inserts one node', () => { + node.prepend({class: 'qux'}); + expect(node.nodes).toHaveLength(4); + expect(node).toHaveNode(0, '.qux'); + expect(node).toHaveNode(1, '.foo'); + expect(node).toHaveNode(2, '.bar'); + expect(node).toHaveNode(3, '.baz'); + }); + + it('inserts multiple nodes', () => { + node.prepend({class: 'qux'}, {class: 'qax'}, {class: 'qix'}); + expect(node.nodes).toHaveLength(6); + expect(node).toHaveNode(0, '.qux'); + expect(node).toHaveNode(1, '.qax'); + expect(node).toHaveNode(2, '.qix'); + expect(node).toHaveNode(3, '.foo'); + expect(node).toHaveNode(4, '.bar'); + expect(node).toHaveNode(5, '.baz'); + }); + + it('inserts before an iterator', () => + testEachMutation(['foo', 'bar', ['baz', 5]], 1, () => + node.prepend({class: 'qux'}, {class: 'qax'}, {class: 'qix'}), + )); + + it('returns itself', () => expect(node.prepend({class: 'qux'})).toBe(node)); + }); + + describe('push', () => { + beforeEach( + () => + void (node = new CompoundSelector({ + nodes: [{class: 'foo'}, {class: 'bar'}], + })), + ); + + it('inserts one node', () => { + node.push(new ClassSelector({class: 'baz'})); + expect(node.nodes).toHaveLength(3); + expect(node).toHaveNode(0, '.foo'); + expect(node).toHaveNode(1, '.bar'); + expect(node).toHaveNode(2, '.baz'); + }); + + it('can be called during iteration', () => + testEachMutation(['foo', 'bar', 'baz'], 0, () => + node.push(new ClassSelector({class: 'baz'})), + )); + + it('returns itself', () => + expect(node.push(new ClassSelector({class: 'baz'}))).toBe(node)); + }); + + describe('removeAll', () => { + beforeEach( + () => + void (node = new CompoundSelector({ + nodes: [{class: 'foo'}, {class: 'bar'}, {class: 'baz'}], + })), + ); + + it('removes all nodes', () => { + node.removeAll(); + expect(node.nodes).toHaveLength(0); + }); + + it("removes a node's parents", () => { + const classSelector = node.nodes[1]; + node.removeAll(); + expect(classSelector).toHaveProperty('parent', undefined); + }); + + it('can be called during iteration', () => + testEachMutation(['foo'], 0, () => node.removeAll())); + + it('returns itself', () => expect(node.removeAll()).toBe(node)); + }); + + describe('removeChild', () => { + beforeEach( + () => + void (node = new CompoundSelector({ + nodes: [{class: 'foo'}, {class: 'bar'}, {class: 'baz'}], + })), + ); + + 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).toHaveNode(0, '.foo'); + expect(node).toHaveNode(1, '.baz'); + }); + + 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', 1]], 1, () => + node.removeChild(1), + )); + + it('removes a node after the iterator', () => + testEachMutation(['foo', 'bar'], 1, () => node.removeChild(2))); + + it('returns itself', () => expect(node.removeChild(0)).toBe(node)); + }); + + describe('some', () => { + beforeEach( + () => + void (node = new CompoundSelector({ + nodes: [{class: 'foo'}, {class: 'bar'}, {class: 'baz'}], + })), + ); + + 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 as ClassSelector).class.asPlain === 'bar', + ), + ).toBe(true)); + }); + + describe('first', () => { + it('returns the first element', () => + expect( + new CompoundSelector({ + nodes: [{class: 'foo'}, {class: 'bar'}, {class: 'baz'}], + }), + ).toHaveNode('first', '.foo')); + + it('returns undefined for an empty selector', () => + expect(new CompoundSelector().first).toBeUndefined()); + }); + + describe('last', () => { + it('returns the last element', () => + expect( + new CompoundSelector({ + nodes: [{class: 'foo'}, {class: 'bar'}, {class: 'baz'}], + }), + ).toHaveNode('last', '.baz')); + + it('returns undefined for an empty selector', () => + expect(new CompoundSelector().last).toBeUndefined()); + }); + + describe('stringifies', () => { + it('with one child', () => expect(parse('.foo').toString()).toBe('.foo')); + + it('with multiple children', () => + expect(parse('.foo.bar.baz').toString()).toBe('.foo.bar.baz')); + }); + + describe('clone', () => { + let original: CompoundSelector; + + beforeEach(() => { + original = parse('.foo.bar'); + }); + + describe('with no overrides', () => { + let clone: CompoundSelector; + + beforeEach(() => void (clone = original.clone())); + + describe('has the same properties:', () => { + it('nodes', () => { + expect(clone.nodes).toHaveLength(2); + expect(clone).toHaveNode(0, '.foo'); + expect(clone).toHaveNode(1, '.bar'); + }); + + it('raws', () => expect(clone.raws).toEqual({})); + + it('source', () => expect(clone.source).toBe(original.source)); + }); + + describe('creates a new', () => { + it('self', () => expect(clone).not.toBe(original)); + + 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: [{class: 'zip'}]}); + expect(clone.nodes).toHaveLength(1); + expect(clone).toHaveNode(0, '.zip'); + }); + + it('undefined', () => { + const clone = original.clone({nodes: undefined}); + expect(clone.nodes).toHaveLength(2); + expect(clone).toHaveNode(0, '.foo'); + expect(clone).toHaveNode(1, '.bar'); + }); + }); + + describe('raws', () => { + it('defined', () => + expect(original.clone({raws: {}}).raws).toEqual({})); + + it('undefined', () => + expect(original.clone({raws: undefined}).raws).toEqual({})); + }); + }); + }); + + it('toJSON', () => expect(parse('.foo.bar')).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, number] | 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 [value, index] = Array.isArray(element) ? element : [element, i]; + expect(fn).toHaveBeenNthCalledWith( + i + 1, + expect.objectContaining({ + class: expect.objectContaining({asPlain: value}), + }), + index, + ); + } + expect(fn).toHaveBeenCalledTimes(elements.length); +} diff --git a/pkg/sass-parser/lib/src/selector/compound.ts b/pkg/sass-parser/lib/src/selector/compound.ts new file mode 100644 index 000000000..e9005b1ca --- /dev/null +++ b/pkg/sass-parser/lib/src/selector/compound.ts @@ -0,0 +1,292 @@ +// 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 {AnyNode, Node, NodeProps} from '../node'; +import * as sassInternal from '../sass-internal'; +import {AnyStatement} from '../statement'; +import * as utils from '../utils'; +import {AnySimpleSelector, SimpleSelectorProps} from '.'; +import {fromProps} from './from-props'; +import {convertSimpleSelector} from './convert'; + +/** + * The initializer properties for {@link CompoundSelector} passed as an options + * object. + * + * @category Selector + */ +export interface CompoundSelectorObjectProps extends NodeProps { + nodes: Array; + raws?: CompoundSelectorRaws; +} + +/** + * The initializer properties for {@link CompoundSelector}. + * + * @category Selector + */ +export type CompoundSelectorProps = + | CompoundSelectorObjectProps + | ReadonlyArray + | AnySimpleSelector + | SimpleSelectorProps; + +// TODO: Parse strings. +/** + * The type of new nodes that can be passed into a compound selector. + * + * @category Selector + */ +export type NewNodeForCompoundSelector = + | AnySimpleSelector + | ReadonlyArray + | SimpleSelectorProps + | ReadonlyArray + | undefined; + +/** + * Raws indicating how to precisely serialize an {@CompoundSelector}. + * + * @category Selector + */ +// eslint-disable-next-line @typescript-eslint/no-empty-interface -- No raws for a compound selector yet. +export interface CompoundSelectorRaws {} + +/** + * A compound selector. + * + * A compound selector is composed of {@link SimpleSelector}s. It matches an element + * that matches all of the component simple selectors. + * + * @category Selector + */ +export class CompoundSelector + extends Node + implements Container +{ + readonly sassType = 'compound-selector' as const; + declare raws: CompoundSelectorRaws; + + /** The simple selectors that comprise this selector. */ + 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 selector. 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?: CompoundSelectorProps); + /** @hidden */ + constructor(_: undefined, inner: sassInternal.CompoundSelector); + constructor(defaults?: object, inner?: sassInternal.CompoundSelector) { + if (Array.isArray(defaults)) { + defaults = {nodes: defaults}; + } else if (defaults && !('nodes' in defaults)) { + defaults = {nodes: [defaults]}; + } + + super(defaults); + this.nodes ??= []; + if (inner) { + this.source = new LazySource(inner); + for (const simple of inner.components) { + this.append(convertSimpleSelector(simple)); + } + } + } + + clone(overrides?: Partial): this { + return utils.cloneNode(this, overrides, ['raws', 'nodes']); + } + + toJSON(): object; + /** @hidden */ + toJSON(_: string, inputs: Map): object; + toJSON(_?: string, inputs?: Map): object { + return utils.toJSON(this, ['nodes'], inputs); + } + + append(...nodes: NewNodeForCompoundSelector[]): this { + // TODO - postcss/postcss#1957: Mark this as dirty + this._nodes!.push(...this._normalizeList(nodes)); + return this; + } + + each( + callback: (node: AnySimpleSelector, 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: AnySimpleSelector, + index: number, + nodes: ReadonlyArray, + ) => boolean, + ): boolean { + return this.nodes.every(condition); + } + + index(child: AnySimpleSelector | number): number { + return typeof child === 'number' ? child : this.nodes.indexOf(child); + } + + insertAfter( + oldNode: AnySimpleSelector | number, + newNode: NewNodeForCompoundSelector, + ): 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: AnySimpleSelector | number, + newNode: NewNodeForCompoundSelector, + ): 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: NewNodeForCompoundSelector[]): 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: AnySimpleSelector): 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: AnySimpleSelector | number): this { + // TODO - postcss/postcss#1957: Mark this as dirty + const index = this.index(child); + const argument = this._nodes![index]; + if (argument) argument.parent = undefined; + this._nodes!.splice(index, 1); + + for (const iterator of this.#iterators) { + if (iterator.index >= index) iterator.index--; + } + + return this; + } + + some( + condition: ( + node: AnySimpleSelector, + index: number, + nodes: ReadonlyArray, + ) => boolean, + ): boolean { + return this.nodes.some(condition); + } + + get first(): AnySimpleSelector | undefined { + return this.nodes[0]; + } + + get last(): AnySimpleSelector | undefined { + return this.nodes[this.nodes.length - 1]; + } + + /** @hidden */ + toString(): string { + return this.nodes.join(''); + } + + /** + * Normalizes a single argument declaration or list of arguments. + */ + private _normalize(nodes: NewNodeForCompoundSelector): AnySimpleSelector[] { + if (nodes === undefined) return []; + const normalized: AnySimpleSelector[] = []; + for (const node of Array.isArray(nodes) ? nodes : [nodes]) { + if (node === undefined) { + continue; + } else if ('sassType' in node) { + node.parent = this; + normalized.push(node); + } else { + const constructed = fromProps(node); + constructed.parent = this; + normalized.push(constructed); + } + } + return normalized; + } + + /** Like {@link _normalize}, but also flattens a list of nodes. */ + private _normalizeList( + nodes: ReadonlyArray, + ): AnySimpleSelector[] { + const result: Array = []; + for (const node of nodes) { + result.push(...this._normalize(node)); + } + return result; + } + + /** @hidden */ + get nonStatementChildren(): ReadonlyArray> { + return this.nodes; + } +} diff --git a/pkg/sass-parser/lib/src/selector/convert.ts b/pkg/sass-parser/lib/src/selector/convert.ts new file mode 100644 index 000000000..585bfcb6c --- /dev/null +++ b/pkg/sass-parser/lib/src/selector/convert.ts @@ -0,0 +1,33 @@ +// 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 sassInternal from '../sass-internal'; +import {AnySimpleSelector} from '.'; +import {AttributeSelector} from './attribute'; +import {ClassSelector} from './class'; +import {IDSelector} from './id'; +import {ParentSelector} from './parent'; +import {PlaceholderSelector} from './placeholder'; +import {PseudoSelector} from './pseudo'; +import {TypeSelector} from './type'; +import {UniversalSelector} from './universal'; + +/** The visitor to use to convert internal Sass nodes to JS. */ +const visitor = sassInternal.createSimpleSelectorVisitor({ + visitAttributeSelector: inner => new AttributeSelector(undefined, inner), + visitClassSelector: inner => new ClassSelector(undefined, inner), + visitIDSelector: inner => new IDSelector(undefined, inner), + visitParentSelector: inner => new ParentSelector(undefined, inner), + visitPlaceholderSelector: inner => new PlaceholderSelector(undefined, inner), + visitPseudoSelector: inner => new PseudoSelector(undefined, inner), + visitTypeSelector: inner => new TypeSelector(undefined, inner), + visitUniversalSelector: inner => new UniversalSelector(undefined, inner), +}); + +/** Converts an internal expression AST node into an external one. */ +export function convertSimpleSelector( + selector: sassInternal.SimpleSelector, +): AnySimpleSelector { + return selector.accept(visitor); +} diff --git a/pkg/sass-parser/lib/src/selector/from-props.ts b/pkg/sass-parser/lib/src/selector/from-props.ts new file mode 100644 index 000000000..a2dd4d3d6 --- /dev/null +++ b/pkg/sass-parser/lib/src/selector/from-props.ts @@ -0,0 +1,27 @@ +// 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 {AnySimpleSelector, SimpleSelectorProps} from '.'; +import {AttributeSelector} from './attribute'; +import {ClassSelector} from './class'; +import {IDSelector} from './id'; +import {ParentSelector} from './parent'; +import {PlaceholderSelector} from './placeholder'; +import {PseudoSelector} from './pseudo'; +import {TypeSelector} from './type'; +import {UniversalSelector} from './universal'; + +/** Constructs a simple selector from {@link SimpleSelectorProps}. */ +export function fromProps(props: SimpleSelectorProps): AnySimpleSelector { + if ('attribute' in props) return new AttributeSelector(props); + if ('class' in props) return new ClassSelector(props); + if ('id' in props) return new IDSelector(props); + if ('suffix' in props) return new ParentSelector(props); + if ('placeholder' in props) return new PlaceholderSelector(props); + if ('pseudo' in props) return new PseudoSelector(props); + if ('type' in props) return new TypeSelector(props); + if ('namespace' in props) return new UniversalSelector(props); + + throw new Error(`Unknown node type, keys: ${Object.keys(props)}`); +} diff --git a/pkg/sass-parser/lib/src/selector/id.test.ts b/pkg/sass-parser/lib/src/selector/id.test.ts new file mode 100644 index 000000000..a5473e9b2 --- /dev/null +++ b/pkg/sass-parser/lib/src/selector/id.test.ts @@ -0,0 +1,132 @@ +// 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 {IDSelector, Interpolation} from '../..'; +import { + fromSimpleSelectorProps, + parseSimpleSelector, +} from '../../../test/utils'; + +describe('an ID selector', () => { + let node: IDSelector; + + describe('without interpolation', () => { + function describeNode(description: string, create: () => IDSelector): void { + describe(description, () => { + beforeEach(() => void (node = create())); + + it('has sassType id', () => expect(node.sassType).toBe('id')); + + it('has an ID', () => expect(node).toHaveInterpolation('id', 'foo')); + }); + } + + describeNode('parsed', () => parseSimpleSelector('#foo')); + + describeNode('constructed manually', () => new IDSelector({id: 'foo'})); + + describeNode('from props', () => fromSimpleSelectorProps({id: 'foo'})); + }); + + describe('with interpolation', () => { + function describeNode(description: string, create: () => IDSelector): void { + describe(description, () => { + beforeEach(() => void (node = create())); + + it('has sassType id', () => expect(node.sassType).toBe('id')); + + it('has an ID', () => expect(node.id).toHaveStringExpression(0, 'foo')); + }); + } + + describeNode('parsed', () => parseSimpleSelector('##{foo}')); + + describeNode( + 'constructed manually', + () => new IDSelector({id: [{text: 'foo'}]}), + ); + + describeNode('from props', () => + fromSimpleSelectorProps({id: [{text: 'foo'}]}), + ); + }); + + describe('assigned new id', () => { + beforeEach(() => void (node = parseSimpleSelector('#foo'))); + + it("removes the old id's parent", () => { + const oldId = node.id; + node.id = 'bar'; + expect(oldId.parent).toBeUndefined(); + }); + + it('assigns id explicitly', () => { + const id = new Interpolation('bar'); + node.id = id; + expect(node.id).toBe(id); + expect(node).toHaveInterpolation('id', 'bar'); + }); + + it('assigns id as InterpolationProps', () => { + node.id = 'bar'; + expect(node).toHaveInterpolation('id', 'bar'); + }); + }); + + it('stringifies', () => + expect(parseSimpleSelector('#foo').toString()).toBe('#foo')); + + describe('clone', () => { + let original: IDSelector; + + beforeEach(() => { + original = parseSimpleSelector('#foo'); + }); + + describe('with no overrides', () => { + let clone: IDSelector; + + beforeEach(() => void (clone = original.clone())); + + describe('has the same properties:', () => { + it('id', () => expect(clone).toHaveInterpolation('id', 'foo')); + + it('raws', () => expect(clone.raws).toEqual({})); + + it('source', () => expect(clone.source).toBe(original.source)); + }); + + describe('creates a new', () => { + it('self', () => expect(clone).not.toBe(original)); + + for (const attr of ['id', 'raws'] as const) { + it(attr, () => expect(clone[attr]).not.toBe(original[attr])); + } + }); + }); + + describe('overrides', () => { + describe('id', () => { + it('defined', () => + expect(original.clone({id: 'bar'})).toHaveInterpolation('id', 'bar')); + + it('undefined', () => + expect(original.clone({id: undefined})).toHaveInterpolation( + 'id', + 'foo', + )); + }); + + describe('raws', () => { + it('defined', () => + expect(original.clone({raws: {}}).raws).toEqual({})); + + it('undefined', () => + expect(original.clone({raws: undefined}).raws).toEqual({})); + }); + }); + }); + + it('toJSON', () => expect(parseSimpleSelector('#foo')).toMatchSnapshot()); +}); diff --git a/pkg/sass-parser/lib/src/selector/id.ts b/pkg/sass-parser/lib/src/selector/id.ts new file mode 100644 index 000000000..4103efbaf --- /dev/null +++ b/pkg/sass-parser/lib/src/selector/id.ts @@ -0,0 +1,89 @@ +// 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 {Interpolation, InterpolationProps} from '../interpolation'; +import {LazySource} from '../lazy-source'; +import type {AnyNode, NodeProps} from '../node'; +import type {AnyStatement} from '../statement'; +import * as sassInternal from '../sass-internal'; +import * as utils from '../utils'; +import {SimpleSelector} from './index'; + +/** + * The initializer properties for {@link IDSelector}. + * + * @category Selector + */ +export interface IDSelectorProps extends NodeProps { + id: Interpolation | InterpolationProps; + raws?: IDSelectorRaws; +} + +/** + * Raws indicating how to precisely serialize an {@IDSelector}. + * + * @category Selector + */ +// eslint-disable-next-line @typescript-eslint/no-empty-interface -- No raws for an ID selector yet. +export interface IDSelectorRaws {} + +/** + * An ID selector. + * + * This selects elements whose `id` attribute contains an identifier with the + * given name. + * + * @category Selector + */ +export class IDSelector extends SimpleSelector { + readonly sassType = 'id' as const; + declare raws: IDSelectorRaws; + + /** The ID name that this selects. */ + get id(): Interpolation { + return this._id; + } + set id(id: Interpolation | InterpolationProps) { + if (this._id) this._id.parent = undefined; + const built = + typeof id === 'object' && 'sassType' in id ? id : new Interpolation(id); + built.parent = this; + this._id = built; + } + private declare _id: Interpolation; + + constructor(defaults: IDSelectorProps); + /** @hidden */ + constructor(_: undefined, inner: sassInternal.IDSelector); + constructor(defaults?: object, inner?: sassInternal.IDSelector) { + super(defaults); + if (inner) { + this.source = new LazySource(inner); + this.id = new Interpolation(undefined, inner.name); + } + } + + clone(overrides?: Partial): this { + return utils.cloneNode(this, overrides, ['raws', 'id']); + } + + toJSON(): object; + /** @hidden */ + toJSON(_: string, inputs: Map): object; + toJSON(_?: string, inputs?: Map): object { + return utils.toJSON(this, ['id'], inputs); + } + + /** @hidden */ + toString(): string { + return `#${this.id}`; + } + + /** @hidden */ + get nonStatementChildren(): ReadonlyArray> { + return [this.id]; + } +} diff --git a/pkg/sass-parser/lib/src/selector/index.ts b/pkg/sass-parser/lib/src/selector/index.ts new file mode 100644 index 000000000..fa1f617d4 --- /dev/null +++ b/pkg/sass-parser/lib/src/selector/index.ts @@ -0,0 +1,72 @@ +// 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 {Node} from '../node'; +import type {AttributeSelector, AttributeSelectorProps} from './attribute'; +import type {ClassSelector, ClassSelectorProps} from './class'; +import type {IDSelector, IDSelectorProps} from './id'; +import type {ParentSelector, ParentSelectorProps} from './parent'; +import type { + PlaceholderSelector, + PlaceholderSelectorProps, +} from './placeholder'; +import type {PseudoSelector, PseudoSelectorProps} from './pseudo'; +import type {TypeSelector, TypeSelectorProps} from './type'; +import type {UniversalSelector, UniversalSelectorProps} from './universal'; + +/** + * The union type of all Sass simple selectors. + * + * @category Selector + */ +export type AnySimpleSelector = + | AttributeSelector + | ClassSelector + | IDSelector + | ParentSelector + | PlaceholderSelector + | PseudoSelector + | TypeSelector + | UniversalSelector; + +/** + * Sass simple selector types. + * + * @category Selector + */ +export type SimpleSelectorType = + | 'attribute' + | 'class' + | 'id' + | 'parent' + | 'placeholder' + | 'pseudo' + | 'type' + | 'universal'; + +/** + * The union type of all properties that can be used to construct Sass + * simple selectors. + * + * @category Selector + */ +export type SimpleSelectorProps = + | AttributeSelectorProps + | ClassSelectorProps + | IDSelectorProps + | ParentSelectorProps + | PlaceholderSelectorProps + | PseudoSelectorProps + | TypeSelectorProps + | UniversalSelectorProps; + +/** + * The superclass of Sass simple selector nodes. + * + * @category Selector + */ +export abstract class SimpleSelector extends Node { + abstract readonly sassType: SimpleSelectorType; + abstract clone(overrides?: object): this; +} diff --git a/pkg/sass-parser/lib/src/selector/list.test.ts b/pkg/sass-parser/lib/src/selector/list.test.ts new file mode 100644 index 000000000..9c53dcb32 --- /dev/null +++ b/pkg/sass-parser/lib/src/selector/list.test.ts @@ -0,0 +1,817 @@ +// 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 { + ClassSelector, + ComplexSelector, + ComplexSelectorComponent, + CompoundSelector, + PseudoSelector, + SelectorList, + SelectorListProps, +} from '../..'; +import * as utils from '../../../test/utils'; + +type EachFn = Parameters[0]; + +/** Loads `props` as a selector list. */ +function fromProps(props: SelectorListProps): SelectorList { + return new PseudoSelector({pseudo: 'is', selector: props}).selector!; +} + +let node: SelectorList; +describe('a complex selector', () => { + describe('with one child', () => { + function describeNode( + description: string, + create: () => SelectorList, + ): void { + describe(description, () => { + beforeEach(() => void (node = create())); + + it('has sassType selector-list', () => + expect(node.sassType).toBe('selector-list')); + + it('has a child', () => { + expect(node.nodes).toHaveLength(1); + expect(node).toHaveNode(0, '.foo', 'complex-selector'); + }); + }); + } + + describeNode('parsed', () => utils.parseSelector('.foo')); + + describeNode( + 'constructed manually', + () => new SelectorList({nodes: [{class: 'foo'}]}), + ); + + describe('from props', () => { + describe('as an object', () => { + describeNode('with simple props', () => + fromProps({nodes: [{class: 'foo'}]}), + ); + + describeNode('with component props', () => + fromProps({nodes: [{compound: {class: 'foo'}}]}), + ); + + describeNode('with complex props', () => + fromProps({nodes: [{nodes: [{class: 'foo'}]}]}), + ); + + describeNode('with a full complex', () => + fromProps({nodes: [new ComplexSelector({class: 'foo'})]}), + ); + }); + + describe('as an array', () => { + describeNode('with simple props', () => fromProps([{class: 'foo'}])); + + describeNode('with component props', () => + fromProps([{compound: {class: 'foo'}}]), + ); + + describeNode('with complex props', () => + fromProps([{nodes: [{class: 'foo'}]}]), + ); + + describeNode('with a full complex', () => + fromProps([new ComplexSelector({class: 'foo'})]), + ); + }); + + describeNode('as simple props', () => fromProps({class: 'foo'})); + + describeNode('as component props', () => + fromProps({compound: {class: 'foo'}}), + ); + + describeNode('as a simple selector', () => + fromProps(new ClassSelector({class: 'foo'})), + ); + + describeNode('as a compound selector', () => + fromProps(new CompoundSelector({class: 'foo'})), + ); + + describeNode('as a component', () => + fromProps(new ComplexSelector({class: 'foo'})), + ); + }); + }); + + describe('with multiple children', () => { + function describeNode( + description: string, + create: () => SelectorList, + ): void { + describe(description, () => { + beforeEach(() => void (node = create())); + + it('has sassType selector-list', () => + expect(node.sassType).toBe('selector-list')); + + it('has children', () => { + expect(node.nodes).toHaveLength(2); + expect(node).toHaveNode(0, '.foo'); + expect(node).toHaveNode(1, '.bar'); + }); + }); + } + + describeNode('parsed', () => utils.parseSelector('.foo, .bar')); + + describeNode( + 'constructed manually', + () => new SelectorList({nodes: [{class: 'foo'}, {class: 'bar'}]}), + ); + + describe('from props', () => { + describe('as an object', () => { + describeNode('with simple props', () => + fromProps({nodes: [{class: 'foo'}, {class: 'bar'}]}), + ); + + describeNode('with full complexes', () => + fromProps({ + nodes: [ + new ComplexSelector({class: 'foo'}), + new ComplexSelector({class: 'bar'}), + ], + }), + ); + }); + + describe('as an array', () => { + describeNode('with simple props', () => + fromProps([{class: 'foo'}, {class: 'bar'}]), + ); + + describeNode('with full complexes', () => + fromProps([ + new ComplexSelector({class: 'foo'}), + new ComplexSelector({class: 'bar'}), + ]), + ); + }); + }); + }); + + describe('can add', () => { + beforeEach(() => void (node = new SelectorList())); + + it('a single complex', () => { + const complex = new ComplexSelector({class: 'foo'}); + node.append(complex); + expect(node.nodes[0]).toBe(complex); + expect(complex.parent).toBe(node); + }); + + it('a list of selectors', () => { + const complex1 = new ComplexSelector({class: 'foo'}); + const complex2 = new ComplexSelector({class: 'bar'}); + node.append([complex1, complex2]); + expect(node.nodes[0]).toBe(complex1); + expect(node.nodes[1]).toBe(complex2); + expect(complex1.parent).toBe(node); + expect(complex2.parent).toBe(node); + }); + + it("a simple selector's properties", () => { + node.append({class: 'foo'}); + expect(node).toHaveNode(0, '.foo'); + }); + + it('a simple selector', () => { + node.append(new ClassSelector({class: 'foo'})); + expect(node).toHaveNode(0, '.foo'); + }); + + it('a compound selector', () => { + node.append(new CompoundSelector({class: 'foo'})); + expect(node).toHaveNode(0, '.foo'); + }); + + it("a complex component's properties", () => { + node.append({compound: {class: 'foo'}}); + expect(node).toHaveNode(0, '.foo'); + }); + + it('a complex component', () => { + node.append(new ComplexSelectorComponent({class: 'foo'})); + expect(node).toHaveNode(0, '.foo'); + }); + + it("a complex selector's properties", () => { + node.append({nodes: [{class: 'foo'}]}); + expect(node).toHaveNode(0, '.foo'); + }); + + it('a complex selector', () => { + node.append(new ComplexSelector({class: 'foo'})); + expect(node).toHaveNode(0, '.foo'); + }); + + it('a list of properties', () => { + node.append([{class: 'foo'}, {class: 'bar'}]); + expect(node).toHaveNode(0, '.foo'); + expect(node).toHaveNode(1, '.bar'); + }); + + it('undefined', () => { + node.append(undefined); + expect(node.nodes).toHaveLength(0); + }); + }); + + describe('append', () => { + beforeEach( + () => + void (node = new SelectorList({ + nodes: [{class: 'foo'}, {class: 'bar'}], + })), + ); + + it('adds multiple children to the end', () => { + node.append({class: 'baz'}, {class: 'qux'}); + expect(node.nodes).toHaveLength(4); + expect(node).toHaveNode(0, '.foo'); + expect(node).toHaveNode(1, '.bar'); + expect(node).toHaveNode(2, '.baz'); + expect(node).toHaveNode(3, '.qux'); + }); + + it('can be called during iteration', () => + testEachMutation(['.foo', '.bar', '.baz'], 0, () => + node.append({class: 'baz'}), + )); + + it('returns itself', () => expect(node.append()).toBe(node)); + }); + + describe('each', () => { + beforeEach( + () => + void (node = new SelectorList({ + nodes: [{class: 'foo'}, {class: 'bar'}], + })), + ); + + 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 SelectorList({ + nodes: [{class: 'foo'}, {class: 'bar'}, {class: 'baz'}], + })), + ); + + 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.toString() !== '.bar')).toBe(false)); + }); + + describe('index', () => { + beforeEach( + () => + void (node = new SelectorList({ + nodes: [ + {class: 'foo'}, + {class: 'bar'}, + {class: 'baz'}, + {class: 'qux'}, + ], + })), + ); + + it('returns the first index of a given selector', () => + 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 SelectorList({ + nodes: [{class: 'foo'}, {class: 'bar'}, {class: 'baz'}], + })), + ); + + it('inserts a node after the given element', () => { + node.insertAfter(node.nodes[1], {class: 'qux'}); + expect(node.nodes).toHaveLength(4); + expect(node).toHaveNode(0, '.foo'); + expect(node).toHaveNode(1, '.bar'); + expect(node).toHaveNode(2, '.qux'); + expect(node).toHaveNode(3, '.baz'); + }); + + it('inserts a node at the beginning', () => { + node.insertAfter(-1, {class: 'qux'}); + expect(node.nodes).toHaveLength(4); + expect(node).toHaveNode(0, '.qux'); + expect(node).toHaveNode(1, '.foo'); + expect(node).toHaveNode(2, '.bar'); + expect(node).toHaveNode(3, '.baz'); + }); + + it('inserts a node at the end', () => { + node.insertAfter(3, {class: 'qux'}); + expect(node.nodes).toHaveLength(4); + expect(node).toHaveNode(0, '.foo'); + expect(node).toHaveNode(1, '.bar'); + expect(node).toHaveNode(2, '.baz'); + expect(node).toHaveNode(3, '.qux'); + }); + + it('inserts multiple nodes', () => { + node.insertAfter(1, [{class: 'qux'}, {class: 'qax'}, {class: 'qix'}]); + expect(node.nodes).toHaveLength(6); + expect(node).toHaveNode(0, '.foo'); + expect(node).toHaveNode(1, '.bar'); + expect(node).toHaveNode(2, '.qux'); + expect(node).toHaveNode(3, '.qax'); + expect(node).toHaveNode(4, '.qix'); + expect(node).toHaveNode(5, '.baz'); + }); + + it('inserts before an iterator', () => + testEachMutation(['.foo', '.bar', ['.baz', 5]], 1, () => + node.insertAfter(0, [{class: 'qux'}, {class: 'qax'}, {class: 'qix'}]), + )); + + it('inserts after an iterator', () => + testEachMutation( + ['.foo', '.bar', '.qux', '.qax', '.qix', '.baz'], + 1, + () => + node.insertAfter(1, [{class: 'qux'}, {class: 'qax'}, {class: 'qix'}]), + )); + + it('returns itself', () => + expect(node.insertAfter(node.nodes[0], {class: 'qux'})).toBe(node)); + }); + + describe('insertBefore', () => { + beforeEach( + () => + void (node = new SelectorList({ + nodes: [{class: 'foo'}, {class: 'bar'}, {class: 'baz'}], + })), + ); + + it('inserts a node before the given element', () => { + node.insertBefore(node.nodes[1], {class: 'qux'}); + expect(node.nodes).toHaveLength(4); + expect(node).toHaveNode(0, '.foo'); + expect(node).toHaveNode(1, '.qux'); + expect(node).toHaveNode(2, '.bar'); + expect(node).toHaveNode(3, '.baz'); + }); + + it('inserts a node at the beginning', () => { + node.insertBefore(0, {class: 'qux'}); + expect(node.nodes).toHaveLength(4); + expect(node).toHaveNode(0, '.qux'); + expect(node).toHaveNode(1, '.foo'); + expect(node).toHaveNode(2, '.bar'); + expect(node).toHaveNode(3, '.baz'); + }); + + it('inserts a node at the end', () => { + node.insertBefore(4, {class: 'qux'}); + expect(node.nodes).toHaveLength(4); + expect(node).toHaveNode(0, '.foo'); + expect(node).toHaveNode(1, '.bar'); + expect(node).toHaveNode(2, '.baz'); + expect(node).toHaveNode(3, '.qux'); + }); + + it('inserts multiple nodes', () => { + node.insertBefore(1, [{class: 'qux'}, {class: 'qax'}, {class: 'qix'}]); + expect(node.nodes).toHaveLength(6); + expect(node).toHaveNode(0, '.foo'); + expect(node).toHaveNode(1, '.qux'); + expect(node).toHaveNode(2, '.qax'); + expect(node).toHaveNode(3, '.qix'); + expect(node).toHaveNode(4, '.bar'); + expect(node).toHaveNode(5, '.baz'); + }); + + it('inserts before an iterator', () => + testEachMutation(['.foo', '.bar', ['.baz', 5]], 1, () => + node.insertBefore(1, [{class: 'qux'}, {class: 'qax'}, {class: 'qix'}]), + )); + + it('inserts after an iterator', () => + testEachMutation( + ['.foo', '.bar', '.qux', '.qax', '.qix', '.baz'], + 1, + () => + node.insertBefore(2, [ + {class: 'qux'}, + {class: 'qax'}, + {class: 'qix'}, + ]), + )); + + it('returns itself', () => + expect(node.insertBefore(node.nodes[0], {class: 'qux'})).toBe(node)); + }); + + describe('prepend', () => { + beforeEach( + () => + void (node = new SelectorList({ + nodes: [{class: 'foo'}, {class: 'bar'}, {class: 'baz'}], + })), + ); + + it('inserts one node', () => { + node.prepend({class: 'qux'}); + expect(node.nodes).toHaveLength(4); + expect(node).toHaveNode(0, '.qux'); + expect(node).toHaveNode(1, '.foo'); + expect(node).toHaveNode(2, '.bar'); + expect(node).toHaveNode(3, '.baz'); + }); + + it('inserts multiple nodes', () => { + node.prepend({class: 'qux'}, {class: 'qax'}, {class: 'qix'}); + expect(node.nodes).toHaveLength(6); + expect(node).toHaveNode(0, '.qux'); + expect(node).toHaveNode(1, '.qax'); + expect(node).toHaveNode(2, '.qix'); + expect(node).toHaveNode(3, '.foo'); + expect(node).toHaveNode(4, '.bar'); + expect(node).toHaveNode(5, '.baz'); + }); + + it('inserts before an iterator', () => + testEachMutation(['.foo', '.bar', ['.baz', 5]], 1, () => + node.prepend({class: 'qux'}, {class: 'qax'}, {class: 'qix'}), + )); + + it('returns itself', () => expect(node.prepend({class: 'qux'})).toBe(node)); + }); + + describe('push', () => { + beforeEach( + () => + void (node = new SelectorList({ + nodes: [{class: 'foo'}, {class: 'bar'}], + })), + ); + + it('inserts one node', () => { + node.push(new ComplexSelector({class: 'baz'})); + expect(node.nodes).toHaveLength(3); + expect(node).toHaveNode(0, '.foo'); + expect(node).toHaveNode(1, '.bar'); + expect(node).toHaveNode(2, '.baz'); + }); + + it('can be called during iteration', () => + testEachMutation(['.foo', '.bar', '.baz'], 0, () => + node.push(new ComplexSelector({class: 'baz'})), + )); + + it('returns itself', () => + expect(node.push(new ComplexSelector({class: 'baz'}))).toBe(node)); + }); + + describe('removeAll', () => { + beforeEach( + () => + void (node = new SelectorList({ + nodes: [{class: 'foo'}, {class: 'bar'}, {class: 'baz'}], + })), + ); + + it('removes all nodes', () => { + node.removeAll(); + expect(node.nodes).toHaveLength(0); + }); + + it("removes a node's parents", () => { + const complex = node.nodes[1]; + node.removeAll(); + expect(complex.parent).toBeUndefined(); + }); + + it('can be called during iteration', () => + testEachMutation(['.foo'], 0, () => node.removeAll())); + + it('returns itself', () => expect(node.removeAll()).toBe(node)); + }); + + describe('removeChild', () => { + beforeEach( + () => + void (node = new SelectorList({ + nodes: [{class: 'foo'}, {class: 'bar'}, {class: 'baz'}], + })), + ); + + 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).toHaveNode(0, '.foo'); + expect(node).toHaveNode(1, '.baz'); + }); + + 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', 1]], 1, () => + node.removeChild(1), + )); + + it('removes a node after the iterator', () => + testEachMutation(['.foo', '.bar'], 1, () => node.removeChild(2))); + + it('returns itself', () => expect(node.removeChild(0)).toBe(node)); + }); + + describe('some', () => { + beforeEach( + () => + void (node = new SelectorList({ + nodes: [{class: 'foo'}, {class: 'bar'}, {class: 'baz'}], + })), + ); + + 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.toString() === '.bar')).toBe(true)); + }); + + describe('first', () => { + it('returns the first element', () => + expect( + new SelectorList({ + nodes: [{class: 'foo'}, {class: 'bar'}, {class: 'baz'}], + }), + ).toHaveNode('first', '.foo')); + + it('returns undefined for an empty selector', () => + expect(new SelectorList().first).toBeUndefined()); + }); + + describe('last', () => { + it('returns the last element', () => + expect( + new SelectorList({ + nodes: [{class: 'foo'}, {class: 'bar'}, {class: 'baz'}], + }), + ).toHaveNode('last', '.baz')); + + it('returns undefined for an empty selector', () => + expect(new SelectorList().last).toBeUndefined()); + }); + + describe('stringifies', () => { + describe('with one child', () => { + beforeEach(() => { + node = new SelectorList({class: 'foo'}); + }); + + it('with no raws', () => expect(node.toString()).toBe('.foo')); + + describe('with one complex raw', () => { + it('with before', () => { + node.raws.complexes = [{before: ' '}]; + expect(node.toString()).toBe(' .foo'); + }); + + it('with after', () => { + node.raws.complexes = [{after: ' '}]; + expect(node.toString()).toBe('.foo '); + }); + + it('with both', () => { + node.raws.complexes = [{before: ' ', after: '/**/'}]; + expect(node.toString()).toBe(' .foo/**/'); + }); + }); + + it('ignores extra complex raws', () => { + node.raws.complexes = [undefined, {before: ' ', after: '/**/'}]; + expect(node.toString()).toBe('.foo'); + }); + }); + + describe('with multiple children', () => { + beforeEach(() => { + node = new SelectorList([{class: 'foo'}, {class: 'bar'}]); + }); + + it('with no raws', () => expect(node.toString()).toBe('.foo, .bar')); + + describe('with one complex raw at the beginning', () => { + describe('at the beginning', () => { + it('before', () => { + node.raws.complexes = [{before: ' '}]; + expect(node.toString()).toBe(' .foo, .bar'); + }); + + it('after', () => { + node.raws.complexes = [{after: ' '}]; + expect(node.toString()).toBe('.foo , .bar'); + }); + + it('both', () => { + node.raws.complexes = [{before: ' ', after: '/**/'}]; + expect(node.toString()).toBe(' .foo/**/, .bar'); + }); + }); + + describe('at the end', () => { + it('before', () => { + node.raws.complexes = [undefined, {before: ' '}]; + expect(node.toString()).toBe('.foo, .bar'); + }); + + it('after', () => { + node.raws.complexes = [undefined, {after: ' '}]; + expect(node.toString()).toBe('.foo, .bar '); + }); + + it('both', () => { + node.raws.complexes = [undefined, {before: ' ', after: '/**/'}]; + expect(node.toString()).toBe('.foo, .bar/**/'); + }); + }); + + describe('in the middle', () => { + beforeEach(() => { + node = new SelectorList([ + {class: 'foo'}, + {class: 'bar'}, + {class: 'baz'}, + ]); + }); + + it('before', () => { + node.raws.complexes = [undefined, {before: ' '}]; + expect(node.toString()).toBe('.foo, .bar, .baz'); + }); + + it('after', () => { + node.raws.complexes = [undefined, {after: ' '}]; + expect(node.toString()).toBe('.foo, .bar , .baz'); + }); + + it('both', () => { + node.raws.complexes = [undefined, {before: ' ', after: '/**/'}]; + expect(node.toString()).toBe('.foo, .bar/**/, .baz'); + }); + }); + }); + + it('with the same number of compound raws', () => { + node.raws.complexes = [ + {before: ' ', after: '/**/'}, + {before: '/* */', after: '/***/'}, + ]; + expect(node.toString()).toBe(' .foo/**/,/* */.bar/***/'); + }); + + it('with too many compound raws', () => { + node.raws.complexes = [ + {before: ' ', after: '/**/'}, + {before: '/* */', after: '/***/'}, + {before: '\t', after: '\t'}, + ]; + expect(node.toString()).toBe(' .foo/**/,/* */.bar/***/'); + }); + }); + }); + + describe('clone', () => { + let original: SelectorList; + + beforeEach(() => { + original = utils.parseSelector('.foo, .bar'); + }); + + describe('with no overrides', () => { + let clone: SelectorList; + + beforeEach(() => void (clone = original.clone())); + + describe('has the same properties:', () => { + it('nodes', () => { + expect(clone.nodes).toHaveLength(2); + expect(clone).toHaveNode(0, '.foo'); + expect(clone).toHaveNode(1, '.bar'); + }); + + it('raws', () => expect(clone.raws).toEqual({})); + + it('source', () => expect(clone.source).toBe(original.source)); + }); + + describe('creates a new', () => { + it('self', () => expect(clone).not.toBe(original)); + + 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: [{class: 'zip'}]}); + expect(clone.nodes).toHaveLength(1); + expect(clone).toHaveNode(0, '.zip'); + }); + + it('undefined', () => { + const clone = original.clone({nodes: undefined}); + expect(clone.nodes).toHaveLength(2); + expect(clone).toHaveNode(0, '.foo'); + expect(clone).toHaveNode(1, '.bar'); + }); + }); + + describe('raws', () => { + it('defined', () => + expect(original.clone({raws: {}}).raws).toEqual({})); + + it('undefined', () => + expect(original.clone({raws: undefined}).raws).toEqual({})); + }); + }); + }); + + it('toJSON', () => + expect(utils.parseSelector('.foo, .bar')).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, number] | 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 [value, index] = Array.isArray(element) ? element : [element, i]; + expect(fn).toHaveBeenNthCalledWith( + i + 1, + expect.nodeWithToString(value), + index, + ); + } + expect(fn).toHaveBeenCalledTimes(elements.length); +} diff --git a/pkg/sass-parser/lib/src/selector/list.ts b/pkg/sass-parser/lib/src/selector/list.ts new file mode 100644 index 000000000..d33b1c517 --- /dev/null +++ b/pkg/sass-parser/lib/src/selector/list.ts @@ -0,0 +1,322 @@ +// 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 {AnyNode, Node, NodeProps} from '../node'; +import * as sassInternal from '../sass-internal'; +import {AnyStatement} from '../statement'; +import * as utils from '../utils'; +import {ComplexSelector, ComplexSelectorProps} from './complex'; + +/** + * The initializer properties for {@link SelectorList} passed as an options + * object. + * + * @category Selector + */ +export interface SelectorListObjectProps extends NodeProps { + nodes: Array; + raws?: SelectorListRaws; +} + +/** + * The initializer properties for {@link SelectorList}. + * + * @category Selector + */ +export type SelectorListProps = + | SelectorListObjectProps + | ReadonlyArray + | ComplexSelector + | ComplexSelectorProps; + +// TODO: Parse strings. +/** + * The type of new nodes that can be passed into a complex selector. + * + * @category Selector + */ +export type NewNodeForSelectorList = + | ComplexSelector + | ReadonlyArray + | ComplexSelectorProps + | ReadonlyArray + | undefined; + +/** + * Raws indicating how to precisely serialize an {@SelectorList}. + * + * @category Selector + */ +export interface SelectorListRaws { + /** + * The whitespace before and after each complex selector in the list. + * + * `before` is the whitespace between the previous comma and the complex + * selector and `after` is the whitespace between the complex selector and the + * next comma. `before` is never set by default for the first complex + * selector, and `after` is never set by default for the last one. + */ + complexes?: Array<{before?: string; after?: string} | undefined>; +} + +/** + * A selector list. + * + * A selector list is composed of {@link ComplexSelector}s. It matches any + * element that matches any of the component selectors. + * + * @category Selector + */ +export class SelectorList + extends Node + implements Container +{ + readonly sassType = 'selector-list' as const; + declare raws: SelectorListRaws; + + /** The components that comprise this selector. */ + 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 selector. 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?: SelectorListProps); + /** @hidden */ + constructor(_: undefined, inner: sassInternal.SelectorList); + constructor(defaults?: object, inner?: sassInternal.SelectorList) { + if (defaults) { + if ( + !Array.isArray(defaults) && + 'nodes' in defaults && + !('sassType' in defaults) + ) { + defaults.nodes = [defaults.nodes]; + } else { + // Wrap an array in an extra array because PostCSS calls + // append(...nodes). This ensures that the array is processed, as a + // unit, by [_normalize]. This in turn means that an array of arrays is + // processed as a single complex. + defaults = {nodes: [defaults]}; + } + } + + super(defaults); + this.nodes ??= []; + if (inner) { + this.source = new LazySource(inner); + this.nodes = []; + for (const complex of inner.components) { + this.append(new ComplexSelector(undefined, complex)); + } + } + } + + clone(overrides?: Partial): this { + return utils.cloneNode(this, overrides, ['raws', 'nodes']); + } + + toJSON(): object; + /** @hidden */ + toJSON(_: string, inputs: Map): object; + toJSON(_?: string, inputs?: Map): object { + return utils.toJSON(this, ['nodes'], inputs); + } + + append(...nodes: NewNodeForSelectorList[]): this { + // TODO - postcss/postcss#1957: Mark this as dirty + this._nodes!.push(...this._normalizeList(nodes)); + return this; + } + + each( + callback: (node: ComplexSelector, 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: ComplexSelector, + index: number, + nodes: ReadonlyArray, + ) => boolean, + ): boolean { + return this.nodes.every(condition); + } + + index(child: ComplexSelector | number): number { + return typeof child === 'number' ? child : this.nodes.indexOf(child); + } + + insertAfter( + oldNode: ComplexSelector | number, + newNode: NewNodeForSelectorList, + ): 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: ComplexSelector | number, + newNode: NewNodeForSelectorList, + ): 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: NewNodeForSelectorList[]): 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: ComplexSelector): 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: ComplexSelector | number): this { + // TODO - postcss/postcss#1957: Mark this as dirty + const index = this.index(child); + const argument = this._nodes![index]; + if (argument) argument.parent = undefined; + this._nodes!.splice(index, 1); + + for (const iterator of this.#iterators) { + if (iterator.index >= index) iterator.index--; + } + + return this; + } + + some( + condition: ( + node: ComplexSelector, + index: number, + nodes: ReadonlyArray, + ) => boolean, + ): boolean { + return this.nodes.some(condition); + } + + get first(): ComplexSelector | undefined { + return this.nodes[0]; + } + + get last(): ComplexSelector | undefined { + return this.nodes[this.nodes.length - 1]; + } + + /** @hidden */ + toString(): string { + let result = ''; + + const rawComplexes = this.raws.complexes; + for (let i = 0; i < this.nodes.length; i++) { + const element = this.nodes[i]; + const raw = rawComplexes?.[i]; + result += raw?.before ?? (i > 0 ? ' ' : ''); + result += element; + result += raw?.after ?? ''; + result += i < this.nodes.length - 1 ? ',' : ''; + } + + return result; + } + + /** + * Normalizes a single argument declaration or list of arguments. + */ + private _normalize(nodes: NewNodeForSelectorList): ComplexSelector[] { + if (nodes === undefined) return []; + const normalized: ComplexSelector[] = []; + for (const node of Array.isArray(nodes) ? nodes : [nodes]) { + if (node === undefined) { + continue; + } else if ('sassType' in node && node.sassType === 'complex-selector') { + node.parent = this; + normalized.push(node); + } else { + const constructed = new ComplexSelector(node); + constructed.parent = this; + normalized.push(constructed); + } + } + return normalized; + } + + /** Like {@link _normalize}, but also flattens a list of nodes. */ + private _normalizeList( + nodes: ReadonlyArray, + ): ComplexSelector[] { + const result: Array = []; + for (const node of nodes) { + result.push(...this._normalize(node)); + } + return result; + } + + /** @hidden */ + get nonStatementChildren(): ReadonlyArray> { + return this.nodes; + } +} diff --git a/pkg/sass-parser/lib/src/selector/parent.test.ts b/pkg/sass-parser/lib/src/selector/parent.test.ts new file mode 100644 index 000000000..f214a2b6e --- /dev/null +++ b/pkg/sass-parser/lib/src/selector/parent.test.ts @@ -0,0 +1,178 @@ +// 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 {Interpolation, ParentSelector} from '../..'; +import { + fromSimpleSelectorProps, + parseSimpleSelector, +} from '../../../test/utils'; + +describe('a parent selector', () => { + let node: ParentSelector; + + describe('without a suffix', () => { + function describeNode( + description: string, + create: () => ParentSelector, + ): void { + describe(description, () => { + beforeEach(() => void (node = create())); + + it('has sassType parent', () => expect(node.sassType).toBe('parent')); + + it('has no suffix', () => expect(node.suffix).toBeUndefined()); + }); + } + + describeNode('parsed', () => parseSimpleSelector('&')); + + describeNode('constructed manually', () => new ParentSelector()); + + describeNode('from props', () => + fromSimpleSelectorProps({suffix: undefined}), + ); + }); + + describe('without interpolation', () => { + function describeNode( + description: string, + create: () => ParentSelector, + ): void { + describe(description, () => { + beforeEach(() => void (node = create())); + + it('has sassType parent', () => expect(node.sassType).toBe('parent')); + + it('has a suffix', () => + expect(node).toHaveInterpolation('suffix', 'foo')); + }); + } + + describeNode('parsed', () => parseSimpleSelector('&foo')); + + describeNode( + 'constructed manually', + () => new ParentSelector({suffix: 'foo'}), + ); + + describeNode('from props', () => fromSimpleSelectorProps({suffix: 'foo'})); + }); + + describe('with interpolation', () => { + function describeNode( + description: string, + create: () => ParentSelector, + ): void { + describe(description, () => { + beforeEach(() => void (node = create())); + + it('has sassType parent', () => expect(node.sassType).toBe('parent')); + + it('has a suffix', () => + expect(node.suffix).toHaveStringExpression(0, 'foo')); + }); + } + + describeNode('parsed', () => parseSimpleSelector('&#{foo}')); + + describeNode( + 'constructed manually', + () => new ParentSelector({suffix: [{text: 'foo'}]}), + ); + + describeNode('from props', () => + fromSimpleSelectorProps({suffix: [{text: 'foo'}]}), + ); + }); + + describe('assigned new suffix', () => { + beforeEach(() => void (node = parseSimpleSelector('&foo'))); + + it("removes the old suffix's parent", () => { + const oldSuffix = node.suffix; + node.suffix = 'bar'; + expect(oldSuffix!.parent).toBeUndefined(); + }); + + it('assigns suffix explicitly', () => { + const suffix = new Interpolation('bar'); + node.suffix = suffix; + expect(node.suffix).toBe(suffix); + expect(node).toHaveInterpolation('suffix', 'bar'); + }); + + it('assigns suffix as InterpolationProps', () => { + node.suffix = 'bar'; + expect(node).toHaveInterpolation('suffix', 'bar'); + }); + + it('assigns undefined suffix', () => { + const oldSuffix = node.suffix; + node.suffix = undefined; + expect(oldSuffix!.parent).toBeUndefined(); + expect(node.suffix).toBeUndefined(); + }); + }); + + describe('stringifies', () => { + it('with no suffix', () => + expect(parseSimpleSelector('&').toString()).toBe('&')); + + it('with a suffix', () => + expect(parseSimpleSelector('&foo').toString()).toBe('&foo')); + }); + + describe('clone', () => { + let original: ParentSelector; + + beforeEach(() => { + original = parseSimpleSelector('&foo'); + }); + + describe('with no overrides', () => { + let clone: ParentSelector; + + beforeEach(() => void (clone = original.clone())); + + describe('has the same properties:', () => { + it('suffix', () => expect(clone).toHaveInterpolation('suffix', 'foo')); + + it('raws', () => expect(clone.raws).toEqual({})); + + it('source', () => expect(clone.source).toBe(original.source)); + }); + + it('creates a new self', () => expect(clone).not.toBe(original)); + }); + + describe('overrides', () => { + describe('suffix', () => { + it('defined', () => + expect(original.clone({suffix: 'bar'})).toHaveInterpolation( + 'suffix', + 'bar', + )); + + it('undefined', () => + expect(original.clone({suffix: undefined}).suffix).toBeUndefined()); + }); + + describe('raws', () => { + it('defined', () => + expect(original.clone({raws: {}}).raws).toEqual({})); + + it('undefined', () => + expect(original.clone({raws: undefined}).raws).toEqual({})); + }); + }); + }); + + describe('toJSON', () => { + it('with no suffix', () => + expect(parseSimpleSelector('&')).toMatchSnapshot()); + + it('with a suffix', () => + expect(parseSimpleSelector('&foo')).toMatchSnapshot()); + }); +}); diff --git a/pkg/sass-parser/lib/src/selector/parent.ts b/pkg/sass-parser/lib/src/selector/parent.ts new file mode 100644 index 000000000..52745f57a --- /dev/null +++ b/pkg/sass-parser/lib/src/selector/parent.ts @@ -0,0 +1,105 @@ +// 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 {Interpolation, InterpolationProps} from '../interpolation'; +import {LazySource} from '../lazy-source'; +import type {AnyNode, NodeProps} from '../node'; +import type {AnyStatement} from '../statement'; +import * as sassInternal from '../sass-internal'; +import * as utils from '../utils'; +import {SimpleSelector} from './index'; + +/** + * The initializer properties for {@link ParentSelector}. + * + * @category Selector + */ +export interface ParentSelectorProps extends NodeProps { + // We can't make this optional because that would allow an empty object to be + // a valid simple selector property set. + suffix: Interpolation | InterpolationProps | undefined; + raws?: ParentSelectorRaws; +} + +/** + * Raws indicating how to precisely serialize a {@ParentSelector}. + * + * @category Selector + */ +// eslint-disable-next-line @typescript-eslint/no-empty-interface -- No raws for a parent selector yet. +export interface ParentSelectorRaws {} + +/** + * A parent selector. + * + * This selects elements matching the selector beneath which it's nested. + * + * @category Selector + */ +export class ParentSelector extends SimpleSelector { + readonly sassType = 'parent' as const; + declare raws: ParentSelectorRaws; + + /** + * The suffix that will be added to the parent selector after it's been + * resolved. + * + * This is assumed to be a valid identifier suffix. It may be `null`, + * indicating that the parent selector will not be modified. + */ + get suffix(): Interpolation | undefined { + return this._suffix; + } + set suffix(suffix: Interpolation | InterpolationProps | undefined) { + if (this._suffix) this._suffix.parent = undefined; + const built = + suffix === undefined + ? undefined + : typeof suffix === 'object' && 'sassType' in suffix + ? suffix + : new Interpolation(suffix); + if (built) built.parent = this; + this._suffix = built; + } + private declare _suffix: Interpolation | undefined; + + constructor(defaults?: ParentSelectorProps); + /** @hidden */ + constructor(_: undefined, inner: sassInternal.ParentSelector); + constructor(defaults?: object, inner?: sassInternal.ParentSelector) { + super(defaults); + if (inner) { + this.source = new LazySource(inner); + if (inner.suffix) { + this.suffix = new Interpolation(undefined, inner.suffix); + } + } + } + + clone(overrides?: Partial): this { + return utils.cloneNode(this, overrides, [ + 'raws', + {name: 'suffix', explicitUndefined: true}, + ]); + } + + toJSON(): object; + /** @hidden */ + toJSON(_: string, inputs: Map): object; + toJSON(_?: string, inputs?: Map): object { + return utils.toJSON(this, ['suffix'], inputs); + } + + /** @hidden */ + toString(): string { + return this.suffix ? `&${this.suffix}` : '&'; + } + + /** @hidden */ + get nonStatementChildren(): ReadonlyArray> { + return this.suffix ? [this.suffix] : []; + } +} diff --git a/pkg/sass-parser/lib/src/selector/placeholder.test.ts b/pkg/sass-parser/lib/src/selector/placeholder.test.ts new file mode 100644 index 000000000..2f6494bbf --- /dev/null +++ b/pkg/sass-parser/lib/src/selector/placeholder.test.ts @@ -0,0 +1,151 @@ +// 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 {Interpolation, PlaceholderSelector} from '../..'; +import { + fromSimpleSelectorProps, + parseSimpleSelector, +} from '../../../test/utils'; + +describe('a placeholder selector', () => { + let node: PlaceholderSelector; + + describe('without interpolation', () => { + function describeNode( + description: string, + create: () => PlaceholderSelector, + ): void { + describe(description, () => { + beforeEach(() => void (node = create())); + + it('has sassType placeholder', () => + expect(node.sassType).toBe('placeholder')); + + it('has a placeholder', () => + expect(node).toHaveInterpolation('placeholder', 'foo')); + }); + } + + describeNode('parsed', () => parseSimpleSelector('%foo')); + + describeNode( + 'constructed manually', + () => new PlaceholderSelector({placeholder: 'foo'}), + ); + + describeNode('from props', () => + fromSimpleSelectorProps({placeholder: 'foo'}), + ); + }); + + describe('with interpolation', () => { + function describeNode( + description: string, + create: () => PlaceholderSelector, + ): void { + describe(description, () => { + beforeEach(() => void (node = create())); + + it('has sassType placeholder', () => + expect(node.sassType).toBe('placeholder')); + + it('has a placeholder', () => + expect(node.placeholder).toHaveStringExpression(0, 'foo')); + }); + } + + describeNode('parsed', () => parseSimpleSelector('%#{foo}')); + + describeNode( + 'constructed manually', + () => new PlaceholderSelector({placeholder: [{text: 'foo'}]}), + ); + + describeNode('from props', () => + fromSimpleSelectorProps({placeholder: [{text: 'foo'}]}), + ); + }); + + describe('assigned new placeholder', () => { + beforeEach(() => void (node = parseSimpleSelector('%foo'))); + + it("removes the old placeholder's parent", () => { + const oldPlaceholder = node.placeholder; + node.placeholder = 'bar'; + expect(oldPlaceholder.parent).toBeUndefined(); + }); + + it('assigns placeholder explicitly', () => { + const placeholder = new Interpolation('bar'); + node.placeholder = placeholder; + expect(node.placeholder).toBe(placeholder); + expect(node).toHaveInterpolation('placeholder', 'bar'); + }); + + it('assigns placeholder as InterpolationProps', () => { + node.placeholder = 'bar'; + expect(node).toHaveInterpolation('placeholder', 'bar'); + }); + }); + + it('stringifies', () => + expect(parseSimpleSelector('%foo').toString()).toBe('%foo')); + + describe('clone', () => { + let original: PlaceholderSelector; + + beforeEach(() => { + original = parseSimpleSelector('%foo'); + }); + + describe('with no overrides', () => { + let clone: PlaceholderSelector; + + beforeEach(() => void (clone = original.clone())); + + describe('has the same properties:', () => { + it('placeholder', () => + expect(clone).toHaveInterpolation('placeholder', 'foo')); + + it('raws', () => expect(clone.raws).toEqual({})); + + it('source', () => expect(clone.source).toBe(original.source)); + }); + + describe('creates a new', () => { + it('self', () => expect(clone).not.toBe(original)); + + for (const attr of ['placeholder', 'raws'] as const) { + it(attr, () => expect(clone[attr]).not.toBe(original[attr])); + } + }); + }); + + describe('overrides', () => { + describe('placeholder', () => { + it('defined', () => + expect(original.clone({placeholder: 'bar'})).toHaveInterpolation( + 'placeholder', + 'bar', + )); + + it('undefined', () => + expect(original.clone({placeholder: undefined})).toHaveInterpolation( + 'placeholder', + 'foo', + )); + }); + + describe('raws', () => { + it('defined', () => + expect(original.clone({raws: {}}).raws).toEqual({})); + + it('undefined', () => + expect(original.clone({raws: undefined}).raws).toEqual({})); + }); + }); + }); + + it('toJSON', () => expect(parseSimpleSelector('%foo')).toMatchSnapshot()); +}); diff --git a/pkg/sass-parser/lib/src/selector/placeholder.ts b/pkg/sass-parser/lib/src/selector/placeholder.ts new file mode 100644 index 000000000..88591f8e4 --- /dev/null +++ b/pkg/sass-parser/lib/src/selector/placeholder.ts @@ -0,0 +1,90 @@ +// 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 {Interpolation, InterpolationProps} from '../interpolation'; +import {LazySource} from '../lazy-source'; +import type {AnyNode, NodeProps} from '../node'; +import type {AnyStatement} from '../statement'; +import * as sassInternal from '../sass-internal'; +import * as utils from '../utils'; +import {SimpleSelector} from './index'; + +/** + * The initializer properties for {@link PlaceholderSelector}. + * + * @category Selector + */ +export interface PlaceholderSelectorProps extends NodeProps { + placeholder: Interpolation | InterpolationProps; + raws?: PlaceholderSelectorRaws; +} + +/** + * Raws indicating how to precisely serialize a {@PlaceholderSelector}. + * + * @category Selector + */ +// eslint-disable-next-line @typescript-eslint/no-empty-interface -- No raws for a placeholder selector yet. +export interface PlaceholderSelectorRaws {} + +/** + * A placeholder selector. + * + * This selects no elements, and is only used as the target for `@extend` rules. + * + * @category Selector + */ +export class PlaceholderSelector extends SimpleSelector { + readonly sassType = 'placeholder' as const; + declare raws: PlaceholderSelectorRaws; + + /** The placeholder name that this selects. */ + get placeholder(): Interpolation { + return this._placeholder; + } + set placeholder(placeholder: Interpolation | InterpolationProps) { + if (this._placeholder) this._placeholder.parent = undefined; + const built = + typeof placeholder === 'object' && 'sassType' in placeholder + ? placeholder + : new Interpolation(placeholder); + built.parent = this; + this._placeholder = built; + } + private declare _placeholder: Interpolation; + + constructor(defaults: PlaceholderSelectorProps); + /** @hidden */ + constructor(_: undefined, inner: sassInternal.PlaceholderSelector); + constructor(defaults?: object, inner?: sassInternal.PlaceholderSelector) { + super(defaults); + if (inner) { + this.source = new LazySource(inner); + this.placeholder = new Interpolation(undefined, inner.name); + } + } + + clone(overrides?: Partial): this { + return utils.cloneNode(this, overrides, ['raws', 'placeholder']); + } + + toJSON(): object; + /** @hidden */ + toJSON(_: string, inputs: Map): object; + toJSON(_?: string, inputs?: Map): object { + return utils.toJSON(this, ['placeholder'], inputs); + } + + /** @hidden */ + toString(): string { + return `%${this.placeholder}`; + } + + /** @hidden */ + get nonStatementChildren(): ReadonlyArray> { + return [this.placeholder]; + } +} diff --git a/pkg/sass-parser/lib/src/selector/pseudo.test.ts b/pkg/sass-parser/lib/src/selector/pseudo.test.ts new file mode 100644 index 000000000..1dc0420dc --- /dev/null +++ b/pkg/sass-parser/lib/src/selector/pseudo.test.ts @@ -0,0 +1,736 @@ +// 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 {Interpolation, PseudoSelector, SelectorList} from '../..'; +import { + fromSimpleSelectorProps, + parseSimpleSelector, +} from '../../../test/utils'; + +describe('a pseudo selector', () => { + let node: PseudoSelector; + + describe('without an argument or selector', () => { + function describeNode( + description: string, + create: () => PseudoSelector, + ): void { + describe(description, () => { + beforeEach(() => void (node = create())); + + it('has sassType pseudo', () => expect(node.sassType).toBe('pseudo')); + + it('has a name', () => + expect(node).toHaveInterpolation('pseudo', 'foo')); + + it('is a pseudo-class', () => expect(node.isClass).toBe(true)); + + it('is not a pseudo-element', () => expect(node.isElement).toBe(false)); + + it('has no argument', () => expect(node.argument).toBeUndefined()); + + it('has no selector', () => expect(node.selector).toBeUndefined()); + }); + } + + describeNode('parsed', () => parseSimpleSelector(':foo')); + + describeNode( + 'constructed manually', + () => new PseudoSelector({pseudo: 'foo'}), + ); + + describeNode('from props', () => fromSimpleSelectorProps({pseudo: 'foo'})); + }); + + describe('a pseudo-element', () => { + function describeNode( + description: string, + create: () => PseudoSelector, + ): void { + describe(description, () => { + beforeEach(() => void (node = create())); + + it('has sassType pseudo', () => expect(node.sassType).toBe('pseudo')); + + it('has a name', () => + expect(node).toHaveInterpolation('pseudo', 'foo')); + + it('is not a pseudo-class', () => expect(node.isClass).toBe(false)); + + it('is a pseudo-element', () => expect(node.isElement).toBe(true)); + + it('has no argument', () => expect(node.argument).toBeUndefined()); + + it('has no selector', () => expect(node.selector).toBeUndefined()); + }); + } + + describeNode('parsed', () => parseSimpleSelector('::foo')); + + describe('constructed manually', () => { + describeNode( + 'with isElement: true', + () => new PseudoSelector({pseudo: 'foo', isElement: true}), + ); + + describeNode( + 'with isClass: false', + () => new PseudoSelector({pseudo: 'foo', isClass: false}), + ); + }); + + describe('from props', () => { + describeNode('with isElement: true', () => + fromSimpleSelectorProps({pseudo: 'foo', isElement: true}), + ); + + describeNode('with isClass: false', () => + fromSimpleSelectorProps({pseudo: 'foo', isClass: false}), + ); + }); + }); + + describe('a fake pseudo-element', () => { + function describeNode( + description: string, + create: () => PseudoSelector, + ): void { + describe(description, () => { + beforeEach(() => void (node = create())); + + it('has sassType pseudo', () => expect(node.sassType).toBe('pseudo')); + + it('has a name', () => + expect(node).toHaveInterpolation('pseudo', 'after')); + + it('is not a pseudo-class', () => expect(node.isClass).toBe(true)); + + it('is a pseudo-element', () => expect(node.isElement).toBe(false)); + + it('has no argument', () => expect(node.argument).toBeUndefined()); + + it('has no selector', () => expect(node.selector).toBeUndefined()); + }); + } + + describeNode('parsed', () => parseSimpleSelector(':after')); + + describeNode( + 'constructed manually', + () => new PseudoSelector({pseudo: 'after'}), + ); + + describeNode('from props', () => + fromSimpleSelectorProps({pseudo: 'after'}), + ); + }); + + describe('with an argument and no selector', () => { + function describeNode( + description: string, + create: () => PseudoSelector, + ): void { + describe(description, () => { + beforeEach(() => void (node = create())); + + it('has sassType pseudo', () => expect(node.sassType).toBe('pseudo')); + + it('has a name', () => + expect(node).toHaveInterpolation('pseudo', 'foo')); + + it('is a pseudo-class', () => expect(node.isClass).toBe(true)); + + it('is not a pseudo-element', () => expect(node.isElement).toBe(false)); + + it('has an argument', () => + expect(node).toHaveInterpolation('argument', '&^*#')); + + it('has no selector', () => expect(node.selector).toBeUndefined()); + }); + } + + describeNode('parsed', () => parseSimpleSelector(':foo(&^*#)')); + + describeNode( + 'constructed manually', + () => new PseudoSelector({pseudo: 'foo', argument: '&^*#'}), + ); + + describeNode('from props', () => + fromSimpleSelectorProps({pseudo: 'foo', argument: '&^*#'}), + ); + }); + + describe('with a selector and no argument', () => { + function describeNode( + description: string, + create: () => PseudoSelector, + ): void { + describe(description, () => { + beforeEach(() => void (node = create())); + + it('has sassType pseudo', () => expect(node.sassType).toBe('pseudo')); + + it('has a name', () => + expect(node).toHaveInterpolation('pseudo', 'is')); + + it('is a pseudo-class', () => expect(node.isClass).toBe(true)); + + it('is not a pseudo-element', () => expect(node.isElement).toBe(false)); + + it('has no argument', () => expect(node.argument).toBeUndefined()); + + it('has a selector', () => { + expect(node.selector!.sassType).toEqual('selector-list'); + expect(node.selector!.toString()).toEqual('.foo'); + }); + }); + } + + describeNode('parsed', () => parseSimpleSelector(':is(.foo)')); + + describeNode( + 'constructed manually', + () => new PseudoSelector({pseudo: 'is', selector: {class: 'foo'}}), + ); + + describeNode('from props', () => + fromSimpleSelectorProps({pseudo: 'is', selector: {class: 'foo'}}), + ); + }); + + describe('with a selector and an argument', () => { + function describeNode( + description: string, + create: () => PseudoSelector, + ): void { + describe(description, () => { + beforeEach(() => void (node = create())); + + it('has sassType pseudo', () => expect(node.sassType).toBe('pseudo')); + + it('has a name', () => + expect(node).toHaveInterpolation('pseudo', 'nth-child')); + + it('is a pseudo-class', () => expect(node.isClass).toBe(true)); + + it('is not a pseudo-element', () => expect(node.isElement).toBe(false)); + + it('has an argument', () => + expect(node).toHaveInterpolation('argument', '2n + 1 of')); + + it('has a selector', () => { + expect(node.selector!.sassType).toEqual('selector-list'); + expect(node.selector!.parent).toBe(node); + expect(node.selector!.toString()).toEqual('.foo'); + }); + }); + } + + describeNode('parsed', () => + parseSimpleSelector(':nth-child(2n + 1 of .foo)'), + ); + + describeNode( + 'constructed manually', + () => + new PseudoSelector({ + pseudo: 'nth-child', + argument: '2n + 1 of', + selector: {class: 'foo'}, + }), + ); + + describeNode('from props', () => + fromSimpleSelectorProps({ + pseudo: 'nth-child', + argument: '2n + 1 of', + selector: {class: 'foo'}, + }), + ); + }); + + describe('with an interpolated name and argument', () => { + function describeNode( + description: string, + create: () => PseudoSelector, + ): void { + describe(description, () => { + beforeEach(() => void (node = create())); + + it('has sassType pseudo', () => expect(node.sassType).toBe('pseudo')); + + it('has a name', () => + expect(node).toHaveInterpolation('pseudo', '#{foo}')); + + it('is a pseudo-class', () => expect(node.isClass).toBe(true)); + + it('is not a pseudo-element', () => expect(node.isElement).toBe(false)); + + it('has an argument', () => + expect(node.argument).toHaveStringExpression(0, 'bar')); + + it('has no selector', () => expect(node.selector).toBeUndefined()); + }); + } + + describeNode('parsed', () => parseSimpleSelector(':#{foo}(#{bar})')); + + describeNode( + 'constructed manually', + () => + new PseudoSelector({ + pseudo: [{text: 'foo'}], + argument: [{text: 'bar'}], + }), + ); + + describeNode('from props', () => + fromSimpleSelectorProps({ + pseudo: [{text: 'foo'}], + argument: [{text: 'bar'}], + }), + ); + }); + + describe('with an interpolated argument and a selector', () => { + function describeNode( + description: string, + create: () => PseudoSelector, + ): void { + describe(description, () => { + beforeEach(() => void (node = create())); + + it('has sassType pseudo', () => expect(node.sassType).toBe('pseudo')); + + it('has a name', () => + expect(node).toHaveInterpolation('pseudo', 'nth-child')); + + it('is a pseudo-class', () => expect(node.isClass).toBe(true)); + + it('is not a pseudo-element', () => expect(node.isElement).toBe(false)); + + it('has an argument', () => { + expect(node.argument).toHaveStringExpression(0, 'bar'); + expect(node.argument!.toString()).toEqual('#{bar} of'); + }); + + it('has a selector', () => { + expect(node.selector!.sassType).toEqual('selector-list'); + expect(node.selector!.parent).toBe(node); + expect(node.selector!.toString()).toEqual('.foo'); + }); + }); + } + + describeNode('parsed', () => + parseSimpleSelector(':nth-child(#{bar} of .foo)'), + ); + + describeNode( + 'constructed manually', + () => + new PseudoSelector({ + pseudo: 'nth-child', + argument: [{text: 'bar'}, ' of'], + selector: {class: 'foo'}, + }), + ); + + describeNode('from props', () => + fromSimpleSelectorProps({ + pseudo: 'nth-child', + argument: [{text: 'bar'}, ' of'], + selector: {class: 'foo'}, + }), + ); + }); + + describe('assigned new', () => { + beforeEach( + () => void (node = parseSimpleSelector(':nth-child(2n of .foo)')), + ); + + describe('pseudo', () => { + it("removes the old pseudo's parent", () => { + const oldPseudo = node.pseudo; + node.pseudo = 'bar'; + expect(oldPseudo.parent).toBeUndefined(); + }); + + it('assigns pseudo explicitly', () => { + const pseudo = new Interpolation('bar'); + node.pseudo = pseudo; + expect(node.pseudo).toBe(pseudo); + expect(node).toHaveInterpolation('pseudo', 'bar'); + }); + + it('assigns pseudo as InterpolationProps', () => { + node.pseudo = 'bar'; + expect(node).toHaveInterpolation('pseudo', 'bar'); + }); + }); + + describe('argument', () => { + it("removes the old argument's parent", () => { + const oldArgument = node.argument; + node.argument = 'bar'; + expect(oldArgument!.parent).toBeUndefined(); + }); + + it('assigns argument explicitly', () => { + const argument = new Interpolation('bar'); + node.argument = argument; + expect(node.argument).toBe(argument); + expect(node).toHaveInterpolation('argument', 'bar'); + }); + + it('assigns argument as InterpolationProps', () => { + node.argument = 'bar'; + expect(node).toHaveInterpolation('argument', 'bar'); + }); + + it('assigns undefined argument', () => { + const oldArgument = node.argument; + node.argument = undefined; + expect(oldArgument!.parent).toBeUndefined(); + expect(node.argument).toBeUndefined(); + }); + }); + + describe('selector', () => { + it("removes the old selector's parent", () => { + const oldSelector = node.selector; + node.selector = {class: 'bar'}; + expect(oldSelector!.parent).toBeUndefined(); + }); + + it('assigns selector explicitly', () => { + const selector = new SelectorList({class: 'bar'}); + node.selector = selector; + expect(node.selector).toBe(selector); + expect(selector.parent).toBe(node); + }); + + it('assigns selector as SelectorListProps', () => { + node.selector = {class: 'bar'}; + expect(node.selector!.toString()).toBe('.bar'); + expect(node.selector!.parent).toBe(node); + }); + + it('assigns undefined selector', () => { + const oldSelector = node.selector; + node.selector = undefined; + expect(oldSelector!.parent).toBeUndefined(); + expect(node.selector).toBeUndefined(); + }); + }); + }); + + it('assigned new name', () => { + node = parseSimpleSelector(':foo') as PseudoSelector; + node.pseudo = 'bar'; + expect(node).toHaveInterpolation('pseudo', 'bar'); + }); + + it('assigned new class', () => { + node = parseSimpleSelector(':foo') as PseudoSelector; + node.isClass = false; + expect(node.isClass).toBe(false); + expect(node.isElement).toBe(true); + }); + + it('assigned new element', () => { + node = parseSimpleSelector(':foo') as PseudoSelector; + node.isElement = true; + expect(node.isClass).toBe(false); + expect(node.isElement).toBe(true); + }); + + it('assigned new argument', () => { + node = parseSimpleSelector(':foo(bar)') as PseudoSelector; + node.argument = 'baz'; + expect(node).toHaveInterpolation('argument', 'baz'); + }); + + it('assigned new selector', () => { + node = parseSimpleSelector(':is(.bar)') as PseudoSelector; + node.selector = {id: 'baz'}; + expect(node.selector!.parent).toBe(node); + expect(node.selector!.toString()).toEqual('#baz'); + }); + + describe('stringifies', () => { + describe('without an argument or selector', () => { + it('with no raws', () => + expect(new PseudoSelector({pseudo: 'foo'}).toString()).toBe(':foo')); + + it('ignores all raws', () => + expect( + new PseudoSelector({ + pseudo: 'foo', + raws: {afterOpen: ' ', beforeClose: ' ', afterArgument: ' '}, + }).toString(), + ).toBe(':foo')); + }); + + it('a pseudo-element', () => + expect(parseSimpleSelector('::foo').toString()).toBe('::foo')); + + it('a fake pseudo-element', () => + expect(parseSimpleSelector(':after').toString()).toBe(':after')); + + describe('with an argument and no selector', () => { + it('with no raws', () => + expect( + new PseudoSelector({pseudo: 'foo', argument: '&#^*'}).toString(), + ).toBe(':foo(&#^*)')); + + it('with afterOpen', () => + expect( + new PseudoSelector({ + pseudo: 'foo', + argument: '&#^*', + raws: {afterOpen: ' '}, + }).toString(), + ).toBe(':foo( &#^*)')); + + it('with beforeClose', () => + expect( + new PseudoSelector({ + pseudo: 'foo', + argument: '&#^*', + raws: {beforeClose: ' '}, + }).toString(), + ).toBe(':foo(&#^* )')); + + it('with afterArgument', () => + expect( + new PseudoSelector({ + pseudo: 'foo', + argument: '&#^*', + raws: {afterArgument: ' '}, + }).toString(), + ).toBe(':foo(&#^* )')); + + it('with afterArgument and beforeClose', () => + expect( + new PseudoSelector({ + pseudo: 'foo', + argument: '&#^*', + raws: {afterArgument: ' ', beforeClose: '/**/'}, + }).toString(), + ).toBe(':foo(&#^* /**/)')); + }); + + describe('with a selector and no argument', () => { + it('with no raws', () => + expect( + new PseudoSelector({ + pseudo: 'is', + selector: {class: 'foo'}, + }).toString(), + ).toBe(':is(.foo)')); + + it('with afterOpen', () => + expect( + new PseudoSelector({ + pseudo: 'is', + selector: {class: 'foo'}, + raws: {afterOpen: ' '}, + }).toString(), + ).toBe(':is( .foo)')); + + it('with beforeClose', () => + expect( + new PseudoSelector({ + pseudo: 'is', + selector: {class: 'foo'}, + raws: {beforeClose: ' '}, + }).toString(), + ).toBe(':is(.foo )')); + + it('ignores afterArgument', () => + expect( + new PseudoSelector({ + pseudo: 'is', + selector: {class: 'foo'}, + raws: {afterArgument: ' '}, + }).toString(), + ).toBe(':is(.foo)')); + }); + + describe('with an argument and a selector', () => { + it('with no raws', () => + expect( + new PseudoSelector({ + pseudo: 'nth-child', + argument: '2n of', + selector: {class: 'foo'}, + }).toString(), + ).toBe(':nth-child(2n of .foo)')); + + it('with afterOpen', () => + expect( + new PseudoSelector({ + pseudo: 'nth-child', + argument: '2n of', + selector: {class: 'foo'}, + raws: {afterOpen: ' '}, + }).toString(), + ).toBe(':nth-child( 2n of .foo)')); + + it('with beforeClose', () => + expect( + new PseudoSelector({ + pseudo: 'nth-child', + argument: '2n of', + selector: {class: 'foo'}, + raws: {beforeClose: ' '}, + }).toString(), + ).toBe(':nth-child(2n of .foo )')); + + it('with afterArgument', () => + expect( + new PseudoSelector({ + pseudo: 'nth-child', + argument: '2n of', + selector: {class: 'foo'}, + raws: {afterArgument: ' '}, + }).toString(), + ).toBe(':nth-child(2n of .foo)')); + }); + }); + + describe('clone', () => { + let original: PseudoSelector; + + beforeEach(() => { + original = parseSimpleSelector(':nth-child(2n of .foo)'); + }); + + describe('with no overrides', () => { + let clone: PseudoSelector; + + beforeEach(() => void (clone = original.clone())); + + describe('has the same properties:', () => { + it('pseudo', () => + expect(clone).toHaveInterpolation('pseudo', 'nth-child')); + + it('argument', () => + expect(clone).toHaveInterpolation('argument', '2n of')); + + it('selector', () => { + expect(clone.selector!.toString()).toEqual('.foo'); + expect(clone.selector!.parent).toBe(clone); + }); + + it('raws', () => expect(clone.raws).toEqual({})); + + it('source', () => expect(clone.source).toBe(original.source)); + }); + + describe('creates a new', () => { + it('self', () => expect(clone).not.toBe(original)); + + for (const attr of [ + 'pseudo', + 'argument', + 'selector', + 'raws', + ] as const) { + it(attr, () => expect(clone[attr]).not.toBe(original[attr])); + } + }); + }); + + describe('overrides', () => { + describe('pseudo', () => { + it('defined', () => + expect(original.clone({pseudo: 'bar'})).toHaveInterpolation( + 'pseudo', + 'bar', + )); + + it('undefined', () => + expect(original.clone({pseudo: undefined})).toHaveInterpolation( + 'pseudo', + 'nth-child', + )); + }); + + describe('class', () => { + it('defined', () => + expect(original.clone({isClass: false}).isClass).toBe(false)); + + it('undefined', () => + expect(original.clone({isClass: undefined}).isClass).toBe(true)); + }); + + describe('element', () => { + it('defined', () => + expect(original.clone({isElement: true}).isElement).toBe(true)); + + it('undefined', () => + expect(original.clone({isElement: undefined}).isElement).toBe(false)); + }); + + describe('argument', () => { + it('defined', () => + expect(original.clone({argument: 'n + 1 of'})).toHaveInterpolation( + 'argument', + 'n + 1 of', + )); + + it('undefined', () => + expect( + original.clone({argument: undefined}).argument, + ).toBeUndefined()); + }); + + describe('selector', () => { + it('defined', () => { + const clone = original.clone({selector: {id: 'bar'}}); + expect(clone.selector!.toString()).toBe('#bar'); + expect(clone.selector!.parent).toBe(clone); + }); + + it('undefined', () => + expect( + original.clone({selector: undefined}).selector, + ).toBeUndefined()); + }); + + describe('raws', () => { + it('defined', () => + expect(original.clone({raws: {}}).raws).toEqual({})); + + it('undefined', () => + expect(original.clone({raws: undefined}).raws).toEqual({})); + }); + }); + }); + + describe('toJSON', () => { + it('with no argument or selector', () => + expect(parseSimpleSelector(':foo')).toMatchSnapshot()); + + it('a pseudo-element', () => + expect(parseSimpleSelector('::foo')).toMatchSnapshot()); + + it('a fake pseudo-element', () => + expect(parseSimpleSelector(':after')).toMatchSnapshot()); + + it('with an argument and no selector', () => + expect(parseSimpleSelector(':foo(&^*#)')).toMatchSnapshot()); + + it('with a selector and no argument', () => + expect(parseSimpleSelector(':is(.foo)')).toMatchSnapshot()); + + it('with an argument and a selector', () => + expect(parseSimpleSelector(':nth-child(2n of .foo)')).toMatchSnapshot()); + }); +}); diff --git a/pkg/sass-parser/lib/src/selector/pseudo.ts b/pkg/sass-parser/lib/src/selector/pseudo.ts new file mode 100644 index 000000000..5bc70b18f --- /dev/null +++ b/pkg/sass-parser/lib/src/selector/pseudo.ts @@ -0,0 +1,228 @@ +// 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 {Interpolation, InterpolationProps} from '../interpolation'; +import {LazySource} from '../lazy-source'; +import type {AnyNode, NodeProps} from '../node'; +import type {AnyStatement} from '../statement'; +import * as sassInternal from '../sass-internal'; +import * as utils from '../utils'; +import {SelectorList, SelectorListProps} from './list'; +import {SimpleSelector} from './index'; + +/** + * The initializer properties for {@link PseudoSelector}. + * + * @category Selector + */ +export type PseudoSelectorProps = NodeProps & { + pseudo: Interpolation | InterpolationProps; + argument?: Interpolation | InterpolationProps; + selector?: SelectorList | SelectorListProps; + raws?: PseudoSelectorRaws; +} & ({isClass?: boolean} | {isElement?: boolean}); + +/** + * Raws indicating how to precisely serialize a {@PseudoSelector}. + * + * @category Selector + */ +export interface PseudoSelectorRaws { + /** + * The whitespace after the opening parenthesis in the selector's argument. + * + * This is ignored unless {@link PseudoSelector.argument} and/or {@link + * PseudoSelector.selector} is defined. + */ + afterOpen?: string; + + /** + * The whitespace before the closing parenthesis in the selector's argument. + * + * This is ignored unless {@link PseudoSelector.argument} and/or {@link + * PseudoSelector.selector} is defined. + */ + beforeClose?: string; + + /** + * The whitespace between {@link PseudoSelector.argument} and {#link + * PseudoSelector.selector}. + * + * This is ignored unless {@link PseudoSelector.argument} is defined. + * It's not assigned by default unless both {@link + * PseudoSelector.argument} and {@link PseudoSelector.selector} are + * both defined. + */ + afterArgument?: string; +} + +/** + * A pseudo-class or pseudo-element selector. + * + * The semantics of a specific pseudo selector depends on its name. Some + * selectors take arguments, including other selectors. + * + * @category Selector + */ +export class PseudoSelector extends SimpleSelector { + readonly sassType = 'pseudo' as const; + declare raws: PseudoSelectorRaws; + + /** The name of the pseudo-selector or pseudo-element. */ + get pseudo(): Interpolation { + return this._pseudo; + } + set pseudo(pseudo: Interpolation | InterpolationProps) { + if (this._pseudo) this._pseudo.parent = undefined; + const built = + typeof pseudo === 'object' && 'sassType' in pseudo + ? pseudo + : new Interpolation(pseudo); + built.parent = this; + this._pseudo = built; + } + private declare _pseudo: Interpolation; + + /** + * Whether this is syntactically written as a pseudo-class (as opposed to a + * pseudo-element). + * + * This defaults to `true`. + */ + get isClass(): boolean { + return !this._isElement; + } + set isClass(isClass: boolean) { + this._isElement = !isClass; + } + + /** + * Whether this is syntactically written as a pseudo-element (as opposed to a + * pseudo-class). + * + * This defaults to `false`. + */ + get isElement(): boolean { + return this._isElement; + } + set isElement(isElement: boolean) { + this._isElement = isElement; + } + private declare _isElement: boolean; + + /** + * The non-selector argument passed to this selector. + * + * This is `undefined` if there's no argument. If {@link argument} and {@link + * selector} are both non-`undefined`, the selector follows the argument. + */ + get argument(): Interpolation | undefined { + return this._argument; + } + set argument(argument: Interpolation | InterpolationProps | undefined) { + if (this._argument) this._argument.parent = undefined; + const built = + argument === undefined + ? undefined + : typeof argument === 'object' && 'sassType' in argument + ? argument + : new Interpolation(argument); + if (built) built.parent = this; + this._argument = built; + } + private declare _argument: Interpolation | undefined; + + /** + * The non-selector argument passed to this selector. + * + * This is `undefined` if there's no argument. If {@link argument} and {@link + * selector} are both non-`undefined`, the selector follows the argument. + */ + get selector(): SelectorList | undefined { + return this._selector; + } + set selector(selector: SelectorList | SelectorListProps | undefined) { + if (this._selector) this._selector.parent = undefined; + const built = + selector === undefined + ? undefined + : 'sassType' in selector && selector.sassType === 'selector-list' + ? selector + : new SelectorList(selector); + if (built) built.parent = this; + this._selector = built; + } + private declare _selector: SelectorList | undefined; + + constructor(defaults: PseudoSelectorProps); + /** @hidden */ + constructor(_: undefined, inner: sassInternal.PseudoSelector); + constructor(defaults?: object, inner?: sassInternal.PseudoSelector) { + super(defaults); + this._isElement ??= false; + if (inner) { + this.source = new LazySource(inner); + this.pseudo = new Interpolation(undefined, inner.name); + this.isClass = inner.isSyntacticClass; + if (inner.argument) + this.argument = new Interpolation(undefined, inner.argument); + if (inner.selector) { + this.selector = new SelectorList(undefined, inner.selector); + } + } + } + + clone(overrides?: Partial): this { + return utils.cloneNode( + this, + overrides, + [ + 'raws', + 'pseudo', + 'isElement', + {name: 'argument', explicitUndefined: true}, + {name: 'selector', explicitUndefined: true}, + ], + ['isClass'], + ); + } + + toJSON(): object; + /** @hidden */ + toJSON(_: string, inputs: Map): object; + toJSON(_?: string, inputs?: Map): object { + return utils.toJSON( + this, + ['pseudo', 'isElement', 'argument', 'selector'], + inputs, + ); + } + + /** @hidden */ + toString(): string { + let result = (this.isElement ? '::' : ':') + this.pseudo; + if (this.argument || this.selector) { + result += `(${this.raws.afterOpen ?? ''}`; + } + if (this.argument) { + result += + this.argument + (this.raws.afterArgument ?? (this.selector ? ' ' : '')); + } + if (this.selector) result += this.selector; + if (this.argument || this.selector) { + result += `${this.raws.beforeClose ?? ''})`; + } + return result; + } + + /** @hidden */ + get nonStatementChildren(): ReadonlyArray> { + const result: Array> = [this.pseudo]; + if (this.argument) result.push(this.argument); + if (this.selector) result.push(this.selector); + return result; + } +} diff --git a/pkg/sass-parser/lib/src/selector/qualified-name.test.ts b/pkg/sass-parser/lib/src/selector/qualified-name.test.ts new file mode 100644 index 000000000..3921e967b --- /dev/null +++ b/pkg/sass-parser/lib/src/selector/qualified-name.test.ts @@ -0,0 +1,290 @@ +// 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 {Interpolation, QualifiedName, TypeSelector} from '../..'; +import * as utils from '../../../test/utils'; + +/** Parses {@link text} as a qualified name. */ +function parseQualifiedName(text: string): QualifiedName { + const selector = utils.parseSimpleSelector(text); + expect(selector.sassType).toBe('type'); + return (selector as TypeSelector).type; +} + +describe('a qualified name', () => { + let node: QualifiedName; + + describe('with a namespace', () => { + function describeNode( + description: string, + create: () => QualifiedName, + ): void { + describe(description, () => { + beforeEach(() => void (node = create())); + + it('has sassType qualified-name', () => + expect(node.sassType).toBe('qualified-name')); + + it('has a namespace', () => + expect(node).toHaveInterpolation('namespace', 'foo')); + + it('has a name', () => expect(node).toHaveInterpolation('name', 'bar')); + }); + } + + describeNode('parsed', () => parseQualifiedName('foo|bar')); + + describeNode( + 'constructed manually', + () => new QualifiedName({namespace: 'foo', name: 'bar'}), + ); + }); + + describe('with a universal namespace', () => { + function describeNode( + description: string, + create: () => QualifiedName, + ): void { + describe(description, () => { + beforeEach(() => void (node = create())); + + it('has sassType qualified-name', () => + expect(node.sassType).toBe('qualified-name')); + + it('has a namespace', () => + expect(node).toHaveInterpolation('namespace', '*')); + + it('has a name', () => expect(node).toHaveInterpolation('name', 'foo')); + }); + } + + describeNode('parsed', () => parseQualifiedName('*|foo')); + + describeNode( + 'constructed manually', + () => new QualifiedName({namespace: '*', name: 'foo'}), + ); + }); + + describe('with an empty namespace', () => { + function describeNode( + description: string, + create: () => QualifiedName, + ): void { + describe(description, () => { + beforeEach(() => void (node = create())); + + it('has sassType qualified-name', () => + expect(node.sassType).toBe('qualified-name')); + + it('has a namespace', () => + expect(node).toHaveInterpolation('namespace', '')); + + it('has a name', () => expect(node).toHaveInterpolation('name', 'foo')); + }); + } + + describeNode('parsed', () => parseQualifiedName('|foo')); + + describeNode( + 'constructed manually', + () => new QualifiedName({namespace: '', name: 'foo'}), + ); + }); + + describe('without a namespace', () => { + function describeNode( + description: string, + create: () => QualifiedName, + ): void { + describe(description, () => { + beforeEach(() => void (node = create())); + + it('has sassType qulaified-name', () => + expect(node.sassType).toBe('qualified-name')); + + it('has sassType qualified-name', () => + expect(node.sassType).toBe('qualified-name')); + + it('has no namespace', () => expect(node.namespace).toBeUndefined()); + + it('has a name', () => expect(node).toHaveInterpolation('name', 'bar')); + }); + } + + describeNode('parsed', () => parseQualifiedName('bar')); + + describeNode( + 'constructed manually', + () => new QualifiedName({name: 'bar'}), + ); + }); + + describe('with interpolation', () => { + function describeNode( + description: string, + create: () => QualifiedName, + ): void { + describe(description, () => { + beforeEach(() => void (node = create())); + + it('has sassType qualified-name', () => + expect(node.sassType).toBe('qualified-name')); + + it('has a namespace', () => + expect(node.namespace!).toHaveStringExpression(0, 'foo')); + + it('has a name', () => + expect(node.name).toHaveStringExpression(0, 'bar')); + }); + } + + describeNode('parsed', () => parseQualifiedName('#{foo}|#{bar}')); + + describeNode( + 'constructed manually', + () => + new QualifiedName({namespace: [{text: 'foo'}], name: [{text: 'bar'}]}), + ); + }); + + describe('assigned new', () => { + beforeEach(() => void (node = parseQualifiedName('foo|bar'))); + + describe('namespace', () => { + it("removes the old namespace's parent", () => { + const oldNamespace = node.namespace; + node.namespace = 'bar'; + expect(oldNamespace!.parent).toBeUndefined(); + }); + + it('assigns namespace explicitly', () => { + const namespace = new Interpolation('bar'); + node.namespace = namespace; + expect(node.namespace).toBe(namespace); + expect(node).toHaveInterpolation('namespace', 'bar'); + }); + + it('assigns namespace as InterpolationProps', () => { + node.namespace = 'bar'; + expect(node).toHaveInterpolation('namespace', 'bar'); + }); + + it('assigns undefined namespace', () => { + const oldNamespace = node.namespace; + node.namespace = undefined; + expect(oldNamespace!.parent).toBeUndefined(); + expect(node.namespace).toBeUndefined(); + }); + }); + + describe('name', () => { + it("removes the old name's parent", () => { + const oldName = node.name; + node.name = 'bar'; + expect(oldName.parent).toBeUndefined(); + }); + + it('assigns name explicitly', () => { + const name = new Interpolation('bar'); + node.name = name; + expect(node.name).toBe(name); + expect(node).toHaveInterpolation('name', 'bar'); + }); + + it('assigns name as InterpolationProps', () => { + node.name = 'bar'; + expect(node).toHaveInterpolation('name', 'bar'); + }); + }); + }); + + describe('stringifies', () => { + it('with a namespace', () => { + expect(parseQualifiedName('foo|bar').toString()).toBe('foo|bar'); + }); + + it('without a namespace', () => { + expect(parseQualifiedName('foo').toString()).toBe('foo'); + }); + }); + + describe('clone', () => { + let original: QualifiedName; + + beforeEach(() => { + original = parseQualifiedName('foo|bar'); + }); + + describe('with no overrides', () => { + let clone: QualifiedName; + + beforeEach(() => void (clone = original.clone())); + + describe('has the same properties:', () => { + it('namespace', () => + expect(clone).toHaveInterpolation('namespace', 'foo')); + + it('name', () => expect(clone).toHaveInterpolation('name', 'bar')); + + it('raws', () => expect(clone.raws).toEqual({})); + + it('source', () => expect(clone.source).toBe(original.source)); + }); + + describe('creates a new', () => { + it('self', () => expect(clone).not.toBe(original)); + + for (const attr of ['namespace', 'name', 'raws'] as const) { + it(attr, () => expect(clone[attr]).not.toBe(original[attr])); + } + }); + }); + + describe('overrides', () => { + describe('namespace', () => { + it('defined', () => + expect(original.clone({namespace: 'baz'})).toHaveInterpolation( + 'namespace', + 'baz', + )); + + it('undefined', () => + expect( + original.clone({namespace: undefined}).namespace, + ).toBeUndefined()); + }); + + describe('name', () => { + it('defined', () => + expect(original.clone({name: 'baz'})).toHaveInterpolation( + 'name', + 'baz', + )); + + it('undefined', () => + expect(original.clone({name: undefined})).toHaveInterpolation( + 'name', + 'bar', + )); + }); + + describe('raws', () => { + it('defined', () => + expect(original.clone({raws: {}}).raws).toEqual({})); + + it('undefined', () => + expect(original.clone({raws: undefined}).raws).toEqual({})); + }); + }); + }); + + describe('toJSON', () => { + it('with a namespace', () => + expect(parseQualifiedName('foo|bar')).toMatchSnapshot()); + + it('without a namespace', () => + expect(parseQualifiedName('foo')).toMatchSnapshot()); + }); +}); diff --git a/pkg/sass-parser/lib/src/selector/qualified-name.ts b/pkg/sass-parser/lib/src/selector/qualified-name.ts new file mode 100644 index 000000000..418f4c634 --- /dev/null +++ b/pkg/sass-parser/lib/src/selector/qualified-name.ts @@ -0,0 +1,142 @@ +// 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 {Interpolation, InterpolationProps} from '../interpolation'; +import {LazySource} from '../lazy-source'; +import {AnyNode, Node, NodeProps} from '../node'; +import * as sassInternal from '../sass-internal'; +import {AnyStatement} from '../statement'; +import * as utils from '../utils'; + +/** + * The initializer properties for {@link QualifiedName} passed as an options + * object. + * + * @category Selector + */ +export interface QualifiedNameObjectProps extends NodeProps { + namespace?: Interpolation | InterpolationProps; + name: Interpolation | InterpolationProps; + raws?: QualifiedNameRaws; +} + +/** + * The initializer properties for {@link QualifiedName}. + * + * A single interpolation (which can be a plain string) is interpreted as a name + * in the default namespace. + * + * @category Selector + */ +export type QualifiedNameProps = + | QualifiedNameObjectProps + | Interpolation + | InterpolationProps; + +/** + * Raws indicating how to precisely serialize a {@link QualifiedName}. + * + * @category Selector + */ +// eslint-disable-next-line @typescript-eslint/no-empty-interface -- No raws for a selector expression yet. +export interface QualifiedNameRaws {} + +/** + * A [qualified name]. + * + * [qualified name]: https://www.w3.org/TR/css3-namespace/#css-qnames + * + * @category Selector + */ +export class QualifiedName extends Node { + readonly sassType = 'qualified-name' as const; + declare raws: QualifiedNameRaws; + + /** + * The qualified identifier's namespace. + * + * If this is `undefined`, this name belongs to the default namespace. If it's + * the empty string, this name belongs to no namespace. If it's `*`, this name + * belongs to any namespace. Otherwise, this belongs to the namespace with the + * given name. + */ + get namespace(): Interpolation | undefined { + return this._namespace; + } + set namespace(value: Interpolation | InterpolationProps | undefined) { + // TODO - postcss/postcss#1957: Mark this as dirty + if (this._namespace) this._namespace.parent = undefined; + const namespace = + value === undefined + ? undefined + : typeof value === 'object' && 'sassType' in value + ? value + : new Interpolation(value); + if (namespace) namespace.parent = this; + this._namespace = namespace; + } + private declare _namespace: Interpolation | undefined; + + /** The identifier name. */ + get name(): Interpolation { + return this._name; + } + set name(value: Interpolation | InterpolationProps) { + // TODO - postcss/postcss#1957: Mark this as dirty + if (this._name) this._name.parent = undefined; + const name = + value instanceof Interpolation ? value : new Interpolation(value); + name.parent = this; + this._name = name; + } + private declare _name: Interpolation; + + constructor(defaults?: QualifiedNameProps); + /** @hidden */ + constructor(_: undefined, inner: sassInternal.QualifiedName); + constructor(defaults?: object | string, inner?: sassInternal.QualifiedName) { + if (!(typeof defaults === 'object' && 'name' in defaults)) { + defaults = {name: defaults}; + } + super(defaults); + if (inner) { + this.source = new LazySource(inner); + if (inner.namespace) + this.namespace = new Interpolation(undefined, inner.namespace); + this.name = new Interpolation(undefined, inner.name); + } + } + + clone(overrides?: Partial): this { + return utils.cloneNode(this, overrides, [ + 'raws', + {name: 'namespace', explicitUndefined: true}, + 'name', + ]); + } + + toJSON(): object; + /** @hidden */ + toJSON(_: string, inputs: Map): object; + toJSON(_?: string, inputs?: Map): object { + return utils.toJSON(this, ['name', 'namespace'], inputs); + } + + /** @hidden */ + toString(): string { + return this.namespace === undefined + ? this.name.toString() + : `${this.namespace}|${this.name}`; + } + + /** @hidden */ + get nonStatementChildren(): ReadonlyArray> { + const result: Array> = []; + if (this.namespace) result.push(this.namespace); + result.push(this.name); + return result; + } +} diff --git a/pkg/sass-parser/lib/src/selector/type.test.ts b/pkg/sass-parser/lib/src/selector/type.test.ts new file mode 100644 index 000000000..805d3ef43 --- /dev/null +++ b/pkg/sass-parser/lib/src/selector/type.test.ts @@ -0,0 +1,119 @@ +// 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 {Interpolation, QualifiedName, TypeSelector} from '../..'; +import { + fromSimpleSelectorProps, + parseSimpleSelector, +} from '../../../test/utils'; + +describe('a type selector', () => { + let node: TypeSelector; + + function describeNode(description: string, create: () => TypeSelector): void { + describe(description, () => { + beforeEach(() => void (node = create())); + + it('has sassType type', () => expect(node.sassType).toBe('type')); + + it('has a type', () => { + expect(node.type.toString()).toEqual('foo'); + expect(node.type.sassType).toEqual('qualified-name'); + expect(node.type.parent).toBe(node); + }); + }); + } + + describeNode('parsed', () => parseSimpleSelector('foo')); + + describeNode('constructed manually', () => new TypeSelector({type: 'foo'})); + + describeNode('from props', () => fromSimpleSelectorProps({type: 'foo'})); + + describe('assigned new type', () => { + beforeEach(() => void (node = parseSimpleSelector('foo'))); + + it("removes the old type's parent", () => { + const oldType = node.type; + node.type = 'bar'; + expect(oldType.parent).toBeUndefined(); + }); + + it('assigns type explicitly', () => { + const type = new QualifiedName('bar'); + node.type = type; + expect(node.type).toBe(type); + expect(node.type.parent).toBe(node); + }); + + it('assigns type as Interpolation', () => { + const type = new Interpolation('bar'); + node.type = type; + expect(node.type.sassType).toEqual('qualified-name'); + expect(node.type.toString()).toEqual('bar'); + expect(node.type.parent).toBe(node); + }); + + it('assigns type as InterpolationProps', () => { + node.type = 'bar'; + expect(node).toHaveNode('type', 'bar', 'qualified-name'); + }); + }); + + it('stringifies', () => + expect(parseSimpleSelector('foo').toString()).toBe('foo')); + + describe('clone', () => { + let original: TypeSelector; + + beforeEach(() => { + original = parseSimpleSelector('foo'); + }); + + describe('with no overrides', () => { + let clone: TypeSelector; + + beforeEach(() => void (clone = original.clone())); + + describe('has the same properties:', () => { + it('type', () => { + expect(clone.type.toString()).toEqual('foo'); + expect(clone.type.parent).toBe(clone); + }); + + it('raws', () => expect(clone.raws).toEqual({})); + + it('source', () => expect(clone.source).toBe(original.source)); + }); + + describe('creates a new', () => { + it('self', () => expect(clone).not.toBe(original)); + + for (const attr of ['type', 'raws'] as const) { + it(attr, () => expect(clone[attr]).not.toBe(original[attr])); + } + }); + }); + + describe('overrides', () => { + describe('class', () => { + it('defined', () => + expect(original.clone({type: 'bar'})).toHaveNode('type', 'bar')); + + it('undefined', () => + expect(original.clone({type: undefined})).toHaveNode('type', 'foo')); + }); + + describe('raws', () => { + it('defined', () => + expect(original.clone({raws: {}}).raws).toEqual({})); + + it('undefined', () => + expect(original.clone({raws: undefined}).raws).toEqual({})); + }); + }); + }); + + it('toJSON', () => expect(parseSimpleSelector('foo')).toMatchSnapshot()); +}); diff --git a/pkg/sass-parser/lib/src/selector/type.ts b/pkg/sass-parser/lib/src/selector/type.ts new file mode 100644 index 000000000..a92f30650 --- /dev/null +++ b/pkg/sass-parser/lib/src/selector/type.ts @@ -0,0 +1,92 @@ +// Copyright 2025 Google Inc. Use of this source code is governed by an +// MIT-style license that can be found in the LICENSE file or at +// https://opensource.org/licenses/MIT. + +import * as postcss from 'postcss'; + +import {LazySource} from '../lazy-source'; +import type {AnyNode, NodeProps} from '../node'; +import type {AnyStatement} from '../statement'; +import * as sassInternal from '../sass-internal'; +import * as utils from '../utils'; +import {QualifiedName, QualifiedNameProps} from './qualified-name'; +import {SimpleSelector} from './index'; + +/** + * The initializer properties for {@link TypeSelector}. + * + * @category Selector + */ +export interface TypeSelectorProps extends NodeProps { + type: QualifiedName | QualifiedNameProps; + raws?: TypeSelectorRaws; +} + +/** + * Raws indicating how to precisely serialize an {@TypeSelector}. + * + * @category Selector + */ +// eslint-disable-next-line @typescript-eslint/no-empty-interface -- No raws for a type selector yet. +export interface TypeSelectorRaws {} + +/** + * A type selector. + * + * This selects elements of the given type. + * + * @category Selector + */ +export class TypeSelector extends SimpleSelector { + readonly sassType = 'type' as const; + declare raws: TypeSelectorRaws; + + /** The class name that this selects. */ + get type(): QualifiedName { + return this._type; + } + set type(type: QualifiedName | QualifiedNameProps) { + if (this._type) this._type.parent = undefined; + const built = + typeof type === 'object' && + 'sassType' in type && + type.sassType === 'qualified-name' + ? type + : new QualifiedName(type); + built.parent = this; + this._type = built; + } + private declare _type: QualifiedName; + + constructor(defaults: TypeSelectorProps); + /** @hidden */ + constructor(_: undefined, inner: sassInternal.TypeSelector); + constructor(defaults?: object, inner?: sassInternal.TypeSelector) { + super(defaults); + if (inner) { + this.source = new LazySource(inner); + this.type = new QualifiedName(undefined, inner.name); + } + } + + clone(overrides?: Partial): this { + return utils.cloneNode(this, overrides, ['raws', 'type']); + } + + toJSON(): object; + /** @hidden */ + toJSON(_: string, inputs: Map): object; + toJSON(_?: string, inputs?: Map): object { + return utils.toJSON(this, ['type'], inputs); + } + + /** @hidden */ + toString(): string { + return this.type.toString(); + } + + /** @hidden */ + get nonStatementChildren(): ReadonlyArray> { + return [this.type]; + } +} diff --git a/pkg/sass-parser/lib/src/selector/universal.test.ts b/pkg/sass-parser/lib/src/selector/universal.test.ts new file mode 100644 index 000000000..ce565df8c --- /dev/null +++ b/pkg/sass-parser/lib/src/selector/universal.test.ts @@ -0,0 +1,186 @@ +// 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 {Interpolation, UniversalSelector} from '../..'; +import { + fromSimpleSelectorProps, + parseSimpleSelector, +} from '../../../test/utils'; + +describe('a universal selector', () => { + let node: UniversalSelector; + + describe('without a namespace', () => { + function describeNode( + description: string, + create: () => UniversalSelector, + ): void { + describe(description, () => { + beforeEach(() => void (node = create())); + + it('has sassType universal', () => + expect(node.sassType).toBe('universal')); + + it('has no namespace', () => expect(node.namespace).toBeUndefined()); + }); + } + + describeNode('parsed', () => parseSimpleSelector('*')); + + describeNode('constructed manually', () => new UniversalSelector()); + + describeNode('from props', () => + fromSimpleSelectorProps({namespace: undefined}), + ); + }); + + describe('without interpolation', () => { + function describeNode( + description: string, + create: () => UniversalSelector, + ): void { + describe(description, () => { + beforeEach(() => void (node = create())); + + it('has sassType universal', () => + expect(node.sassType).toBe('universal')); + + it('has a namespace', () => + expect(node).toHaveInterpolation('namespace', 'foo')); + }); + } + + describeNode('parsed', () => parseSimpleSelector('foo|*')); + + describeNode( + 'constructed manually', + () => new UniversalSelector({namespace: 'foo'}), + ); + + describeNode('from props', () => + fromSimpleSelectorProps({namespace: 'foo'}), + ); + }); + + describe('with interpolation', () => { + function describeNode( + description: string, + create: () => UniversalSelector, + ): void { + describe(description, () => { + beforeEach(() => void (node = create())); + + it('has sassType universal', () => + expect(node.sassType).toBe('universal')); + + it('has a namespace', () => + expect(node.namespace).toHaveStringExpression(0, 'foo')); + }); + } + + describeNode('parsed', () => parseSimpleSelector('#{foo}|*')); + + describeNode( + 'constructed manually', + () => new UniversalSelector({namespace: [{text: 'foo'}]}), + ); + + describeNode('from props', () => + fromSimpleSelectorProps({namespace: [{text: 'foo'}]}), + ); + }); + + describe('assigned new namespace', () => { + beforeEach(() => void (node = parseSimpleSelector('foo|*'))); + + it("removes the old namespace's parent", () => { + const oldNamespace = node.namespace; + node.namespace = 'bar'; + expect(oldNamespace!.parent).toBeUndefined(); + }); + + it('assigns namespace explicitly', () => { + const namespace = new Interpolation('bar'); + node.namespace = namespace; + expect(node.namespace).toBe(namespace); + expect(node).toHaveInterpolation('namespace', 'bar'); + }); + + it('assigns namespace as InterpolationProps', () => { + node.namespace = 'bar'; + expect(node).toHaveInterpolation('namespace', 'bar'); + }); + + it('assigns undefined namespace', () => { + const oldNamespace = node.namespace; + node.namespace = undefined; + expect(oldNamespace!.parent).toBeUndefined(); + expect(node.namespace).toBeUndefined(); + }); + }); + + describe('stringifies', () => { + it('with no namespace', () => + expect(parseSimpleSelector('*').toString()).toBe('*')); + + it('with a namespace', () => + expect(parseSimpleSelector('foo|*').toString()).toBe('foo|*')); + }); + + describe('clone', () => { + let original: UniversalSelector; + + beforeEach(() => { + original = parseSimpleSelector('foo|*'); + }); + + describe('with no overrides', () => { + let clone: UniversalSelector; + + beforeEach(() => void (clone = original.clone())); + + describe('has the same properties:', () => { + it('namespace', () => + expect(clone).toHaveInterpolation('namespace', 'foo')); + + it('raws', () => expect(clone.raws).toEqual({})); + + it('source', () => expect(clone.source).toBe(original.source)); + }); + + it('creates a new self', () => expect(clone).not.toBe(original)); + }); + + describe('overrides', () => { + describe('namespace', () => { + it('defined', () => + expect(original.clone({namespace: 'bar'})).toHaveInterpolation( + 'namespace', + 'bar', + )); + + it('undefined', () => + expect( + original.clone({namespace: undefined}).namespace, + ).toBeUndefined()); + }); + + describe('raws', () => { + it('defined', () => + expect(original.clone({raws: {}}).raws).toEqual({})); + + it('undefined', () => + expect(original.clone({raws: undefined}).raws).toEqual({})); + }); + }); + }); + + describe('toJSON', () => { + it('with no namespace', () => + expect(parseSimpleSelector('*')).toMatchSnapshot()); + + it('with a namespace', () => + expect(parseSimpleSelector('foo|*')).toMatchSnapshot()); + }); +}); diff --git a/pkg/sass-parser/lib/src/selector/universal.ts b/pkg/sass-parser/lib/src/selector/universal.ts new file mode 100644 index 000000000..0454b1ea2 --- /dev/null +++ b/pkg/sass-parser/lib/src/selector/universal.ts @@ -0,0 +1,98 @@ +// 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 {Interpolation, InterpolationProps} from '../interpolation'; +import {LazySource} from '../lazy-source'; +import type {AnyNode, NodeProps} from '../node'; +import type {AnyStatement} from '../statement'; +import * as sassInternal from '../sass-internal'; +import * as utils from '../utils'; +import {SimpleSelector} from '.'; + +/** + * The initializer properties for {@link UniversalSelector}. + * + * @category Selector + */ +export interface UniversalSelectorProps extends NodeProps { + // We can't make this optional because that would allow an empty object to be + // a valid simple selector property set. + namespace: Interpolation | InterpolationProps | undefined; + raws?: UniversalSelectorRaws; +} + +/** + * Raws indicating how to precisely serialize an {@UniversalSelector}. + * + * @category Selector + */ +// eslint-disable-next-line @typescript-eslint/no-empty-interface -- No raws for a universal selector yet. +export interface UniversalSelectorRaws {} + +/** + * A type selector. + * + * This selects elements of the given type. + * + * @category Selector + */ +export class UniversalSelector extends SimpleSelector { + readonly sassType = 'universal' as const; + declare raws: UniversalSelectorRaws; + + /** The class name that this selects. */ + get namespace(): Interpolation | undefined { + return this._namespace; + } + set namespace(namespace: Interpolation | InterpolationProps | undefined) { + if (this._namespace) this._namespace.parent = undefined; + const built = + namespace === undefined + ? undefined + : typeof namespace === 'object' && 'sassType' in namespace + ? namespace + : new Interpolation(namespace); + if (built) built.parent = this; + this._namespace = built; + } + private declare _namespace: Interpolation | undefined; + + constructor(defaults?: UniversalSelectorProps); + /** @hidden */ + constructor(_: undefined, inner: sassInternal.UniversalSelector); + constructor(defaults?: object, inner?: sassInternal.UniversalSelector) { + super(defaults); + if (inner) { + this.source = new LazySource(inner); + if (inner.namespace) + this.namespace = new Interpolation(undefined, inner.namespace); + } + } + + clone(overrides?: Partial): this { + return utils.cloneNode(this, overrides, [ + 'raws', + {name: 'namespace', explicitUndefined: true}, + ]); + } + + toJSON(): object; + /** @hidden */ + toJSON(_: string, inputs: Map): object; + toJSON(_?: string, inputs?: Map): object { + return utils.toJSON(this, ['namespace'], inputs); + } + + /** @hidden */ + toString(): string { + return this.namespace ? `${this.namespace}|*` : '*'; + } + + /** @hidden */ + get nonStatementChildren(): ReadonlyArray> { + return this.namespace ? [this.namespace] : []; + } +} diff --git a/pkg/sass-parser/lib/src/statement/__snapshots__/rule.test.ts.snap b/pkg/sass-parser/lib/src/statement/__snapshots__/rule.test.ts.snap index 76782d464..5d35ed60a 100644 --- a/pkg/sass-parser/lib/src/statement/__snapshots__/rule.test.ts.snap +++ b/pkg/sass-parser/lib/src/statement/__snapshots__/rule.test.ts.snap @@ -12,10 +12,10 @@ exports[`a style rule toJSON with a child 1`] = ` "nodes": [ <@bar>, ], + "parsedSelector": <.foo>, "raws": {}, "sassType": "rule", - "selector": ".foo ", - "selectorInterpolation": <.foo >, + "selector": ".foo", "source": <1:1-1:12 in 0>, "type": "rule", } @@ -31,10 +31,10 @@ exports[`a style rule toJSON with empty children 1`] = ` }, ], "nodes": [], + "parsedSelector": <.foo>, "raws": {}, "sassType": "rule", - "selector": ".foo ", - "selectorInterpolation": <.foo >, + "selector": ".foo", "source": <1:1-1:8 in 0>, "type": "rule", } diff --git a/pkg/sass-parser/lib/src/statement/at-root-rule.test.ts b/pkg/sass-parser/lib/src/statement/at-root-rule.test.ts index 937f0d332..a2895ec2c 100644 --- a/pkg/sass-parser/lib/src/statement/at-root-rule.test.ts +++ b/pkg/sass-parser/lib/src/statement/at-root-rule.test.ts @@ -72,7 +72,7 @@ describe('an @at-root rule', () => { it('contains a Rule', () => { const rule = node.nodes![0] as Rule; - expect(rule).toHaveInterpolation('selectorInterpolation', '.foo '); + expect(rule).toHaveNode('parsedSelector', '.foo'); expect(rule.parent).toBe(node); }); }); diff --git a/pkg/sass-parser/lib/src/statement/container.test.ts b/pkg/sass-parser/lib/src/statement/container.test.ts index d6ca1da91..1cd663b23 100644 --- a/pkg/sass-parser/lib/src/statement/container.test.ts +++ b/pkg/sass-parser/lib/src/statement/container.test.ts @@ -36,15 +36,9 @@ describe('a container node', () => { const otherRoot = new Root({nodes: [rule1, rule2]}); root.append(otherRoot); expect(root.nodes[0]).toBeInstanceOf(Rule); - expect(root.nodes[0]).toHaveInterpolation( - 'selectorInterpolation', - '.foo', - ); + expect(root.nodes[0]).toHaveNode('parsedSelector', '.foo'); expect(root.nodes[1]).toBeInstanceOf(Rule); - expect(root.nodes[1]).toHaveInterpolation( - 'selectorInterpolation', - '.bar', - ); + expect(root.nodes[1]).toHaveNode('parsedSelector', '.bar'); expect(root.nodes[0].parent).toBe(root); expect(root.nodes[1].parent).toBe(root); expect(rule1.parent).toBeUndefined(); @@ -55,10 +49,7 @@ describe('a container node', () => { const node = postcss.parse('.foo {}').nodes[0]; root.append(node); expect(root.nodes[0]).toBeInstanceOf(Rule); - expect(root.nodes[0]).toHaveInterpolation( - 'selectorInterpolation', - '.foo', - ); + expect(root.nodes[0]).toHaveNode('parsedSelector', '.foo'); expect(root.nodes[0].parent).toBe(root); expect(root.nodes[0].source).toBe(node.source); expect(node.parent).toBeUndefined(); @@ -80,15 +71,9 @@ describe('a container node', () => { const rule2 = new postcss.Rule({selector: '.bar'}); root.append([rule1, rule2]); expect(root.nodes[0]).toBeInstanceOf(Rule); - expect(root.nodes[0]).toHaveInterpolation( - 'selectorInterpolation', - '.foo', - ); + expect(root.nodes[0]).toHaveNode('parsedSelector', '.foo'); expect(root.nodes[1]).toBeInstanceOf(Rule); - expect(root.nodes[1]).toHaveInterpolation( - 'selectorInterpolation', - '.bar', - ); + expect(root.nodes[1]).toHaveNode('parsedSelector', '.bar'); expect(root.nodes[0].parent).toBe(root); expect(root.nodes[1].parent).toBe(root); expect(rule1.parent).toBeUndefined(); @@ -101,15 +86,9 @@ describe('a container node', () => { const otherRoot = new postcss.Root({nodes: [rule1, rule2]}); root.append(otherRoot); expect(root.nodes[0]).toBeInstanceOf(Rule); - expect(root.nodes[0]).toHaveInterpolation( - 'selectorInterpolation', - '.foo', - ); + expect(root.nodes[0]).toHaveNode('parsedSelector', '.foo'); expect(root.nodes[1]).toBeInstanceOf(Rule); - expect(root.nodes[1]).toHaveInterpolation( - 'selectorInterpolation', - '.bar', - ); + expect(root.nodes[1]).toHaveNode('parsedSelector', '.bar'); expect(root.nodes[0].parent).toBe(root); expect(root.nodes[1].parent).toBe(root); expect(rule1.parent).toBeUndefined(); @@ -117,40 +96,28 @@ describe('a container node', () => { }); it("a single Sass node's properties", () => { - root.append({selectorInterpolation: '.foo'}); + root.append({parsedSelector: {class: 'foo'}}); expect(root.nodes[0]).toBeInstanceOf(Rule); - expect(root.nodes[0]).toHaveInterpolation( - 'selectorInterpolation', - '.foo', - ); + expect(root.nodes[0]).toHaveNode('parsedSelector', '.foo'); expect(root.nodes[0].parent).toBe(root); }); it("a single PostCSS node's properties", () => { root.append({selector: '.foo'}); expect(root.nodes[0]).toBeInstanceOf(Rule); - expect(root.nodes[0]).toHaveInterpolation( - 'selectorInterpolation', - '.foo', - ); + expect(root.nodes[0]).toHaveNode('parsedSelector', '.foo'); expect(root.nodes[0].parent).toBe(root); }); it('a list of properties', () => { root.append( - {selectorInterpolation: '.foo'}, - {selectorInterpolation: '.bar'}, + {parsedSelector: {class: 'foo'}}, + {parsedSelector: {class: 'bar'}}, ); expect(root.nodes[0]).toBeInstanceOf(Rule); - expect(root.nodes[0]).toHaveInterpolation( - 'selectorInterpolation', - '.foo', - ); + expect(root.nodes[0]).toHaveNode('parsedSelector', '.foo'); expect(root.nodes[1]).toBeInstanceOf(Rule); - expect(root.nodes[1]).toHaveInterpolation( - 'selectorInterpolation', - '.bar', - ); + expect(root.nodes[1]).toHaveNode('parsedSelector', '.bar'); expect(root.nodes[0].parent).toBe(root); expect(root.nodes[1].parent).toBe(root); }); @@ -158,25 +125,16 @@ describe('a container node', () => { it('a plain CSS string', () => { root.append('.foo {}'); expect(root.nodes[0]).toBeInstanceOf(Rule); - expect(root.nodes[0]).toHaveInterpolation( - 'selectorInterpolation', - '.foo', - ); + expect(root.nodes[0]).toHaveNode('parsedSelector', '.foo'); expect(root.nodes[0].parent).toBe(root); }); it('a list of plain CSS strings', () => { root.append(['.foo {}', '.bar {}']); expect(root.nodes[0]).toBeInstanceOf(Rule); - expect(root.nodes[0]).toHaveInterpolation( - 'selectorInterpolation', - '.foo', - ); + expect(root.nodes[0]).toHaveNode('parsedSelector', '.foo'); expect(root.nodes[1]).toBeInstanceOf(Rule); - expect(root.nodes[1]).toHaveInterpolation( - 'selectorInterpolation', - '.bar', - ); + expect(root.nodes[1]).toHaveNode('parsedSelector', '.bar'); expect(root.nodes[0].parent).toBe(root); expect(root.nodes[1].parent).toBe(root); }); diff --git a/pkg/sass-parser/lib/src/statement/declaration.ts b/pkg/sass-parser/lib/src/statement/declaration.ts index 0ed8ac549..14e201218 100644 --- a/pkg/sass-parser/lib/src/statement/declaration.ts +++ b/pkg/sass-parser/lib/src/statement/declaration.ts @@ -24,7 +24,6 @@ import { } from '.'; import {_DeclarationWithChildren} from './declaration-internal'; import * as sassParser from '../..'; -import {ContainerWithChildren} from 'postcss/lib/container'; // TODO(nweiz): Make sure setting non-identifier strings for prop here and name // in GenericAtRule escapes properly. diff --git a/pkg/sass-parser/lib/src/statement/generic-at-rule.test.ts b/pkg/sass-parser/lib/src/statement/generic-at-rule.test.ts index 15279deb6..406a75909 100644 --- a/pkg/sass-parser/lib/src/statement/generic-at-rule.test.ts +++ b/pkg/sass-parser/lib/src/statement/generic-at-rule.test.ts @@ -230,7 +230,7 @@ describe('a generic @-rule', () => { it('has a child node', () => { expect(node.nodes).toHaveLength(1); expect(node.nodes![0]).toBeInstanceOf(Rule); - expect(node.nodes![0]).toHaveProperty('selector', '.bar\n'); + expect(node.nodes![0]).toHaveProperty('selector', '.bar'); }); }); }); @@ -253,7 +253,7 @@ describe('a generic @-rule', () => { it('has a child node', () => { expect(node.nodes).toHaveLength(1); expect(node.nodes![0]).toBeInstanceOf(Rule); - expect(node.nodes![0]).toHaveProperty('selector', '.baz\n'); + expect(node.nodes![0]).toHaveProperty('selector', '.baz'); }); }); } @@ -270,7 +270,7 @@ describe('a generic @-rule', () => { new GenericAtRule({ name: 'foo', params: 'bar', - nodes: [{selector: '.baz\n'}], + nodes: [{selector: '.baz'}], }), ); }); @@ -280,7 +280,7 @@ describe('a generic @-rule', () => { utils.fromChildProps({ name: 'foo', params: 'bar', - nodes: [{selector: '.baz\n'}], + nodes: [{selector: '.baz'}], }), ); }); @@ -594,7 +594,7 @@ describe('a generic @-rule', () => { it('nodes', () => { expect(clone.nodes).toHaveLength(1); expect(clone.nodes![0]).toBeInstanceOf(Rule); - expect(clone.nodes![0]).toHaveProperty('selector', '.baz '); + expect(clone.nodes![0]).toHaveProperty('selector', '.baz'); }); }); diff --git a/pkg/sass-parser/lib/src/statement/index.ts b/pkg/sass-parser/lib/src/statement/index.ts index ff6640322..92f22a768 100644 --- a/pkg/sass-parser/lib/src/statement/index.ts +++ b/pkg/sass-parser/lib/src/statement/index.ts @@ -371,7 +371,7 @@ export function normalize( } else if ('prop' in node || 'propInterpolation' in node) { result.push(new Declaration(node)); } else if ( - 'selectorInterpolation' in node || + 'parsedSelector' in node || 'selector' in node || 'selectors' in node ) { diff --git a/pkg/sass-parser/lib/src/statement/rule.test.ts b/pkg/sass-parser/lib/src/statement/rule.test.ts index 708de8170..8fb31d412 100644 --- a/pkg/sass-parser/lib/src/statement/rule.test.ts +++ b/pkg/sass-parser/lib/src/statement/rule.test.ts @@ -2,7 +2,7 @@ // MIT-style license that can be found in the LICENSE file or at // https://opensource.org/licenses/MIT. -import {GenericAtRule, Interpolation, Root, Rule, css, sass, scss} from '../..'; +import {GenericAtRule, Root, Rule, SelectorList, css, sass, scss} from '../..'; import * as utils from '../../../test/utils'; describe('a style rule', () => { @@ -16,10 +16,10 @@ describe('a style rule', () => { it('has sassType rule', () => expect(node.sassType).toBe('rule')); - it('has matching selectorInterpolation', () => - expect(node).toHaveInterpolation('selectorInterpolation', '.foo ')); + it('has matching parsedSelector', () => + expect(node).toHaveNode('parsedSelector', '.foo', 'selector-list')); - it('has matching selector', () => expect(node.selector).toBe('.foo ')); + it('has matching selector', () => expect(node.selector).toBe('.foo')); it('has empty nodes', () => expect(node.nodes).toHaveLength(0)); }); @@ -34,41 +34,36 @@ describe('a style rule', () => { describe('parsed as Sass', () => { beforeEach(() => { - node = sass.parse('.foo').nodes[0] as Rule; + node = sass.parse('.foo\n').nodes[0] as Rule; }); - it('has matching selectorInterpolation', () => - expect(node).toHaveInterpolation('selectorInterpolation', '.foo\n')); + it('has matching parsedSelector', () => + expect(node).toHaveNode('parsedSelector', '.foo', 'selector-list')); - it('has matching selector', () => expect(node.selector).toBe('.foo\n')); + it('has matching selector', () => expect(node.selector).toBe('.foo')); it('has empty nodes', () => expect(node.nodes).toHaveLength(0)); }); describe('constructed manually', () => { describeNode( - 'with an interpolation', - () => - new Rule({ - selectorInterpolation: new Interpolation({nodes: ['.foo ']}), - }), + 'with a parsed selector', + () => new Rule({parsedSelector: {class: 'foo'}}), ); describeNode( 'with a selector string', - () => new Rule({selector: '.foo '}), + () => new Rule({selector: '.foo'}), ); }); describe('constructed from ChildProps', () => { - describeNode('with an interpolation', () => - utils.fromChildProps({ - selectorInterpolation: new Interpolation({nodes: ['.foo ']}), - }), + describeNode('with a parsed selector', () => + utils.fromChildProps({parsedSelector: {class: 'foo'}}), ); describeNode('with a selector string', () => - utils.fromChildProps({selector: '.foo '}), + utils.fromChildProps({selector: '.foo'}), ); }); }); @@ -78,10 +73,10 @@ describe('a style rule', () => { describe(description, () => { beforeEach(() => void (node = create())); - it('has matching selectorInterpolation', () => - expect(node).toHaveInterpolation('selectorInterpolation', '.foo ')); + it('has matching parsedSelector', () => + expect(node).toHaveNode('parsedSelector', '.foo', 'selector-list')); - it('has matching selector', () => expect(node.selector).toBe('.foo ')); + it('has matching selector', () => expect(node.selector).toBe('.foo')); it('has a child node', () => { expect(node.nodes).toHaveLength(1); @@ -106,10 +101,10 @@ describe('a style rule', () => { node = sass.parse('.foo\n @bar').nodes[0] as Rule; }); - it('has matching selectorInterpolation', () => - expect(node).toHaveInterpolation('selectorInterpolation', '.foo\n')); + it('has matching parsedSelector', () => + expect(node).toHaveNode('parsedSelector', '.foo')); - it('has matching selector', () => expect(node.selector).toBe('.foo\n')); + it('has matching selector', () => expect(node.selector).toBe('.foo')); it('has a child node', () => { expect(node.nodes).toHaveLength(1); @@ -120,30 +115,30 @@ describe('a style rule', () => { describe('constructed manually', () => { describeNode( - 'with an interpolation', + 'with a parsedSelector', () => new Rule({ - selectorInterpolation: new Interpolation({nodes: ['.foo ']}), + parsedSelector: {class: 'foo'}, nodes: [{name: 'bar'}], }), ); describeNode( 'with a selector string', - () => new Rule({selector: '.foo ', nodes: [{name: 'bar'}]}), + () => new Rule({selector: '.foo', nodes: [{name: 'bar'}]}), ); }); describe('constructed from ChildProps', () => { - describeNode('with an interpolation', () => + describeNode('with a parsedSelector', () => utils.fromChildProps({ - selectorInterpolation: new Interpolation({nodes: ['.foo ']}), + parsedSelector: {class: 'foo'}, nodes: [{name: 'bar'}], }), ); describeNode('with a selector string', () => - utils.fromChildProps({selector: '.foo ', nodes: [{name: 'bar'}]}), + utils.fromChildProps({selector: '.foo', nodes: [{name: 'bar'}]}), ); }); }); @@ -153,32 +148,27 @@ describe('a style rule', () => { node = scss.parse('.foo {}').nodes[0] as Rule; }); - it("removes the old interpolation's parent", () => { - const oldSelector = node.selectorInterpolation!; - node.selectorInterpolation = '.bar'; + it("removes the old selector's parent", () => { + const oldSelector = node.parsedSelector!; + node.parsedSelector = {class: 'bar'}; expect(oldSelector.parent).toBeUndefined(); }); - it("assigns the new interpolation's parent", () => { - const interpolation = new Interpolation({nodes: ['.bar']}); - node.selectorInterpolation = interpolation; - expect(interpolation.parent).toBe(node); - }); - - it('assigns the interpolation explicitly', () => { - const interpolation = new Interpolation({nodes: ['.bar']}); - node.selectorInterpolation = interpolation; - expect(node.selectorInterpolation).toBe(interpolation); + it("assigns the new selector's parent", () => { + const selector = new SelectorList({class: 'bar'}); + node.parsedSelector = selector; + expect(selector.parent).toBe(node); }); - it('assigns the interpolation as a string', () => { - node.selectorInterpolation = '.bar'; - expect(node).toHaveInterpolation('selectorInterpolation', '.bar'); + it('assigns the selector explicitly', () => { + const selector = new SelectorList({class: 'bar'}); + node.parsedSelector = selector; + expect(node.parsedSelector).toBe(selector); }); - it('assigns the interpolation as selector', () => { + it('assigns the selector as selector', () => { node.selector = '.bar'; - expect(node).toHaveInterpolation('selectorInterpolation', '.bar'); + expect(node).toHaveNode('parsedSelector', '.bar'); }); }); @@ -245,10 +235,10 @@ describe('a style rule', () => { }); describe('has the same properties:', () => { - it('selectorInterpolation', () => - expect(clone).toHaveInterpolation('selectorInterpolation', '.foo ')); + it('parsedSelector', () => + expect(clone).toHaveNode('parsedSelector', '.foo')); - it('selector', () => expect(clone.selector).toBe('.foo ')); + it('selector', () => expect(clone.selector).toBe('.foo')); it('raws', () => expect(clone.raws).toEqual({between: ' '})); @@ -264,11 +254,7 @@ describe('a style rule', () => { describe('creates a new', () => { it('self', () => expect(clone).not.toBe(original)); - for (const attr of [ - 'selectorInterpolation', - 'raws', - 'nodes', - ] as const) { + for (const attr of ['parsedSelector', 'raws', 'nodes'] as const) { it(attr, () => expect(clone[attr]).not.toBe(original[attr])); } }); @@ -288,8 +274,8 @@ describe('a style rule', () => { it('changes selector', () => expect(clone.selector).toBe('qux')); - it('changes selectorInterpolation', () => - expect(clone).toHaveInterpolation('selectorInterpolation', 'qux')); + it('changes parsedSelector', () => + expect(clone).toHaveNode('parsedSelector', 'qux')); }); describe('undefined', () => { @@ -298,44 +284,38 @@ describe('a style rule', () => { clone = original.clone({selector: undefined}); }); - it('preserves selector', () => expect(clone.selector).toBe('.foo ')); + it('preserves selector', () => expect(clone.selector).toBe('.foo')); - it('preserves selectorInterpolation', () => - expect(clone).toHaveInterpolation( - 'selectorInterpolation', - '.foo ', - )); + it('preserves parsedSelector', () => + expect(clone).toHaveNode('parsedSelector', '.foo')); }); }); - describe('selectorInterpolation', () => { + describe('parsedSelector', () => { describe('defined', () => { let clone: Rule; beforeEach(() => { clone = original.clone({ - selectorInterpolation: new Interpolation({nodes: ['.baz']}), + parsedSelector: {class: 'baz'}, }); }); it('changes selector', () => expect(clone.selector).toBe('.baz')); - it('changes selectorInterpolation', () => - expect(clone).toHaveInterpolation('selectorInterpolation', '.baz')); + it('changes parsedSelector', () => + expect(clone).toHaveNode('parsedSelector', '.baz')); }); describe('undefined', () => { let clone: Rule; beforeEach(() => { - clone = original.clone({selectorInterpolation: undefined}); + clone = original.clone({parsedSelector: undefined}); }); - it('preserves selector', () => expect(clone.selector).toBe('.foo ')); + it('preserves selector', () => expect(clone.selector).toBe('.foo')); - it('preserves selectorInterpolation', () => - expect(clone).toHaveInterpolation( - 'selectorInterpolation', - '.foo ', - )); + it('preserves parsedSelector', () => + expect(clone).toHaveNode('parsedSelector', '.foo')); }); }); diff --git a/pkg/sass-parser/lib/src/statement/rule.ts b/pkg/sass-parser/lib/src/statement/rule.ts index eb0b3553b..b4cc4ccb0 100644 --- a/pkg/sass-parser/lib/src/statement/rule.ts +++ b/pkg/sass-parser/lib/src/statement/rule.ts @@ -5,9 +5,9 @@ import * as postcss from 'postcss'; import type {RuleRaws as PostcssRuleRaws} from 'postcss/lib/rule'; -import {Interpolation} from '../interpolation'; import {LazySource} from '../lazy-source'; -import type * as sassInternal from '../sass-internal'; +import * as sassInternal from '../sass-internal'; +import {SelectorList, SelectorListProps} from '../selector/list'; import * as utils from '../utils'; import { ChildNode, @@ -38,7 +38,7 @@ export type RuleRaws = Omit; * @category Statement */ export type RuleProps = ContainerProps & {raws?: RuleRaws} & ( - | {selectorInterpolation: Interpolation | string} + | {parsedSelector: SelectorList | SelectorListProps} | {selector: string} | {selectors: string[]} ); @@ -56,30 +56,27 @@ export class Rule extends _Rule implements Statement { declare raws: RuleRaws; get selector(): string { - return this.selectorInterpolation.toString(); + return this.parsedSelector.toString(); } set selector(value: string) { - this.selectorInterpolation = value; + this.parsedSelector = {type: value}; } - /** The interpolation that represents this rule's selector. */ - get selectorInterpolation(): Interpolation { - return this._selectorInterpolation!; + get parsedSelector(): SelectorList { + return this._selector!; } - set selectorInterpolation(selectorInterpolation: Interpolation | string) { - // TODO - postcss/postcss#1957: Mark this as dirty - if (this._selectorInterpolation) { - this._selectorInterpolation.parent = undefined; - } - if (typeof selectorInterpolation === 'string') { - selectorInterpolation = new Interpolation({ - nodes: [selectorInterpolation], - }); - } - selectorInterpolation.parent = this; - this._selectorInterpolation = selectorInterpolation; + set parsedSelector(selector: SelectorList | SelectorListProps) { + if (this._selector) this._selector.parent = undefined; + const built = + typeof selector === 'object' && + 'sassType' in selector && + selector.sassType === 'selector-list' + ? selector + : new SelectorList(selector); + built.parent = this; + this._selector = built; } - private declare _selectorInterpolation?: Interpolation; + private declare _selector?: SelectorList; constructor(defaults: RuleProps); constructor(_: undefined, inner: sassInternal.StyleRule); @@ -90,7 +87,7 @@ export class Rule extends _Rule implements Statement { super(defaults as postcss.RuleProps); if (inner) { this.source = new LazySource(inner); - this.selectorInterpolation = new Interpolation(undefined, inner.selector); + this.parsedSelector = new SelectorList(undefined, inner.parsedSelector); appendInternalChildren(this, inner.children); } } @@ -103,7 +100,7 @@ export class Rule extends _Rule implements Statement { return utils.cloneNode( this, overrides, - ['nodes', 'raws', 'selectorInterpolation'], + ['nodes', 'raws', 'parsedSelector'], ['selector', 'selectors'], ); } @@ -112,11 +109,7 @@ export class Rule extends _Rule implements Statement { /** @hidden */ toJSON(_: string, inputs: Map): object; toJSON(_?: string, inputs?: Map): object { - return utils.toJSON( - this, - ['selector', 'selectorInterpolation', 'nodes'], - inputs, - ); + return utils.toJSON(this, ['selector', 'parsedSelector', 'nodes'], inputs); } /** @hidden */ @@ -128,8 +121,8 @@ export class Rule extends _Rule implements Statement { } /** @hidden */ - get nonStatementChildren(): ReadonlyArray { - return [this.selectorInterpolation]; + get nonStatementChildren(): ReadonlyArray { + return [this.parsedSelector]; } /** @hidden */ diff --git a/pkg/sass-parser/lib/src/stringifier.ts b/pkg/sass-parser/lib/src/stringifier.ts index 8d72e7452..5a023bad5 100644 --- a/pkg/sass-parser/lib/src/stringifier.ts +++ b/pkg/sass-parser/lib/src/stringifier.ts @@ -180,7 +180,7 @@ export class Stringifier extends PostCssStringifier { node.nodes[0], '@at-root' + (node.raws.afterName ?? ' ') + - node.nodes[0].selectorInterpolation, + node.nodes[0].parsedSelector, ); return; } @@ -204,7 +204,7 @@ export class Stringifier extends PostCssStringifier { } private rule(node: Rule): void { - this.block(node, node.selectorInterpolation.toString()); + this.block(node, node.parsedSelector.toString()); } private ['sass-comment'](node: SassComment): void { diff --git a/pkg/sass-parser/lib/src/utils.ts b/pkg/sass-parser/lib/src/utils.ts index 6c017b721..5d9fbd7a2 100644 --- a/pkg/sass-parser/lib/src/utils.ts +++ b/pkg/sass-parser/lib/src/utils.ts @@ -84,7 +84,7 @@ export function cloneNode>( if ( explicitUndefined ? Object.hasOwn(typedOverrides, name) - : typedOverrides[name] + : typedOverrides[name] !== undefined ) { // This isn't actually guaranteed to be non-null, but TypeScript // (correctly) complains that we could be passing an undefined value to diff --git a/pkg/sass-parser/package.json b/pkg/sass-parser/package.json index 2512d3950..877b2f412 100644 --- a/pkg/sass-parser/package.json +++ b/pkg/sass-parser/package.json @@ -1,6 +1,6 @@ { "name": "sass-parser", - "version": "0.4.32", + "version": "0.4.33", "description": "A PostCSS-compatible wrapper of the official Sass parser", "repository": "sass/sass", "author": "Google Inc.", diff --git a/pkg/sass-parser/test/setup.ts b/pkg/sass-parser/test/setup.ts index d48e202b8..ee8786e1c 100644 --- a/pkg/sass-parser/test/setup.ts +++ b/pkg/sass-parser/test/setup.ts @@ -11,7 +11,7 @@ import type * as pretty from 'pretty-format'; import * as sass from 'sass'; import 'jest-extended'; -import {Interpolation, StringExpression} from '../lib'; +import {Interpolation, Node, StringExpression} from '../lib'; /** * Like {@link MatcherContext.printReceived}, but with special handling for AST @@ -45,11 +45,43 @@ declare global { * `nodes` property of the object being matched. */ toHaveStringExpression(property: string | number, value: string): void; + + /** + * Asserts that the object being matched has a property named {@link + * property} whose value is a {@link Node}, that that node's `toString()` + * returns {@link stringification}, and that the node's parent is the + * object being tested. + * + * If {@link property} is a number, it's treated as an index into the + * `nodes` property of the object being matched. + * + * If {@link sassType} is passed, this also asserts that it matches the + * type of the node. + */ + toHaveNode( + property: string | number, + stringification: string, + sassType?: string, + ): void; + + /** + * Asserts that the object being matched is a Sass node whose `toString()` + * returns {@link stringification}. + * + * If {@link sassType} is passed, this also asserts that it matches the + * type of the node. + */ + nodeWithToString(stringification: string, sassType?: string): void; } interface Matchers { toHaveInterpolation(property: string, value: string): R; toHaveStringExpression(property: string | number, value: string): R; + toHaveNode( + property: string | number, + stringification: string, + sassType?: string, + ): R; } } } @@ -199,6 +231,129 @@ function toHaveStringExpression( expect.extend({toHaveStringExpression}); +function toHaveNode( + this: MatcherContext, + actual: unknown, + propertyOrIndex: unknown, + value: unknown, + sassType: unknown, +): ExpectationResult { + if ( + typeof propertyOrIndex !== 'string' && + typeof propertyOrIndex !== 'number' + ) { + throw new TypeError( + `Property ${propertyOrIndex} must be a string or number.`, + ); + } else if (typeof value !== 'string') { + throw new TypeError(`Value ${value} must be a string.`); + } else if (sassType !== undefined && typeof sassType !== 'string') { + throw new TypeError(`Type ${sassType} must be a string.`); + } + + let index: number | null = null; + let property: string; + if (typeof propertyOrIndex === 'number') { + index = propertyOrIndex; + property = 'nodes'; + } else { + property = propertyOrIndex; + } + + if (typeof actual !== 'object' || !actual || !(property in actual)) { + return { + message: () => + `expected ${printValue( + this, + actual, + )} to have a property ${this.utils.printExpected(property)}`, + pass: false, + }; + } + + let actualValue = (actual as Record)[property]; + if (index !== null) actualValue = (actualValue as unknown[])[index]; + + const message = (): string => { + let message = `expected (${printValue(this, actual)}).${property}`; + if (index !== null) message += `[${index}]`; + + return ( + message + + ` ${printValue( + this, + actualValue, + )} to be a sass.Node with value ${this.utils.printExpected(value)}` + ); + }; + + if (!(actualValue instanceof Node) || actualValue.toString() !== value) { + return { + message, + pass: false, + }; + } + + if (!('parent' in actualValue) || actualValue.parent !== actual) { + return { + message: () => + `expected (${printValue(this, actual)}).${property} ${printValue( + this, + actualValue, + )} to have the correct parent`, + pass: false, + }; + } + + if (typeof sassType === 'string' && actualValue.sassType !== sassType) { + return { + message: () => + `expected (${printValue(this, actual)}).${property} ${printValue( + this, + actualValue, + )} to have sassType ${sassType}, was ${actualValue.sassType}`, + pass: false, + }; + } + + return {message, pass: true}; +} + +expect.extend({toHaveNode}); + +function nodeWithToString( + this: MatcherContext, + actual: unknown, + stringification: unknown, + sassType: unknown, +): ExpectationResult { + const message = (): string => + `expected ${printValue( + this, + actual, + )} to be a sass.Node with value ${this.utils.printExpected(stringification)}`; + + if (!(actual instanceof Node) || actual.toString() !== stringification) { + return { + message, + pass: false, + }; + } + + if (typeof sassType === 'string' && actual.sassType !== sassType) { + return { + message: () => + `expected ${printValue(this, actual)} to have sassType ${sassType}, ` + + `was ${actual.sassType}`, + pass: false, + }; + } + + return {message, pass: true}; +} + +expect.extend({nodeWithToString}); + // Serialize nodes using toJSON(), but also updating them to avoid run- or // machine-specific information in the inputs and to make sources and nested // nodes more concise. diff --git a/pkg/sass-parser/test/utils.ts b/pkg/sass-parser/test/utils.ts index 37d1b7931..ba8981ca9 100644 --- a/pkg/sass-parser/test/utils.ts +++ b/pkg/sass-parser/test/utils.ts @@ -4,12 +4,17 @@ import { AnyExpression, + AnySimpleSelector, ChildNode, ChildProps, + CompoundSelector, ExpressionProps, GenericAtRule, Interpolation, Root, + Rule, + SelectorList, + SimpleSelectorProps, scss, } from '../lib'; @@ -22,6 +27,28 @@ export function parseExpression(text: string): T { return expression; } +/** Parses selector list from {@link text}. */ +export function parseSelector(text: string): SelectorList { + const rule = scss.parse(`${text} {}`).nodes[0] as Rule; + expect(rule.type).toEqual('rule'); + return rule.parsedSelector; +} + +/** Parses simple selector from {@link text}. */ +export function parseSimpleSelector( + text: string, +): T { + const rule = scss.parse(`${text} {}`).nodes[0] as Rule; + expect(rule.type).toEqual('rule'); + expect(rule.parsedSelector.nodes).toHaveLength(1); + const complex = rule.parsedSelector.nodes[0]; + expect(complex.nodes).toHaveLength(1); + const component = complex.nodes[0]; + expect(component.combinator).toBeUndefined(); + expect(component.compound.nodes).toHaveLength(1); + return component.compound.nodes[0] as T; +} + /** Constructs a new node from {@link props} as in child node injection. */ export function fromChildProps(props: ChildProps): T { return new Root({nodes: [props]}).nodes[0] as T; @@ -33,3 +60,10 @@ export function fromExpressionProps( ): T { return new Interpolation({nodes: [props]}).nodes[0] as T; } + +/** Constructs a new simple selector from {@link props}. */ +export function fromSimpleSelectorProps( + props: SimpleSelectorProps, +): T { + return new CompoundSelector({nodes: [props]}).nodes[0] as T; +} diff --git a/pkg/sass_api/CHANGELOG.md b/pkg/sass_api/CHANGELOG.md index 9de3d228b..48ca10c84 100644 --- a/pkg/sass_api/CHANGELOG.md +++ b/pkg/sass_api/CHANGELOG.md @@ -1,3 +1,17 @@ +## 16.0.0 + +* **Breaking change:** `InterpolationMap` now takes a list of integer offsets + rather than a list of `SourceLocation` objects. + +* **Breaking change:** `AttributeSelector`'s `op`, `value`, and `modifier` + fields are now `CssValue`s rather than plain values. + +* Add `Interpolated...` versions of all the selector AST nodes that are + available via the `StyleRule.parsedSelector` field if `parseSelectors: true` + is passed to `Stylesheet.parse()`. This makes it easier for tools to interact + with selectors despite the fact that the Sass implementation doesn't parse + them until after all interpolation has been resolved. + ## 15.12.3 * No user-visible changes. diff --git a/pkg/sass_api/pubspec.yaml b/pkg/sass_api/pubspec.yaml index 14d86ffbc..0d79be0b4 100644 --- a/pkg/sass_api/pubspec.yaml +++ b/pkg/sass_api/pubspec.yaml @@ -2,7 +2,7 @@ name: sass_api # Note: Every time we add a new Sass AST node, we need to bump the *major* # version because it's a breaking change for anyone who's implementing the # visitor interface(s). -version: 15.12.3 +version: 16.0.0 description: Additional APIs for Dart Sass. homepage: https://github.com/sass/dart-sass @@ -10,7 +10,7 @@ environment: sdk: ">=3.6.0 <4.0.0" dependencies: - sass: 1.93.3 + sass: 1.93.4 dev_dependencies: dartdoc: ">=8.0.14 <10.0.0" diff --git a/pubspec.yaml b/pubspec.yaml index 72a9f73ff..08950d37d 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,5 +1,5 @@ name: sass -version: 1.93.3 +version: 1.93.4 description: A Sass implementation in Dart. homepage: https://github.com/sass/dart-sass From 32b3b625fa6f13ccd039729161c487e81180a62a Mon Sep 17 00:00:00 2001 From: Natalie Weizenbaum Date: Mon, 10 Nov 2025 12:59:00 -0800 Subject: [PATCH 2/2] Code review --- lib/src/parse/stylesheet.dart | 2 -- 1 file changed, 2 deletions(-) diff --git a/lib/src/parse/stylesheet.dart b/lib/src/parse/stylesheet.dart index bab7a25fe..6885e2954 100644 --- a/lib/src/parse/stylesheet.dart +++ b/lib/src/parse/stylesheet.dart @@ -3282,8 +3282,6 @@ abstract class StylesheetParser extends Parser { /// as whitespace. It should only be set to `true` in positions when a /// statement can't end. /// - /// If [ - /// /// Unlike [declarationValue], this allows interpolation. Interpolation _interpolatedDeclarationValue({ bool allowEmpty = false,