diff --git a/CHANGELOG.md b/CHANGELOG.md index 51ae4810d..a83ee46c2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ ## 1.54.0 +* Deprecate selectors with leading or trailing combinators, or with multiple + combinators in a row. If they're included in style rules after nesting is + resolved Sass will now produce a deprecation warning and, in most cases, omit + the selector. Leading and trailing combinators can still be freely used for + nesting purposes. + + See https://sass-lang.com/d/bogus-combinators for more details. + ### JS API * Add a `charset` option that controls whether or not Sass emits a diff --git a/lib/src/ast/css/modifiable/node.dart b/lib/src/ast/css/modifiable/node.dart index ca489df71..0f4495f32 100644 --- a/lib/src/ast/css/modifiable/node.dart +++ b/lib/src/ast/css/modifiable/node.dart @@ -5,9 +5,7 @@ import 'dart:collection'; import '../../../visitor/interface/modifiable_css.dart'; -import '../at_rule.dart'; import '../node.dart'; -import '../style_rule.dart'; /// A modifiable version of [CssNode]. /// @@ -27,36 +25,11 @@ abstract class ModifiableCssNode extends CssNode { var isGroupEnd = false; /// Whether this node has a visible sibling after it. - bool get hasFollowingSibling { - var parent = _parent; - if (parent == null) return false; - var siblings = parent.children; - for (var i = _indexInParent! + 1; i < siblings.length; i++) { - var sibling = siblings[i]; - if (!_isInvisible(sibling)) return true; - } - return false; - } - - /// Returns whether [node] is invisible for the purposes of - /// [hasFollowingSibling]. - /// - /// This can return a false negative for a comment node in compressed mode, - /// since the AST doesn't know the output style, but that's an extremely - /// narrow edge case so we don't worry about it. - bool _isInvisible(CssNode node) { - if (node is CssParentNode) { - // An unknown at-rule is never invisible. Because we don't know the - // semantics of unknown rules, we can't guarantee that (for example) - // `@foo {}` isn't meaningful. - if (node is CssAtRule) return false; - - if (node is CssStyleRule && node.selector.value.isInvisible) return true; - return node.children.every(_isInvisible); - } else { - return false; - } - } + bool get hasFollowingSibling => + _parent?.children + .skip(_indexInParent! + 1) + .any((sibling) => !sibling.isInvisible) ?? + false; T accept(ModifiableCssVisitor visitor); diff --git a/lib/src/ast/css/node.dart b/lib/src/ast/css/node.dart index 0366961ca..a69b529fd 100644 --- a/lib/src/ast/css/node.dart +++ b/lib/src/ast/css/node.dart @@ -2,9 +2,15 @@ // MIT-style license that can be found in the LICENSE file or at // https://opensource.org/licenses/MIT. +import 'package:meta/meta.dart'; + +import '../../visitor/every_css.dart'; import '../../visitor/interface/css.dart'; import '../../visitor/serialize.dart'; import '../node.dart'; +import 'at_rule.dart'; +import 'comment.dart'; +import 'style_rule.dart'; /// A statement in a plain CSS syntax tree. abstract class CssNode extends AstNode { @@ -15,6 +21,28 @@ abstract class CssNode extends AstNode { /// Calls the appropriate visit method on [visitor]. T accept(CssVisitor visitor); + /// Whether this is invisible and won't be emitted to the compiled stylesheet. + /// + /// Note that this doesn't consider nodes that contain loud comments to be + /// invisible even though they're omitted in compressed mode. + @internal + bool get isInvisible => accept( + const _IsInvisibleVisitor(includeBogus: true, includeComments: false)); + + // Whether this node would be invisible even if style rule selectors within it + // didn't have bogus combinators. + /// + /// Note that this doesn't consider nodes that contain loud comments to be + /// invisible even though they're omitted in compressed mode. + @internal + bool get isInvisibleOtherThanBogusCombinators => accept( + const _IsInvisibleVisitor(includeBogus: false, includeComments: false)); + + // Whether this node will be invisible when loud comments are stripped. + @internal + bool get isInvisibleHidingComments => accept( + const _IsInvisibleVisitor(includeBogus: true, includeComments: true)); + String toString() => serialize(this, inspect: true).css; } @@ -32,3 +60,29 @@ abstract class CssParentNode extends CssNode { /// like `@foo {}`, [children] is empty but [isChildless] is `false`. bool get isChildless; } + +/// The visitor used to implement [CssNode.isInvisible] +class _IsInvisibleVisitor extends EveryCssVisitor { + /// Whether to consider selectors with bogus combinators invisible. + final bool includeBogus; + + /// Whether to consider comments invisible. + final bool includeComments; + + const _IsInvisibleVisitor( + {required this.includeBogus, required this.includeComments}); + + // An unknown at-rule is never invisible. Because we don't know the semantics + // of unknown rules, we can't guarantee that (for example) `@foo {}` isn't + // meaningful. + bool visitCssAtRule(CssAtRule rule) => false; + + bool visitCssComment(CssComment comment) => + includeComments && !comment.isPreserved; + + bool visitCssStyleRule(CssStyleRule rule) => + (includeBogus + ? rule.selector.value.isInvisible + : rule.selector.value.isInvisibleOtherThanBogusCombinators) || + super.visitCssStyleRule(rule); +} diff --git a/lib/src/ast/selector.dart b/lib/src/ast/selector.dart index 528eef18a..e43112d67 100644 --- a/lib/src/ast/selector.dart +++ b/lib/src/ast/selector.dart @@ -4,12 +4,19 @@ import 'package:meta/meta.dart'; +import '../visitor/any_selector.dart'; import '../visitor/interface/selector.dart'; import '../visitor/serialize.dart'; +import 'selector/complex.dart'; +import 'selector/list.dart'; +import 'selector/placeholder.dart'; +import 'selector/pseudo.dart'; export 'selector/attribute.dart'; export 'selector/class.dart'; +export 'selector/combinator.dart'; export 'selector/complex.dart'; +export 'selector/complex_component.dart'; export 'selector/compound.dart'; export 'selector/id.dart'; export 'selector/list.dart'; @@ -32,11 +39,131 @@ export 'selector/universal.dart'; abstract class Selector { /// Whether this selector, and complex selectors containing it, should not be /// emitted. + /// + /// @nodoc @internal - bool get isInvisible => false; + bool get isInvisible => accept(const _IsInvisibleVisitor(includeBogus: true)); + + // Whether this selector would be invisible even if it didn't have bogus + // combinators. + /// + /// @nodoc + @internal + bool get isInvisibleOtherThanBogusCombinators => + accept(const _IsInvisibleVisitor(includeBogus: false)); + + /// Whether this selector is not valid CSS. + /// + /// This includes both selectors that are useful exclusively for build-time + /// nesting (`> .foo)` and selectors with invalid combiantors that are still + /// supported for backwards-compatibility reasons (`.foo + ~ .bar`). + bool get isBogus => + accept(const _IsBogusVisitor(includeLeadingCombinator: true)); + + /// Whether this selector is bogus other than having a leading combinator. + /// + /// @nodoc + @internal + bool get isBogusOtherThanLeadingCombinator => + accept(const _IsBogusVisitor(includeLeadingCombinator: false)); + + /// Whether this is a useless selector (that is, it's bogus _and_ it can't be + /// transformed into valid CSS by `@extend` or nesting). + /// + /// @nodoc + @internal + bool get isUseless => accept(const _IsUselessVisitor()); + + /// Prints a warning if [this] is a bogus selector. + /// + /// This may only be called from within a custom Sass function. This will + /// throw a [SassScriptException] in Dart Sass 2.0.0. + void assertNotBogus({String? name}) { + if (!isBogus) return; + warn( + (name == null ? '' : '\$$name: ') + + '$this is not valid CSS.\n' + 'This will be an error in Dart Sass 2.0.0.\n' + '\n' + 'More info: https://sass-lang.com/d/bogus-combinators', + deprecation: true); + } /// Calls the appropriate visit method on [visitor]. T accept(SelectorVisitor visitor); String toString() => serializeSelector(this, inspect: true); } + +/// The visitor used to implement [Selector.isInvisible]. +class _IsInvisibleVisitor extends AnySelectorVisitor { + /// Whether to consider selectors with bogus combinators invisible. + final bool includeBogus; + + const _IsInvisibleVisitor({required this.includeBogus}); + + bool visitSelectorList(SelectorList list) => + list.components.every(visitComplexSelector); + + bool visitComplexSelector(ComplexSelector complex) => + super.visitComplexSelector(complex) || + (includeBogus && complex.isBogusOtherThanLeadingCombinator); + + bool visitPlaceholderSelector(PlaceholderSelector placeholder) => true; + + bool visitPseudoSelector(PseudoSelector pseudo) { + var selector = pseudo.selector; + if (selector == null) return false; + + // We don't consider `:not(%foo)` to be invisible because, semantically, it + // means "doesn't match this selector that matches nothing", so it's + // equivalent to *. If the entire compound selector is composed of `:not`s + // with invisible lists, the serializer emits it as `*`. + return pseudo.name == 'not' + ? (includeBogus && selector.isBogus) + : selector.accept(this); + } +} + +/// The visitor used to implement [Selector.isBogus]. +class _IsBogusVisitor extends AnySelectorVisitor { + /// Whether to consider selectors with leading combinators as bogus. + final bool includeLeadingCombinator; + + const _IsBogusVisitor({required this.includeLeadingCombinator}); + + bool visitComplexSelector(ComplexSelector complex) { + if (complex.components.isEmpty) { + return complex.leadingCombinators.isNotEmpty; + } else { + return complex.leadingCombinators.length > + (includeLeadingCombinator ? 0 : 1) || + complex.components.last.combinators.isNotEmpty || + complex.components.any((component) => + component.combinators.length > 1 || + component.selector.accept(this)); + } + } + + bool visitPseudoSelector(PseudoSelector pseudo) { + var selector = pseudo.selector; + if (selector == null) return false; + + // The CSS spec specifically allows leading combinators in `:has()`. + return pseudo.name == 'has' + ? selector.isBogusOtherThanLeadingCombinator + : selector.isBogus; + } +} + +/// The visitor used to implement [Selector.isUseless] +class _IsUselessVisitor extends AnySelectorVisitor { + const _IsUselessVisitor(); + + bool visitComplexSelector(ComplexSelector complex) => + complex.leadingCombinators.length > 1 || + complex.components.any((component) => + component.combinators.length > 1 || component.selector.accept(this)); + + bool visitPseudoSelector(PseudoSelector pseudo) => pseudo.isBogus; +} diff --git a/lib/src/ast/selector/combinator.dart b/lib/src/ast/selector/combinator.dart new file mode 100644 index 000000000..74fa0931d --- /dev/null +++ b/lib/src/ast/selector/combinator.dart @@ -0,0 +1,31 @@ +// Copyright 2022 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:meta/meta.dart'; + +/// A combinator that defines the relationship between selectors in a +/// [ComplexSelector]. +/// +/// {@category Selector} +@sealed +class Combinator { + /// Matches the right-hand selector if it's immediately adjacent to the + /// left-hand selector in the DOM tree. + static const nextSibling = Combinator._("+"); + + /// Matches the right-hand selector if it's a direct child of the left-hand + /// selector in the DOM tree. + static const child = Combinator._(">"); + + /// Matches the right-hand selector if it comes after the left-hand selector + /// in the DOM tree. + static const followingSibling = Combinator._("~"); + + /// The combinator's token text. + final String _text; + + const Combinator._(this._text); + + String toString() => _text; +} diff --git a/lib/src/ast/selector/complex.dart b/lib/src/ast/selector/complex.dart index e7d1f32a2..b2a6b8e2c 100644 --- a/lib/src/ast/selector/complex.dart +++ b/lib/src/ast/selector/complex.dart @@ -19,9 +19,16 @@ import '../selector.dart'; /// {@category Selector} @sealed class ComplexSelector extends Selector { + /// This selector's leading combinators. + /// + /// If this is empty, that indicates that it has no leading combinator. If + /// it's more than one element, that means it's invalid CSS; however, we still + /// support this for backwards-compatibility purposes. + final List leadingCombinators; + /// The components of this selector. /// - /// This is never empty. + /// This is only empty if [leadingCombinators] is not empty. /// /// Descendant combinators aren't explicitly represented here. If two /// [CompoundSelector]s are adjacent to one another, there's an implicit @@ -59,16 +66,27 @@ class ComplexSelector extends Selector { int? _maxSpecificity; + /// If this compound selector is composed of a single compound selector with + /// no combinators, returns it. + /// + /// Otherwise, returns null. + /// /// @nodoc @internal - late final bool isInvisible = components.any( - (component) => component is CompoundSelector && component.isInvisible); - - ComplexSelector(Iterable components, + CompoundSelector? get singleCompound => leadingCombinators.isEmpty && + components.length == 1 && + components.first.combinators.isEmpty + ? components.first.selector + : null; + + ComplexSelector(Iterable leadingCombinators, + Iterable components, {this.lineBreak = false}) - : components = List.unmodifiable(components) { - if (this.components.isEmpty) { - throw ArgumentError("components may not be empty."); + : leadingCombinators = List.unmodifiable(leadingCombinators), + components = List.unmodifiable(components) { + if (this.leadingCombinators.isEmpty && this.components.isEmpty) { + throw ArgumentError( + "leadingCombinators and components may not both be empty."); } } @@ -92,6 +110,8 @@ class ComplexSelector extends Selector { /// That is, whether this matches every element that [other] matches, as well /// as possibly matching more. bool isSuperselector(ComplexSelector other) => + leadingCombinators.isEmpty && + other.leadingCombinators.isEmpty && complexIsSuperselector(components, other.components); /// Computes [_minSpecificity] and [_maxSpecificity]. @@ -99,50 +119,88 @@ class ComplexSelector extends Selector { var minSpecificity = 0; var maxSpecificity = 0; for (var component in components) { - if (component is CompoundSelector) { - minSpecificity += component.minSpecificity; - maxSpecificity += component.maxSpecificity; - } + minSpecificity += component.selector.minSpecificity; + maxSpecificity += component.selector.maxSpecificity; } _minSpecificity = minSpecificity; _maxSpecificity = maxSpecificity; } - int get hashCode => listHash(components); - - bool operator ==(Object other) => - other is ComplexSelector && listEquals(components, other.components); -} - -/// A component of a [ComplexSelector]. -/// -/// This is either a [CompoundSelector] or a [Combinator]. -/// -/// {@category Selector} -abstract class ComplexSelectorComponent {} - -/// A combinator that defines the relationship between selectors in a -/// [ComplexSelector]. -/// -/// {@category Selector} -@sealed -class Combinator implements ComplexSelectorComponent { - /// Matches the right-hand selector if it's immediately adjacent to the - /// left-hand selector in the DOM tree. - static const nextSibling = Combinator._("+"); - - /// Matches the right-hand selector if it's a direct child of the left-hand - /// selector in the DOM tree. - static const child = Combinator._(">"); + /// Returns a copy of `this` with [combinators] added to the end of the final + /// component in [components]. + /// + /// If [forceLineBreak] is `true`, this will mark the new complex selector as + /// having a line break. + /// + /// @nodoc + @internal + ComplexSelector withAdditionalCombinators(List combinators, + {bool forceLineBreak = false}) { + if (combinators.isEmpty) { + return this; + } else if (components.isEmpty) { + return ComplexSelector([...leadingCombinators, ...combinators], const [], + lineBreak: lineBreak || forceLineBreak); + } else { + return ComplexSelector( + leadingCombinators, + [ + ...components.exceptLast, + components.last.withAdditionalCombinators(combinators) + ], + lineBreak: lineBreak || forceLineBreak); + } + } - /// Matches the right-hand selector if it comes after the left-hand selector - /// in the DOM tree. - static const followingSibling = Combinator._("~"); + /// Returns a copy of `this` with an additional [component] added to the end. + /// + /// If [forceLineBreak] is `true`, this will mark the new complex selector as + /// having a line break. + /// + /// @nodoc + @internal + ComplexSelector withAdditionalComponent(ComplexSelectorComponent component, + {bool forceLineBreak = false}) => + ComplexSelector(leadingCombinators, [...components, component], + lineBreak: lineBreak || forceLineBreak); - /// The combinator's token text. - final String _text; + /// Returns a copy of `this` with [child]'s combinators added to the end. + /// + /// If [child] has [leadingCombinators], they're appended to `this`'s last + /// combinator. This does _not_ resolve parent selectors. + /// + /// If [forceLineBreak] is `true`, this will mark the new complex selector as + /// having a line break. + /// + /// @nodoc + @internal + ComplexSelector concatenate(ComplexSelector child, + {bool forceLineBreak = false}) { + if (child.leadingCombinators.isEmpty) { + return ComplexSelector( + leadingCombinators, [...components, ...child.components], + lineBreak: lineBreak || child.lineBreak || forceLineBreak); + } else if (components.isEmpty) { + return ComplexSelector( + [...leadingCombinators, ...child.leadingCombinators], + child.components, + lineBreak: lineBreak || child.lineBreak || forceLineBreak); + } else { + return ComplexSelector( + leadingCombinators, + [ + ...components.exceptLast, + components.last.withAdditionalCombinators(child.leadingCombinators), + ...child.components + ], + lineBreak: lineBreak || child.lineBreak || forceLineBreak); + } + } - const Combinator._(this._text); + int get hashCode => listHash(leadingCombinators) ^ listHash(components); - String toString() => _text; + bool operator ==(Object other) => + other is ComplexSelector && + listEquals(leadingCombinators, other.leadingCombinators) && + listEquals(components, other.components); } diff --git a/lib/src/ast/selector/complex_component.dart b/lib/src/ast/selector/complex_component.dart new file mode 100644 index 000000000..f6abe9146 --- /dev/null +++ b/lib/src/ast/selector/complex_component.dart @@ -0,0 +1,52 @@ +// Copyright 2022 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:meta/meta.dart'; + +import '../../utils.dart'; +import '../selector.dart'; + +/// A component of a [ComplexSelector]. +/// +/// This a [CompoundSelector] with one or more trailing [Combinator]s. +/// +/// {@category Selector} +@sealed +class ComplexSelectorComponent { + /// This component's compound selector. + final CompoundSelector selector; + + /// This selector's combinators. + /// + /// If this is empty, that indicates that it has an implicit descendent + /// combinator. If it's more than one element, that means it's invalid CSS; + /// however, we still support this for backwards-compatibility purposes. + final List combinators; + + ComplexSelectorComponent(this.selector, Iterable combinators) + : combinators = List.unmodifiable(combinators); + + /// Returns a copy of `this` with [combinators] added to the end of + /// [this.combinators]. + /// + /// @nodoc + @internal + ComplexSelectorComponent withAdditionalCombinators( + List combinators) => + combinators.isEmpty + ? this + : ComplexSelectorComponent( + selector, [...this.combinators, ...combinators]); + + int get hashCode => selector.hashCode ^ listHash(combinators); + + bool operator ==(Object other) => + other is ComplexSelectorComponent && + selector == other.selector && + listEquals(combinators, other.combinators); + + String toString() => + selector.toString() + + combinators.map((combinator) => ' $combinator').join(''); +} diff --git a/lib/src/ast/selector/compound.dart b/lib/src/ast/selector/compound.dart index 22e58c2f3..f9c70f34a 100644 --- a/lib/src/ast/selector/compound.dart +++ b/lib/src/ast/selector/compound.dart @@ -18,7 +18,7 @@ import '../selector.dart'; /// /// {@category Selector} @sealed -class CompoundSelector extends Selector implements ComplexSelectorComponent { +class CompoundSelector extends Selector { /// The components of this selector. /// /// This is never empty. @@ -46,9 +46,15 @@ class CompoundSelector extends Selector implements ComplexSelectorComponent { int? _maxSpecificity; + /// If this compound selector is composed of a single simple selector, returns + /// it. + /// + /// Otherwise, returns null. + /// /// @nodoc @internal - bool get isInvisible => components.any((component) => component.isInvisible); + SimpleSelector? get singleSimple => + components.length == 1 ? components.first : null; CompoundSelector(Iterable components) : components = List.unmodifiable(components) { diff --git a/lib/src/ast/selector/list.dart b/lib/src/ast/selector/list.dart index 72e1a0054..d21a755c4 100644 --- a/lib/src/ast/selector/list.dart +++ b/lib/src/ast/selector/list.dart @@ -30,19 +30,20 @@ class SelectorList extends Selector { bool get _containsParentSelector => components.any(_complexContainsParentSelector); - /// @nodoc - @internal - bool get isInvisible => components.every((complex) => complex.isInvisible); - /// Returns a SassScript list that represents this selector. /// /// This has the same format as a list returned by `selector-parse()`. SassList get asSassList { return SassList(components.map((complex) { - return SassList( - complex.components.map( - (component) => SassString(component.toString(), quotes: false)), - ListSeparator.space); + return SassList([ + for (var combinator in complex.leadingCombinators) + SassString(combinator.toString(), quotes: false), + for (var component in complex.components) ...[ + SassString(component.selector.toString(), quotes: false), + for (var combinator in component.combinators) + SassString(combinator.toString(), quotes: false) + ] + ], ListSeparator.space); }), ListSeparator.comma); } @@ -79,13 +80,11 @@ class SelectorList extends Selector { /// /// If no such list can be produced, returns `null`. SelectorList? unify(SelectorList other) { - var contents = components.expand((complex1) { - return other.components.expand((complex2) { - var unified = unifyComplex([complex1.components, complex2.components]); - if (unified == null) return const []; - return unified.map((complex) => ComplexSelector(complex)); - }); - }).toList(); + var contents = [ + for (var complex1 in components) + for (var complex2 in other.components) + ...?unifyComplex([complex1, complex2]) + ]; return contents.isEmpty ? null : SelectorList(contents); } @@ -109,77 +108,68 @@ class SelectorList extends Selector { return SelectorList(flattenVertically(components.map((complex) { if (!_complexContainsParentSelector(complex)) { if (!implicitParent) return [complex]; - return parent.components.map((parentComplex) => ComplexSelector( - [...parentComplex.components, ...complex.components], - lineBreak: complex.lineBreak || parentComplex.lineBreak)); + return parent.components + .map((parentComplex) => parentComplex.concatenate(complex)); } - var newComplexes = [[]]; - var lineBreaks = [false]; + var newComplexes = []; for (var component in complex.components) { - if (component is CompoundSelector) { - var resolved = _resolveParentSelectorsCompound(component, parent); - if (resolved == null) { - for (var newComplex in newComplexes) { - newComplex.add(component); - } - continue; - } - - var previousComplexes = newComplexes; - var previousLineBreaks = lineBreaks; - newComplexes = >[]; - lineBreaks = []; - var i = 0; - for (var newComplex in previousComplexes) { - var lineBreak = previousLineBreaks[i++]; - for (var resolvedComplex in resolved) { - newComplexes.add([...newComplex, ...resolvedComplex.components]); - lineBreaks.add(lineBreak || resolvedComplex.lineBreak); + var resolved = _resolveParentSelectorsCompound(component, parent); + if (resolved == null) { + if (newComplexes.isEmpty) { + newComplexes.add(ComplexSelector( + complex.leadingCombinators, [component], + lineBreak: false)); + } else { + for (var i = 0; i < newComplexes.length; i++) { + newComplexes[i] = + newComplexes[i].withAdditionalComponent(component); } } + } else if (newComplexes.isEmpty) { + newComplexes.addAll(resolved); } else { - for (var newComplex in newComplexes) { - newComplex.add(component); - } + var previousComplexes = newComplexes; + newComplexes = [ + for (var newComplex in previousComplexes) + for (var resolvedComplex in resolved) + newComplex.concatenate(resolvedComplex) + ]; } } - var i = 0; - return newComplexes.map((newComplex) => - ComplexSelector(newComplex, lineBreak: lineBreaks[i++])); + return newComplexes; }))); } /// Returns whether [complex] contains a [ParentSelector]. bool _complexContainsParentSelector(ComplexSelector complex) => - complex.components.any((component) => - component is CompoundSelector && - component.components.any((simple) { - if (simple is ParentSelector) return true; - if (simple is! PseudoSelector) return false; - var selector = simple.selector; - return selector != null && selector._containsParentSelector; - })); - - /// Returns a new [CompoundSelector] based on [compound] with all + complex.components + .any((component) => component.selector.components.any((simple) { + if (simple is ParentSelector) return true; + if (simple is! PseudoSelector) return false; + var selector = simple.selector; + return selector != null && selector._containsParentSelector; + })); + + /// Returns a new selector list based on [component] with all /// [ParentSelector]s replaced with [parent]. /// - /// Returns `null` if [compound] doesn't contain any [ParentSelector]s. + /// Returns `null` if [component] doesn't contain any [ParentSelector]s. Iterable? _resolveParentSelectorsCompound( - CompoundSelector compound, SelectorList parent) { - var containsSelectorPseudo = compound.components.any((simple) { + ComplexSelectorComponent component, SelectorList parent) { + var simples = component.selector.components; + var containsSelectorPseudo = simples.any((simple) { if (simple is! PseudoSelector) return false; var selector = simple.selector; return selector != null && selector._containsParentSelector; }); - if (!containsSelectorPseudo && - compound.components.first is! ParentSelector) { + if (!containsSelectorPseudo && simples.first is! ParentSelector) { return null; } - var resolvedMembers = containsSelectorPseudo - ? compound.components.map((simple) { + var resolvedSimples = containsSelectorPseudo + ? simples.map((simple) { if (simple is! PseudoSelector) return simple; var selector = simple.selector; if (selector == null) return simple; @@ -187,41 +177,43 @@ class SelectorList extends Selector { return simple.withSelector( selector.resolveParentSelectors(parent, implicitParent: false)); }) - : compound.components; + : simples; - var parentSelector = compound.components.first; - if (parentSelector is ParentSelector) { - if (compound.components.length == 1 && parentSelector.suffix == null) { - return parent.components; - } - } else { + var parentSelector = simples.first; + if (parentSelector is! ParentSelector) { return [ - ComplexSelector([CompoundSelector(resolvedMembers)]) + ComplexSelector(const [], [ + ComplexSelectorComponent( + CompoundSelector(resolvedSimples), component.combinators) + ]) ]; + } else if (simples.length == 1 && parentSelector.suffix == null) { + return parent.withAdditionalCombinators(component.combinators).components; } return parent.components.map((complex) { var lastComponent = complex.components.last; - if (lastComponent is! CompoundSelector) { + if (lastComponent.combinators.isNotEmpty) { throw SassScriptException( 'Parent "$complex" is incompatible with this selector.'); } - var last = lastComponent; - var suffix = (compound.components.first as ParentSelector).suffix; - if (suffix != null) { - last = CompoundSelector([ - ...last.components.take(last.components.length - 1), - last.components.last.addSuffix(suffix), - ...resolvedMembers.skip(1) - ]); - } else { - last = - CompoundSelector([...last.components, ...resolvedMembers.skip(1)]); - } + var suffix = parentSelector.suffix; + var lastSimples = lastComponent.selector.components; + var last = CompoundSelector(suffix == null + ? [...lastSimples, ...resolvedSimples.skip(1)] + : [ + ...lastSimples.exceptLast, + lastSimples.last.addSuffix(suffix), + ...resolvedSimples.skip(1) + ]); return ComplexSelector( - [...complex.components.take(complex.components.length - 1), last], + complex.leadingCombinators, + [ + ...complex.components.exceptLast, + ComplexSelectorComponent(last, component.combinators) + ], lineBreak: complex.lineBreak); }); } @@ -233,6 +225,15 @@ class SelectorList extends Selector { bool isSuperselector(SelectorList other) => listIsSuperselector(components, other.components); + /// Returns a copy of `this` with [combinators] added to the end of each + /// complex selector in [components]. + @internal + SelectorList withAdditionalCombinators(List combinators) => + combinators.isEmpty + ? this + : SelectorList(components.map( + (complex) => complex.withAdditionalCombinators(combinators))); + int get hashCode => listHash(components); bool operator ==(Object other) => diff --git a/lib/src/ast/selector/placeholder.dart b/lib/src/ast/selector/placeholder.dart index ffdf7d9a9..53be6eefd 100644 --- a/lib/src/ast/selector/placeholder.dart +++ b/lib/src/ast/selector/placeholder.dart @@ -20,10 +20,6 @@ class PlaceholderSelector extends SimpleSelector { /// The name of the placeholder. final String name; - /// @nodoc - @internal - bool get isInvisible => true; - /// Returns whether this is a private selector (that is, whether it begins /// with `-` or `_`). bool get isPrivate => character.isPrivate(name); diff --git a/lib/src/ast/selector/pseudo.dart b/lib/src/ast/selector/pseudo.dart index df20dc0c6..c8f7927bf 100644 --- a/lib/src/ast/selector/pseudo.dart +++ b/lib/src/ast/selector/pseudo.dart @@ -93,19 +93,6 @@ class PseudoSelector extends SimpleSelector { int? _maxSpecificity; - /// @nodoc - @internal - bool get isInvisible { - var selector = this.selector; - if (selector == null) return false; - - // We don't consider `:not(%foo)` to be invisible because, semantically, it - // means "doesn't match this selector that matches nothing", so it's - // equivalent to *. If the entire compound selector is composed of `:not`s - // with invisible lists, the serializer emits it as `*`. - return name != 'not' && selector.isInvisible; - } - PseudoSelector(this.name, {bool element = false, this.argument, this.selector}) : isClass = !element && !_isFakePseudoElement(name), diff --git a/lib/src/extend/extension_store.dart b/lib/src/extend/extension_store.dart index 316dd1718..82d6a9f1a 100644 --- a/lib/src/extend/extension_store.dart +++ b/lib/src/extend/extension_store.dart @@ -101,10 +101,10 @@ class ExtensionStore { } for (var complex in targets.components) { - if (complex.components.length != 1) { + var compound = complex.singleCompound; + if (compound == null) { throw SassScriptException("Can't extend complex selector $complex."); } - var compound = complex.components.first as CompoundSelector; selector = extender._extendList(selector, span, { for (var simple in compound.components) @@ -210,9 +210,7 @@ class ExtensionStore { SelectorList list, ModifiableCssValue selector) { for (var complex in list.components) { for (var component in complex.components) { - if (component is! CompoundSelector) continue; - - for (var simple in component.components) { + for (var simple in component.selector.components) { _selectors.putIfAbsent(simple, () => {}).add(selector); if (simple is! PseudoSelector) continue; @@ -243,6 +241,8 @@ class ExtensionStore { Map? newExtensions; var sources = _extensions.putIfAbsent(target, () => {}); for (var complex in extender.value.components) { + if (complex.isUseless) continue; + var extension = Extension(complex, extender.span, target, extend.span, mediaContext: mediaContext, optional: extend.isOptional); @@ -289,16 +289,14 @@ class ExtensionStore { /// Returns an iterable of all simple selectors in [complex] Iterable _simpleSelectors(ComplexSelector complex) sync* { for (var component in complex.components) { - if (component is CompoundSelector) { - for (var simple in component.components) { - yield simple; - - if (simple is! PseudoSelector) continue; - var selector = simple.selector; - if (selector == null) continue; - for (var complex in selector.components) { - yield* _simpleSelectors(complex); - } + for (var simple in component.selector.components) { + yield simple; + + if (simple is! PseudoSelector) continue; + var selector = simple.selector; + if (selector == null) continue; + for (var complex in selector.components) { + yield* _simpleSelectors(complex); } } } @@ -360,12 +358,10 @@ class ExtensionStore { sources[complex] = withExtender; for (var component in complex.components) { - if (component is CompoundSelector) { - for (var simple in component.components) { - _extensionsByExtender - .putIfAbsent(simple, () => []) - .add(withExtender); - } + for (var simple in component.selector.components) { + _extensionsByExtender + .putIfAbsent(simple, () => []) + .add(withExtender); } } @@ -492,6 +488,10 @@ class ExtensionStore { var complex = list.components[i]; var result = _extendComplex(complex, listSpan, extensions, mediaQueryContext); + assert( + result?.isNotEmpty ?? true, + '_extendComplex($complex) should return null rather than [] if ' + 'extension fails'); if (result == null) { if (extended != null) extended.add(complex); } else { @@ -511,6 +511,8 @@ class ExtensionStore { FileSpan complexSpan, Map> extensions, List? mediaQueryContext) { + if (complex.leadingCombinators.length > 1) return null; + // The complex selectors that each compound selector in [complex.components] // can expand to. // @@ -532,39 +534,50 @@ class ExtensionStore { var isOriginal = _originals.contains(complex); for (var i = 0; i < complex.components.length; i++) { var component = complex.components[i]; - if (component is CompoundSelector) { - var extended = _extendCompound( - component, complexSpan, extensions, mediaQueryContext, - inOriginal: isOriginal); - if (extended == null) { - extendedNotExpanded?.add([ - ComplexSelector([component]) - ]); - } else { - extendedNotExpanded ??= complex.components - .take(i) - .map((component) => [ - ComplexSelector([component], lineBreak: complex.lineBreak) - ]) - .toList(); - extendedNotExpanded.add(extended); - } - } else { + var extended = _extendCompound( + component, complexSpan, extensions, mediaQueryContext, + inOriginal: isOriginal); + assert( + extended?.isNotEmpty ?? true, + '_extendCompound($component) should return null rather than [] if ' + 'extension fails'); + if (extended == null) { extendedNotExpanded?.add([ - ComplexSelector([component]) + ComplexSelector(const [], [component], lineBreak: complex.lineBreak) ]); + } else if (extendedNotExpanded != null) { + extendedNotExpanded.add(extended); + } else if (i != 0) { + extendedNotExpanded = [ + [ + ComplexSelector( + complex.leadingCombinators, complex.components.take(i), + lineBreak: complex.lineBreak) + ], + extended + ]; + } else if (complex.leadingCombinators.isEmpty) { + extendedNotExpanded = [extended]; + } else { + extendedNotExpanded = [ + [ + for (var newComplex in extended) + if (newComplex.leadingCombinators.isEmpty || + listEquals(complex.leadingCombinators, + newComplex.leadingCombinators)) + ComplexSelector( + complex.leadingCombinators, newComplex.components, + lineBreak: complex.lineBreak || newComplex.lineBreak) + ] + ]; } } if (extendedNotExpanded == null) return null; var first = true; return paths(extendedNotExpanded).expand((path) { - return weave(path.map((complex) => complex.components).toList()) - .map((components) { - var outputComplex = ComplexSelector(components, - lineBreak: complex.lineBreak || - path.any((inputComplex) => inputComplex.lineBreak)); - + return weave(path, forceLineBreak: complex.lineBreak) + .map((outputComplex) { // Make sure that copies of [complex] retain their status as "original" // selectors. This includes selectors that are modified because a :not() // was extended into. @@ -578,14 +591,17 @@ class ExtensionStore { }).toList(); } - /// Extends [compound] using [extensions], and returns the contents of a + /// Extends [component] using [extensions], and returns the contents of a /// [SelectorList]. /// /// The [inOriginal] parameter indicates whether this is in an original /// complex selector, meaning that [compound] should not be trimmed out. + /// + /// The [lineBreak] parameter indicates whether [component] appears in a + /// complex selector with a line break. List? _extendCompound( - CompoundSelector compound, - FileSpan compoundSpan, + ComplexSelectorComponent component, + FileSpan componentSpan, Map> extensions, List? mediaQueryContext, {required bool inOriginal}) { @@ -595,21 +611,25 @@ class ExtensionStore { ? null : {}; - // The complex selectors produced from each component of [compound]. + var simples = component.selector.components; + + // The complex selectors produced from each simple selector in [compound]. List>? options; - for (var i = 0; i < compound.components.length; i++) { - var simple = compound.components[i]; + for (var i = 0; i < simples.length; i++) { + var simple = simples[i]; var extended = _extendSimple( - simple, compoundSpan, extensions, mediaQueryContext, targetsUsed); + simple, componentSpan, extensions, mediaQueryContext, targetsUsed); + assert( + extended?.isNotEmpty ?? true, + '_extendSimple($simple) should return null rather than [] if ' + 'extension fails'); if (extended == null) { - options?.add([_extenderForSimple(simple, compoundSpan)]); + options?.add([_extenderForSimple(simple, componentSpan)]); } else { if (options == null) { options = []; if (i != 0) { - options.add([ - _extenderForCompound(compound.components.take(i), compoundSpan) - ]); + options.add([_extenderForCompound(simples.take(i), componentSpan)]); } } @@ -619,7 +639,7 @@ class ExtensionStore { if (options == null) return null; // If [_mode] isn't [ExtendMode.normal] and we didn't use all the targets in - // [extensions], extension fails for [compound]. + // [extensions], extension fails for [component]. if (targetsUsed != null && targetsUsed.length != extensions.length) { return null; } @@ -627,10 +647,16 @@ class ExtensionStore { // Optimize for the simple case of a single simple selector that doesn't // need any unification. if (options.length == 1) { - return options.first.map((extender) { + List? result; + for (var extender in options.first) { extender.assertCompatibleMediaContext(mediaQueryContext); - return extender.selector; - }).toList(); + var complex = + extender.selector.withAdditionalCombinators(component.combinators); + if (complex.isUseless) continue; + result ??= []; + result.add(complex); + } + return result; } // Find all paths through [options]. In this case, each path represents a @@ -667,60 +693,31 @@ class ExtensionStore { // .w .y .x.z, // .y .w .x.z // ] - var first = _mode != ExtendMode.replace; - var result = paths(options) - .map((path) { - List>? complexes; - if (first) { - // The first path is always the original selector. We can't just - // return [compound] directly because pseudo selectors may be - // modified, but we don't have to do any unification. - first = false; - complexes = [ - [ - CompoundSelector(path.expand((extender) { - assert(extender.selector.components.length == 1); - return (extender.selector.components.last as CompoundSelector) - .components; - })) - ] - ]; - } else { - var toUnify = QueueList>(); - List? originals; - for (var extender in path) { - if (extender.isOriginal) { - originals ??= []; - originals.addAll( - (extender.selector.components.last as CompoundSelector) - .components); - } else { - toUnify.add(extender.selector.components); - } - } - - if (originals != null) { - toUnify.addFirst([CompoundSelector(originals)]); - } - - complexes = unifyComplex(toUnify); - if (complexes == null) return null; - } - - var lineBreak = false; - for (var extender in path) { - extender.assertCompatibleMediaContext(mediaQueryContext); - lineBreak = lineBreak || extender.selector.lineBreak; - } - - return complexes - .map((components) => - ComplexSelector(components, lineBreak: lineBreak)) - .toList(); - }) - .whereNotNull() - .expand((l) => l) - .toList(); + var extenderPaths = paths(options); + var result = [ + if (_mode != ExtendMode.replace) + // The first path is always the original selector. We can't just return + // [component] directly because selector pseudos may be modified, but we + // don't have to do any unification. + ComplexSelector(const [], [ + ComplexSelectorComponent( + CompoundSelector(extenderPaths.first.expand((extender) { + assert(extender.selector.components.length == 1); + return extender.selector.components.last.selector.components; + })), component.combinators) + ]) + ]; + + for (var path in extenderPaths.skip(_mode == ExtendMode.replace ? 0 : 1)) { + var extended = _unifyExtenders(path, mediaQueryContext); + if (extended == null) continue; + + for (var complex in extended) { + var withCombinators = + complex.withAdditionalCombinators(component.combinators); + if (!withCombinators.isUseless) result.add(withCombinators); + } + } // If we're preserving the original selector, mark the first unification as // such so [_trim] doesn't get rid of it. @@ -733,6 +730,48 @@ class ExtensionStore { return _trim(result, isOriginal); } + /// Returns a list of [ComplexSelector]s that match the intersection of + /// elements matched by all of [extenders]' selectors. + List? _unifyExtenders( + List extenders, List? mediaQueryContext) { + var toUnify = QueueList(); + List? originals; + var originalsLineBreak = false; + for (var extender in extenders) { + if (extender.isOriginal) { + originals ??= []; + var finalExtenderComponent = extender.selector.components.last; + assert(finalExtenderComponent.combinators.isEmpty); + originals.addAll(finalExtenderComponent.selector.components); + originalsLineBreak = originalsLineBreak || extender.selector.lineBreak; + } else if (extender.selector.isUseless) { + return null; + } else { + toUnify.add(extender.selector); + } + } + + if (originals != null) { + toUnify.addFirst(ComplexSelector(const [], [ + ComplexSelectorComponent(CompoundSelector(originals), const []) + ], lineBreak: originalsLineBreak)); + } + + var complexes = unifyComplex(toUnify); + if (complexes == null) return null; + + for (var extender in extenders) { + extender.assertCompatibleMediaContext(mediaQueryContext); + } + + return complexes; + } + + /// Returns the [Extender]s from [extensions] that that should replace + /// [simple], or `null` if it's not the target of an extension. + /// + /// Each element of the returned iterable is a list of choices, which will be + /// combined using [paths]. Iterable>? _extendSimple( SimpleSelector simple, FileSpan simpleSpan, @@ -769,14 +808,18 @@ class ExtensionStore { Extender _extenderForCompound( Iterable simples, FileSpan span) { var compound = CompoundSelector(simples); - return Extender(ComplexSelector([compound]), span, - specificity: _sourceSpecificityFor(compound), original: true); + return Extender( + ComplexSelector( + const [], [ComplexSelectorComponent(compound, const [])]), + span, + specificity: _sourceSpecificityFor(compound), + original: true); } /// Returns an [Extender] composed solely of [simple]. Extender _extenderForSimple(SimpleSelector simple, FileSpan span) => Extender( - ComplexSelector([ - CompoundSelector([simple]) + ComplexSelector(const [], [ + ComplexSelectorComponent(CompoundSelector([simple]), const []) ]), span, specificity: _sourceSpecificity[simple] ?? 0, @@ -814,12 +857,8 @@ class ExtensionStore { } complexes = complexes.expand((complex) { - if (complex.components.length != 1) return [complex]; - if (complex.components.first is! CompoundSelector) return [complex]; - var compound = complex.components.first as CompoundSelector; - if (compound.components.length != 1) return [complex]; - if (compound.components.first is! PseudoSelector) return [complex]; - var innerPseudo = compound.components.first as PseudoSelector; + var innerPseudo = complex.singleCompound?.singleSimple; + if (innerPseudo is! PseudoSelector) return [complex]; var innerSelector = innerPseudo.selector; if (innerSelector == null) return [complex]; @@ -922,10 +961,8 @@ class ExtensionStore { // greater or equal to this. var maxSpecificity = 0; for (var component in complex1.components) { - if (component is CompoundSelector) { - maxSpecificity = - math.max(maxSpecificity, _sourceSpecificityFor(component)); - } + maxSpecificity = + math.max(maxSpecificity, _sourceSpecificityFor(component.selector)); } // Look in [result] rather than [selectors] for selectors after [i]. This diff --git a/lib/src/extend/functions.dart b/lib/src/extend/functions.dart index 8afb3112b..2f7c5b33f 100644 --- a/lib/src/extend/functions.dart +++ b/lib/src/extend/functions.dart @@ -31,37 +31,67 @@ final _subselectorPseudos = { }; /// Returns the contents of a [SelectorList] that matches only elements that are -/// matched by both [complex1] and [complex2]. +/// matched by every complex selector in [complexes]. /// /// If no such list can be produced, returns `null`. -List>? unifyComplex( - List> complexes) { - assert(complexes.isNotEmpty); - +List? unifyComplex(List complexes) { if (complexes.length == 1) return complexes; List? unifiedBase; + Combinator? leadingCombinator; + Combinator? trailingCombinator; for (var complex in complexes) { - var base = complex.last; - if (base is! CompoundSelector) return null; + if (complex.isUseless) return null; + + if (complex.components.length == 1 && + complex.leadingCombinators.isNotEmpty) { + var newLeadingCombinator = complex.leadingCombinators.single; + if (leadingCombinator != null && + leadingCombinator != newLeadingCombinator) { + return null; + } + leadingCombinator = newLeadingCombinator; + } + + var base = complex.components.last; + if (base.combinators.isNotEmpty) { + var newTrailingCombinator = base.combinators.single; + if (trailingCombinator != null && + trailingCombinator != newTrailingCombinator) { + return null; + } + trailingCombinator = newTrailingCombinator; + } - assert(base.components.isNotEmpty); if (unifiedBase == null) { - unifiedBase = base.components; + unifiedBase = base.selector.components; } else { - for (var simple in base.components) { + for (var simple in base.selector.components) { unifiedBase = simple.unify(unifiedBase!); // dart-lang/sdk#45348 if (unifiedBase == null) return null; } } } - var complexesWithoutBases = complexes - .map((complex) => complex.sublist(0, complex.length - 1)) - .toList(); - // By the time we make it here, [unifiedBase] must be non-null. - complexesWithoutBases.last.add(CompoundSelector(unifiedBase!)); - return weave(complexesWithoutBases); + var withoutBases = [ + for (var complex in complexes) + if (complex.components.length > 1) + ComplexSelector( + complex.leadingCombinators, complex.components.exceptLast, + lineBreak: complex.lineBreak), + ]; + + var base = ComplexSelector( + leadingCombinator == null ? const [] : [leadingCombinator], + [ + ComplexSelectorComponent(CompoundSelector(unifiedBase!), + trailingCombinator == null ? const [] : [trailingCombinator]) + ], + lineBreak: complexes.any((complex) => complex.lineBreak)); + + return weave(withoutBases.isEmpty + ? [base] + : [...withoutBases.exceptLast, withoutBases.last.concatenate(base)]); } /// Returns a [CompoundSelector] that matches only elements that are matched by @@ -142,70 +172,86 @@ SimpleSelector? unifyUniversalAndElement( /// also be required, but including merged selectors results in exponential /// output for very little gain. /// -/// The selector `.D (.A .B)` is represented as the list `[[.D], [.A, .B]]`. -List> weave( - List> complexes) { - var prefixes = [complexes.first.toList()]; +/// The selector `.D (.A .B)` is represented as the list `[.D, .A .B]`. +/// +/// If [forceLineBreak] is `true`, this will mark all returned complex selectors +/// as having line breaks. +List weave(List complexes, + {bool forceLineBreak = false}) { + if (complexes.length == 1) { + var complex = complexes.first; + if (!forceLineBreak || complex.lineBreak) return complexes; + return [ + ComplexSelector(complex.leadingCombinators, complex.components, + lineBreak: true) + ]; + } - for (var complex in complexes.skip(1)) { - if (complex.isEmpty) continue; + var prefixes = [complexes.first]; - var target = complex.last; - if (complex.length == 1) { - for (var prefix in prefixes) { - prefix.add(target); + for (var complex in complexes.skip(1)) { + var target = complex.components.last; + if (complex.components.length == 1) { + for (var i = 0; i < prefixes.length; i++) { + prefixes[i] = + prefixes[i].concatenate(complex, forceLineBreak: forceLineBreak); } continue; } - var parents = complex.take(complex.length - 1).toList(); - var newPrefixes = >[]; - for (var prefix in prefixes) { - var parentPrefixes = _weaveParents(prefix, parents); - if (parentPrefixes == null) continue; - - for (var parentPrefix in parentPrefixes) { - newPrefixes.add(parentPrefix..add(target)); - } - } - prefixes = newPrefixes; + prefixes = [ + for (var prefix in prefixes) + for (var parentPrefix + in _weaveParents(prefix, complex) ?? const []) + parentPrefix.withAdditionalComponent(target, + forceLineBreak: forceLineBreak), + ]; } return prefixes; } -/// Interweaves [parents1] and [parents2] as parents of the same target selector. +/// Interweaves [prefix]'s components with [base]'s components _other than +/// the last_. /// /// Returns all possible orderings of the selectors in the inputs (including /// using unification) that maintain the relative ordering of the input. For -/// example, given `.foo .bar` and `.baz .bang`, this would return `.foo .bar -/// .baz .bang`, `.foo .bar.baz .bang`, `.foo .baz .bar .bang`, `.foo .baz -/// .bar.bang`, `.foo .baz .bang .bar`, and so on until `.baz .bang .foo .bar`. +/// example, given `.foo .bar` and `.baz .bang div`, this would return `.foo +/// .bar .baz .bang div`, `.foo .bar.baz .bang div`, `.foo .baz .bar .bang div`, +/// `.foo .baz .bar.bang div`, `.foo .baz .bang .bar div`, and so on until `.baz +/// .bang .foo .bar div`. +/// +/// Semantically, for selectors `P` and `C`, this returns all selectors `PC_i` +/// such that the union over all `i` of elements matched by `PC_i` is identical +/// to the intersection of all elements matched by `C` and all descendants of +/// elements matched by `P`. Some `PC_i` are elided to reduce the size of the +/// output. /// -/// Semantically, for selectors A and B, this returns all selectors `AB_i` -/// such that the union over all i of elements matched by `AB_i X` is -/// identical to the intersection of all elements matched by `A X` and all -/// elements matched by `B X`. Some `AB_i` are elided to reduce the size of -/// the output. -Iterable>? _weaveParents( - List parents1, - List parents2) { - var queue1 = Queue.of(parents1); - var queue2 = Queue.of(parents2); - - var initialCombinators = _mergeInitialCombinators(queue1, queue2); - if (initialCombinators == null) return null; - var finalCombinators = _mergeFinalCombinators(queue1, queue2); - if (finalCombinators == null) return null; +/// Returns `null` if this intersection is empty. +Iterable? _weaveParents( + ComplexSelector prefix, ComplexSelector base) { + var leadingCombinators = _mergeLeadingCombinators( + prefix.leadingCombinators, base.leadingCombinators); + if (leadingCombinators == null) return null; + + // Make queues of _only_ the parent selectors. The prefix only contains + // parents, but the complex selector has a target that we don't want to weave + // in. + var queue1 = Queue.of(prefix.components); + var queue2 = Queue.of(base.components.exceptLast); + + var trailingCombinators = _mergeTrailingCombinators(queue1, queue2); + if (trailingCombinators == null) return null; // Make sure there's at most one `:root` in the output. var root1 = _firstIfRoot(queue1); var root2 = _firstIfRoot(queue2); if (root1 != null && root2 != null) { - var root = unifyCompound(root1.components, root2.components); + var root = + unifyCompound(root1.selector.components, root2.selector.components); if (root == null) return null; - queue1.addFirst(root); - queue2.addFirst(root); + queue1.addFirst(ComplexSelectorComponent(root, root1.combinators)); + queue2.addFirst(ComplexSelectorComponent(root, root2.combinators)); } else if (root1 != null) { queue2.addFirst(root1); } else if (root2 != null) { @@ -217,159 +263,126 @@ Iterable>? _weaveParents( var lcs = longestCommonSubsequence>( groups2, groups1, select: (group1, group2) { if (listEquals(group1, group2)) return group1; - if (group1.first is! CompoundSelector || - group2.first is! CompoundSelector) { - return null; - } - if (complexIsParentSuperselector(group1, group2)) return group2; - if (complexIsParentSuperselector(group2, group1)) return group1; + if (_complexIsParentSuperselector(group1, group2)) return group2; + if (_complexIsParentSuperselector(group2, group1)) return group1; if (!_mustUnify(group1, group2)) return null; - var unified = unifyComplex([group1, group2]); + var unified = unifyComplex( + [ComplexSelector(const [], group1), ComplexSelector(const [], group2)]); if (unified == null) return null; if (unified.length > 1) return null; - return unified.first; + return unified.first.components; }); - var choices = [ - >[initialCombinators] - ]; + var choices = >>[]; for (var group in lcs) { - choices.add(_chunks>(groups1, groups2, - (sequence) => complexIsParentSuperselector(sequence.first, group)) - .map((chunk) => chunk.expand((group) => group)) - .toList()); + choices.add([ + for (var chunk in _chunks>( + groups1, + groups2, + (sequence) => _complexIsParentSuperselector(sequence.first, group))) + [for (var components in chunk) ...components] + ]); choices.add([group]); groups1.removeFirst(); groups2.removeFirst(); } - choices.add(_chunks(groups1, groups2, (sequence) => sequence.isEmpty) - .map((chunk) => chunk.expand((group) => group)) - .toList()); - choices.addAll(finalCombinators); + choices.add([ + for (var chunk in _chunks(groups1, groups2, (sequence) => sequence.isEmpty)) + [for (var components in chunk) ...components] + ]); + choices.addAll(trailingCombinators); - return paths(choices.where((choice) => choice.isNotEmpty)) - .map((path) => path.expand((group) => group).toList()); + return [ + for (var path in paths(choices.where((choice) => choice.isNotEmpty))) + ComplexSelector( + leadingCombinators, [for (var components in path) ...components], + lineBreak: prefix.lineBreak || base.lineBreak) + ]; } -/// If the first element of [queue] has a `::root` selector, removes and returns +/// If the first element of [queue] has a `:root` selector, removes and returns /// that element. -CompoundSelector? _firstIfRoot(Queue queue) { +ComplexSelectorComponent? _firstIfRoot(Queue queue) { if (queue.isEmpty) return null; var first = queue.first; - if (first is CompoundSelector) { - if (!_hasRoot(first)) return null; - - queue.removeFirst(); - return first; - } else { - return null; - } + if (!_hasRoot(first.selector)) return null; + queue.removeFirst(); + return first; } -/// Extracts leading [Combinator]s from [components1] and [components2] and -/// merges them together into a single list of combinators. +/// Returns a leading combinator list that's compatible with both [combinators1] +/// and [combinators2]. /// -/// If there are no combinators to be merged, returns an empty list. If the -/// combinators can't be merged, returns `null`. -List? _mergeInitialCombinators( - Queue components1, - Queue components2) { - var combinators1 = []; - while (components1.isNotEmpty && components1.first is Combinator) { - combinators1.add(components1.removeFirst() as Combinator); - } - - var combinators2 = []; - while (components2.isNotEmpty && components2.first is Combinator) { - combinators2.add(components2.removeFirst() as Combinator); - } - - // If neither sequence of combinators is a subsequence of the other, they - // cannot be merged successfully. - var lcs = longestCommonSubsequence(combinators1, combinators2); - if (listEquals(lcs, combinators1)) return combinators2; - if (listEquals(lcs, combinators2)) return combinators1; - return null; +/// Returns `null` if the combinator lists can't be unified. +List? _mergeLeadingCombinators( + List? combinators1, List? combinators2) { + // Allow null arguments just to make calls to `Iterable.reduce()` easier. + if (combinators1 == null) return null; + if (combinators2 == null) return null; + if (combinators1.length > 1) return null; + if (combinators2.length > 1) return null; + if (combinators1.isEmpty) return combinators2; + if (combinators2.isEmpty) return combinators1; + return listEquals(combinators1, combinators2) ? combinators1 : null; } -/// Extracts trailing [Combinator]s, and the selectors to which they apply, from +/// Extracts trailing [ComplexSelectorComponent]s with trailing combinators from /// [components1] and [components2] and merges them together into a single list. /// +/// Each element in the returned list is a set of choices for a particular +/// position in a complex selector. Each choice is the contents of a complex +/// selector, which is to say a list of complex selector components. The union +/// of each path through these choices will match the full set of necessary +/// elements. +/// /// If there are no combinators to be merged, returns an empty list. If the /// sequences can't be merged, returns `null`. -List>>? _mergeFinalCombinators( +List>>? _mergeTrailingCombinators( Queue components1, Queue components2, [QueueList>>? result]) { result ??= QueueList(); - if ((components1.isEmpty || components1.last is! Combinator) && - (components2.isEmpty || components2.last is! Combinator)) { - return result; - } - var combinators1 = []; - while (components1.isNotEmpty && components1.last is Combinator) { - combinators1.add(components1.removeLast() as Combinator); - } - - var combinators2 = []; - while (components2.isNotEmpty && components2.last is Combinator) { - combinators2.add(components2.removeLast() as Combinator); - } - - if (combinators1.length > 1 || combinators2.length > 1) { - // If there are multiple combinators, something hacky's going on. If one - // is a supersequence of the other, use that, otherwise give up. - var lcs = longestCommonSubsequence(combinators1, combinators2); - if (listEquals(lcs, combinators1)) { - result.addFirst([List.of(combinators2.reversed)]); - } else if (listEquals(lcs, combinators2)) { - result.addFirst([List.of(combinators1.reversed)]); - } else { - return null; - } + var combinators1 = + components1.isEmpty ? const [] : components1.last.combinators; + var combinators2 = + components2.isEmpty ? const [] : components2.last.combinators; + if (combinators1.isEmpty && combinators2.isEmpty) return result; - return result; - } + if (combinators1.length > 1 || combinators2.length > 1) return null; // This code looks complicated, but it's actually just a bunch of special // cases for interactions between different combinators. var combinator1 = combinators1.isEmpty ? null : combinators1.first; var combinator2 = combinators2.isEmpty ? null : combinators2.first; if (combinator1 != null && combinator2 != null) { - var compound1 = components1.removeLast() as CompoundSelector; - var compound2 = components2.removeLast() as CompoundSelector; + var component1 = components1.removeLast(); + var component2 = components2.removeLast(); if (combinator1 == Combinator.followingSibling && combinator2 == Combinator.followingSibling) { - if (compound1.isSuperselector(compound2)) { + if (component1.selector.isSuperselector(component2.selector)) { result.addFirst([ - [compound2, Combinator.followingSibling] + [component2] ]); - } else if (compound2.isSuperselector(compound1)) { + } else if (component2.selector.isSuperselector(component1.selector)) { result.addFirst([ - [compound1, Combinator.followingSibling] + [component1] ]); } else { var choices = [ - [ - compound1, - Combinator.followingSibling, - compound2, - Combinator.followingSibling - ], - [ - compound2, - Combinator.followingSibling, - compound1, - Combinator.followingSibling - ] + [component1, component2], + [component2, component1] ]; - var unified = unifyCompound(compound1.components, compound2.components); + var unified = unifyCompound( + component1.selector.components, component2.selector.components); if (unified != null) { - choices.add([unified, Combinator.followingSibling]); + choices.add([ + ComplexSelectorComponent( + unified, const [Combinator.followingSibling]) + ]); } result.addFirst(choices); @@ -378,78 +391,75 @@ List>>? _mergeFinalCombinators( combinator2 == Combinator.nextSibling) || (combinator1 == Combinator.nextSibling && combinator2 == Combinator.followingSibling)) { - var followingSiblingSelector = - combinator1 == Combinator.followingSibling ? compound1 : compound2; - var nextSiblingSelector = - combinator1 == Combinator.followingSibling ? compound2 : compound1; + var followingSiblingComponent = + combinator1 == Combinator.followingSibling ? component1 : component2; + var nextSiblingComponent = + combinator1 == Combinator.followingSibling ? component2 : component1; - if (followingSiblingSelector.isSuperselector(nextSiblingSelector)) { + if (followingSiblingComponent.selector + .isSuperselector(nextSiblingComponent.selector)) { result.addFirst([ - [nextSiblingSelector, Combinator.nextSibling] + [nextSiblingComponent] ]); } else { - var unified = unifyCompound(compound1.components, compound2.components); + var unified = unifyCompound( + component1.selector.components, component2.selector.components); result.addFirst([ - [ - followingSiblingSelector, - Combinator.followingSibling, - nextSiblingSelector, - Combinator.nextSibling - ], - if (unified != null) [unified, Combinator.nextSibling] + [followingSiblingComponent, nextSiblingComponent], + if (unified != null) + [ + ComplexSelectorComponent(unified, const [Combinator.nextSibling]) + ] ]); } } else if (combinator1 == Combinator.child && (combinator2 == Combinator.nextSibling || combinator2 == Combinator.followingSibling)) { result.addFirst([ - [compound2, combinator2] + [component2] ]); - components1 - ..add(compound1) - ..add(Combinator.child); + components1.add(component1); } else if (combinator2 == Combinator.child && (combinator1 == Combinator.nextSibling || combinator1 == Combinator.followingSibling)) { result.addFirst([ - [compound1, combinator1] + [component1] ]); - components2 - ..add(compound2) - ..add(Combinator.child); + components2.add(component2); } else if (combinator1 == combinator2) { - var unified = unifyCompound(compound1.components, compound2.components); + var unified = unifyCompound( + component1.selector.components, component2.selector.components); if (unified == null) return null; result.addFirst([ - [unified, combinator1] + [ + ComplexSelectorComponent(unified, [combinator1]) + ] ]); } else { return null; } - return _mergeFinalCombinators(components1, components2, result); + return _mergeTrailingCombinators(components1, components2, result); } else if (combinator1 != null) { if (combinator1 == Combinator.child && components2.isNotEmpty && - (components2.last as CompoundSelector) - .isSuperselector(components1.last as CompoundSelector)) { + components2.last.selector.isSuperselector(components1.last.selector)) { components2.removeLast(); } result.addFirst([ - [components1.removeLast(), combinator1] + [components1.removeLast()] ]); - return _mergeFinalCombinators(components1, components2, result); + return _mergeTrailingCombinators(components1, components2, result); } else { if (combinator2 == Combinator.child && components1.isNotEmpty && - (components1.last as CompoundSelector) - .isSuperselector(components2.last as CompoundSelector)) { + components1.last.selector.isSuperselector(components2.last.selector)) { components1.removeLast(); } result.addFirst([ - [components2.removeLast(), combinator2!] + [components2.removeLast()] ]); - return _mergeFinalCombinators(components1, components2, result); + return _mergeTrailingCombinators(components1, components2, result); } } @@ -462,15 +472,12 @@ bool _mustUnify(List complex1, List complex2) { var uniqueSelectors = { for (var component in complex1) - if (component is CompoundSelector) - ...component.components.where(_isUnique) + ...component.selector.components.where(_isUnique) }; if (uniqueSelectors.isEmpty) return false; - return complex2.any((component) => - component is CompoundSelector && - component.components.any( - (simple) => _isUnique(simple) && uniqueSelectors.contains(simple))); + return complex2.any((component) => component.selector.components + .any((simple) => _isUnique(simple) && uniqueSelectors.contains(simple))); } /// Returns whether a [CompoundSelector] may contain only one simple selector of @@ -526,26 +533,25 @@ List> paths(Iterable> choices) => choices.fold( .expand((option) => paths.map((path) => [...path, option])) .toList()); -/// Returns [complex], grouped into sub-lists such that no sub-list contains two -/// adjacent [ComplexSelector]s. +/// Returns [complex], grouped into the longest possible sub-lists such that +/// [ComplexSelectorComponent]s without combinators only appear at the end of +/// sub-lists. /// -/// For example, `(A B > C D + E ~ > G)` is grouped into -/// `[(A) (B > C) (D + E ~ > G)]`. +/// For example, `(A B > C D + E ~ G)` is grouped into +/// `[(A) (B > C) (D + E ~ G)]`. QueueList> _groupSelectors( Iterable complex) { var groups = QueueList>(); - var iterator = complex.iterator; - if (!iterator.moveNext()) return groups; - var group = [iterator.current]; - groups.add(group); - while (iterator.moveNext()) { - if (group.last is Combinator || iterator.current is Combinator) { - group.add(iterator.current); - } else { - group = [iterator.current]; + var group = []; + for (var component in complex) { + group.add(component); + if (component.combinators.isEmpty) { groups.add(group); + group = []; } } + + if (group.isNotEmpty) groups.add(group); return groups; } @@ -570,16 +576,14 @@ bool listIsSuperselector( /// For example, `B` is not normally a superselector of `B A`, since it doesn't /// match elements that match `A`. However, it *is* a parent superselector, /// since `B X` is a superselector of `B A X`. -bool complexIsParentSuperselector(List complex1, +bool _complexIsParentSuperselector(List complex1, List complex2) { - // Try some simple heuristics to see if we can avoid allocations. - if (complex1.first is Combinator) return false; - if (complex2.first is Combinator) return false; if (complex1.length > complex2.length) return false; // TODO(nweiz): There's got to be a way to do this without a bunch of extra // allocations... - var base = CompoundSelector([PlaceholderSelector('')]); + var base = ComplexSelectorComponent( + CompoundSelector([PlaceholderSelector('')]), const []); return complexIsSuperselector([...complex1, base], [...complex2, base]); } @@ -591,8 +595,8 @@ bool complexIsSuperselector(List complex1, List complex2) { // Selectors with trailing operators are neither superselectors nor // subselectors. - if (complex1.last is Combinator) return false; - if (complex2.last is Combinator) return false; + if (complex1.last.combinators.isNotEmpty) return false; + if (complex2.last.combinators.isNotEmpty) return false; var i1 = 0; var i2 = 0; @@ -604,39 +608,49 @@ bool complexIsSuperselector(List complex1, // More complex selectors are never superselectors of less complex ones. if (remaining1 > remaining2) return false; - // Selectors with leading operators are neither superselectors nor - // subselectors. - if (complex1[i1] is Combinator) return false; - if (complex2[i2] is Combinator) return false; - var compound1 = complex1[i1] as CompoundSelector; - + var component1 = complex1[i1]; + if (component1.combinators.length > 1) return false; if (remaining1 == 1) { + var parents = complex2.sublist(i2, complex2.length - 1); + if (parents.any((parent) => parent.combinators.length > 1)) return false; + return compoundIsSuperselector( - compound1, complex2.last as CompoundSelector, - parents: complex2.take(complex2.length - 1).skip(i2)); + component1.selector, complex2.last.selector, + parents: parents); } - // Find the first index where `complex2.sublist(i2, afterSuperselector)` is - // a subselector of [compound1]. We stop before the superselector would - // encompass all of [complex2] because we know [complex1] has more than one - // element, and consuming all of [complex2] wouldn't leave anything for the - // rest of [complex1] to match. - var afterSuperselector = i2 + 1; - for (; afterSuperselector < complex2.length; afterSuperselector++) { - var compound2 = complex2[afterSuperselector - 1]; - if (compound2 is CompoundSelector) { - if (compoundIsSuperselector(compound1, compound2, - parents: complex2.take(afterSuperselector - 1).skip(i2 + 1))) { - break; - } + // Find the first index [endOfSubselector] in [complex2] such that + // `complex2.sublist(i2, endOfSubselector + 1)` is a subselector of + // [component1.selector]. + var endOfSubselector = i2; + List? parents; + while (true) { + var component2 = complex2[endOfSubselector]; + if (component2.combinators.length > 1) return false; + if (compoundIsSuperselector(component1.selector, component2.selector, + parents: parents)) { + break; } + + endOfSubselector++; + if (endOfSubselector == complex2.length - 1) { + // Stop before the superselector would encompass all of [complex2] + // because we know [complex1] has more than one element, and consuming + // all of [complex2] wouldn't leave anything for the rest of [complex1] + // to match. + return false; + } + + parents ??= []; + parents.add(component2); } - if (afterSuperselector == complex2.length) return false; - var combinator1 = complex1[i1 + 1]; - var combinator2 = complex2[afterSuperselector]; - if (combinator1 is Combinator) { - if (combinator2 is! Combinator) return false; + var component2 = complex2[endOfSubselector]; + var combinator1 = component1.combinators.firstOrNull; + var combinator2 = component2.combinators.firstOrNull; + + if (combinator1 != null) { + if (combinator2 == null) return false; // `.foo ~ .bar` is a superselector of `.foo + .bar`, but otherwise the // combinators must match. @@ -649,17 +663,17 @@ bool complexIsSuperselector(List complex1, // `.foo > .baz` is not a superselector of `.foo > .bar > .baz` or // `.foo > .bar .baz`, despite the fact that `.baz` is a superselector of // `.bar > .baz` and `.bar .baz`. Same goes for `+` and `~`. - if (remaining1 == 3 && remaining2 > 3) return false; + if (remaining1 == 2 && remaining2 > 2) return false; - i1 += 2; - i2 = afterSuperselector + 1; - } else if (combinator2 is Combinator) { + i1++; + i2 = endOfSubselector + 1; + } else if (combinator2 != null) { if (combinator2 != Combinator.child) return false; i1++; - i2 = afterSuperselector + 1; + i2 = endOfSubselector + 1; } else { i1++; - i2 = afterSuperselector; + i2 = endOfSubselector + 1; } } } @@ -717,11 +731,8 @@ bool _simpleIsSuperselectorOfCompound( if (selector == null) return false; if (!_subselectorPseudos.contains(theirSimple.normalizedName)) return false; - return selector.components.every((complex) { - if (complex.components.length != 1) return false; - var compound = complex.components.single as CompoundSelector; - return compound.components.contains(simple); - }); + return selector.components.every((complex) => + complex.singleCompound?.components.contains(simple) ?? false); }); } @@ -752,8 +763,12 @@ bool _selectorPseudoIsSuperselector( var selectors = _selectorPseudoArgs(compound2, pseudo1.name); return selectors .any((selector2) => selector1.isSuperselector(selector2)) || - selector1.components.any((complex1) => complexIsSuperselector( - complex1.components, [...?parents, compound2])); + selector1.components.any((complex1) => + complex1.leadingCombinators.isEmpty && + complexIsSuperselector(complex1.components, [ + ...?parents, + ComplexSelectorComponent(compound2, const []) + ])); case 'has': case 'host': @@ -767,17 +782,15 @@ bool _selectorPseudoIsSuperselector( case 'not': return selector1.components.every((complex) { + if (complex.isBogus) return false; + return compound2.components.any((simple2) { if (simple2 is TypeSelector) { - var compound1 = complex.components.last; - return compound1 is CompoundSelector && - compound1.components.any( - (simple1) => simple1 is TypeSelector && simple1 != simple2); + return complex.components.last.selector.components.any( + (simple1) => simple1 is TypeSelector && simple1 != simple2); } else if (simple2 is IDSelector) { - var compound1 = complex.components.last; - return compound1 is CompoundSelector && - compound1.components.any( - (simple1) => simple1 is IDSelector && simple1 != simple2); + return complex.components.last.selector.components + .any((simple1) => simple1 is IDSelector && simple1 != simple2); } else if (simple2 is PseudoSelector && simple2.name == pseudo1.name) { var selector2 = simple2.selector; diff --git a/lib/src/functions/map.dart b/lib/src/functions/map.dart index 7eb4843b1..8a9b07412 100644 --- a/lib/src/functions/map.dart +++ b/lib/src/functions/map.dart @@ -9,6 +9,7 @@ import 'package:collection/collection.dart'; import '../callable.dart'; import '../exception.dart'; import '../module/built_in.dart'; +import '../utils.dart'; import '../value.dart'; /// The global definitions of Sass map functions. @@ -37,7 +38,7 @@ final module = BuiltInModule("map", functions: [ final _get = _function("get", r"$map, $key, $keys...", (arguments) { var map = arguments[0].assertMap("map"); var keys = [arguments[1], ...arguments[2].asList]; - for (var key in keys.take(keys.length - 1)) { + for (var key in keys.exceptLast) { var value = map.contents[key]; if (value is SassMap) { map = value; @@ -80,7 +81,7 @@ final _merge = BuiltInCallable.overloadedFunction("merge", { throw SassScriptException("Expected \$args to contain a map."); } var map2 = args.last.assertMap("map2"); - return _modify(map1, args.take(args.length - 1), (oldValue) { + return _modify(map1, args.exceptLast, (oldValue) { var nestedMap = oldValue.tryMap(); if (nestedMap == null) return map2; return SassMap({...nestedMap.contents, ...map2.contents}); @@ -98,7 +99,7 @@ final _deepRemove = _function("deep-remove", r"$map, $key, $keys...", (arguments) { var map = arguments[0].assertMap("map"); var keys = [arguments[1], ...arguments[2].asList]; - return _modify(map, keys.take(keys.length - 1), (value) { + return _modify(map, keys.exceptLast, (value) { var nestedMap = value.tryMap(); if (nestedMap != null && nestedMap.contents.containsKey(keys.last)) { return SassMap(Map.of(nestedMap.contents)..remove(keys.last)); @@ -141,7 +142,7 @@ final _values = _function( final _hasKey = _function("has-key", r"$map, $key, $keys...", (arguments) { var map = arguments[0].assertMap("map"); var keys = [arguments[1], ...arguments[2].asList]; - for (var key in keys.take(keys.length - 1)) { + for (var key in keys.exceptLast) { var value = map.contents[key]; if (value is SassMap) { map = value; diff --git a/lib/src/functions/selector.dart b/lib/src/functions/selector.dart index 442330e32..3dfce07e6 100644 --- a/lib/src/functions/selector.dart +++ b/lib/src/functions/selector.dart @@ -67,26 +67,32 @@ final _append = _function("append", r"$selectors...", (arguments) { .map((selector) => selector.assertSelector()) .reduce((parent, child) { return SelectorList(child.components.map((complex) { - var compound = complex.components.first; - if (compound is CompoundSelector) { - var newCompound = _prependParent(compound); - if (newCompound == null) { - throw SassScriptException("Can't append $complex to $parent."); - } - - return ComplexSelector([newCompound, ...complex.components.skip(1)]); - } else { + if (complex.leadingCombinators.isNotEmpty) { throw SassScriptException("Can't append $complex to $parent."); } + + var component = complex.components.first; + var newCompound = _prependParent(component.selector); + if (newCompound == null) { + throw SassScriptException("Can't append $complex to $parent."); + } + + return ComplexSelector(const [], [ + ComplexSelectorComponent(newCompound, component.combinators), + ...complex.components.skip(1) + ]); })).resolveParentSelectors(parent); }).asSassList; }); final _extend = _function("extend", r"$selector, $extendee, $extender", (arguments) { - var selector = arguments[0].assertSelector(name: "selector"); - var target = arguments[1].assertSelector(name: "extendee"); - var source = arguments[2].assertSelector(name: "extender"); + var selector = arguments[0].assertSelector(name: "selector") + ..assertNotBogus(name: "selector"); + var target = arguments[1].assertSelector(name: "extendee") + ..assertNotBogus(name: "extendee"); + var source = arguments[2].assertSelector(name: "extender") + ..assertNotBogus(name: "extender"); return ExtensionStore.extend(selector, source, target, EvaluationContext.current.currentCallableSpan) @@ -95,9 +101,12 @@ final _extend = final _replace = _function("replace", r"$selector, $original, $replacement", (arguments) { - var selector = arguments[0].assertSelector(name: "selector"); - var target = arguments[1].assertSelector(name: "original"); - var source = arguments[2].assertSelector(name: "replacement"); + var selector = arguments[0].assertSelector(name: "selector") + ..assertNotBogus(name: "selector"); + var target = arguments[1].assertSelector(name: "original") + ..assertNotBogus(name: "original"); + var source = arguments[2].assertSelector(name: "replacement") + ..assertNotBogus(name: "replacement"); return ExtensionStore.replace(selector, source, target, EvaluationContext.current.currentCallableSpan) @@ -105,8 +114,10 @@ final _replace = }); final _unify = _function("unify", r"$selector1, $selector2", (arguments) { - var selector1 = arguments[0].assertSelector(name: "selector1"); - var selector2 = arguments[1].assertSelector(name: "selector2"); + var selector1 = arguments[0].assertSelector(name: "selector1") + ..assertNotBogus(name: "selector1"); + var selector2 = arguments[1].assertSelector(name: "selector2") + ..assertNotBogus(name: "selector2"); var result = selector1.unify(selector2); return result == null ? sassNull : result.asSassList; @@ -114,8 +125,10 @@ final _unify = _function("unify", r"$selector1, $selector2", (arguments) { final _isSuperselector = _function("is-superselector", r"$super, $sub", (arguments) { - var selector1 = arguments[0].assertSelector(name: "super"); - var selector2 = arguments[1].assertSelector(name: "sub"); + var selector1 = arguments[0].assertSelector(name: "super") + ..assertNotBogus(name: "super"); + var selector2 = arguments[1].assertSelector(name: "sub") + ..assertNotBogus(name: "sub"); return SassBoolean(selector1.isSuperselector(selector2)); }); diff --git a/lib/src/parse/selector.dart b/lib/src/parse/selector.dart index 4442275ad..0a270ccf8 100644 --- a/lib/src/parse/selector.dart +++ b/lib/src/parse/selector.dart @@ -100,6 +100,10 @@ class SelectorParser extends Parser { /// If [lineBreak] is `true`, that indicates that there was a line break /// before this selector. ComplexSelector _complexSelector({bool lineBreak = false}) { + CompoundSelector? lastCompound; + var combinators = []; + + List? initialCombinators; var components = []; loop: @@ -110,37 +114,44 @@ class SelectorParser extends Parser { switch (next) { case $plus: scanner.readChar(); - components.add(Combinator.nextSibling); + combinators.add(Combinator.nextSibling); break; case $gt: scanner.readChar(); - components.add(Combinator.child); + combinators.add(Combinator.child); break; case $tilde: scanner.readChar(); - components.add(Combinator.followingSibling); + combinators.add(Combinator.followingSibling); break; - case $lbracket: - case $dot: - case $hash: - case $percent: - case $colon: - case $ampersand: - case $asterisk: - case $pipe: - components.add(_compoundSelector()); - if (scanner.peekChar() == $ampersand) { - scanner.error( - '"&" may only used at the beginning of a compound selector.'); + default: + if (next == null || + (!const { + $lbracket, + $dot, + $hash, + $percent, + $colon, + $ampersand, + $asterisk, + $pipe + }.contains(next) && + !lookingAtIdentifier())) { + break loop; } - break; - default: - if (next == null || !lookingAtIdentifier()) break loop; - components.add(_compoundSelector()); + if (lastCompound != null) { + components.add(ComplexSelectorComponent(lastCompound, combinators)); + } else if (combinators.isNotEmpty) { + assert(initialCombinators == null); + initialCombinators = combinators; + } + + lastCompound = _compoundSelector(); + combinators = []; if (scanner.peekChar() == $ampersand) { scanner.error( '"&" may only used at the beginning of a compound selector.'); @@ -149,8 +160,16 @@ class SelectorParser extends Parser { } } - if (components.isEmpty) scanner.error("expected selector."); - return ComplexSelector(components, lineBreak: lineBreak); + if (lastCompound != null) { + components.add(ComplexSelectorComponent(lastCompound, combinators)); + } else if (combinators.isNotEmpty) { + initialCombinators = combinators; + } else { + scanner.error("expected selector."); + } + + return ComplexSelector(initialCombinators ?? const [], components, + lineBreak: lineBreak); } /// Consumes a compound selector. diff --git a/lib/src/util/multi_span.dart b/lib/src/util/multi_span.dart new file mode 100644 index 000000000..24ca42b48 --- /dev/null +++ b/lib/src/util/multi_span.dart @@ -0,0 +1,76 @@ +// Copyright 2022 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'; + +/// A FileSpan wrapper that with secondary spans attached, so that +/// [MultiSpan.message] can forward to [SourceSpanExtension.messageMultiple]. +/// +/// This is used to transparently support multi-span messages in situations that +/// need to be backwards-comaptible with single spans, such as logger +/// invocations. To match the `source_span` package, separate APIs should +/// generally be preferred over this class wherever backwards compatibility +/// isn't a concern. +class MultiSpan implements FileSpan { + /// The span to primarily highlight. + final FileSpan _primary; + + /// The label for [primary]. + final String primaryLabel; + + /// The [secondarySpans] map for [SourceSpanExtension.messageMultiple]. + final Map secondarySpans; + + MultiSpan(FileSpan primary, String primaryLabel, + Map secondarySpans) + : this._(primary, primaryLabel, Map.unmodifiable(secondarySpans)); + + MultiSpan._(this._primary, this.primaryLabel, this.secondarySpans); + + FileLocation get start => _primary.start; + FileLocation get end => _primary.end; + String get text => _primary.text; + String get context => _primary.context; + SourceFile get file => _primary.file; + int get length => _primary.length; + Uri? get sourceUrl => _primary.sourceUrl; + int compareTo(SourceSpan other) => _primary.compareTo(other); + String toString() => _primary.toString(); + MultiSpan expand(FileSpan other) => _withPrimary(_primary.expand(other)); + SourceSpan union(SourceSpan other) => _primary.union(other); + MultiSpan subspan(int start, [int? end]) => + _withPrimary(_primary.subspan(start, end)); + + String highlight({dynamic color}) => + _primary.highlightMultiple(primaryLabel, secondarySpans, + color: color == true || color is String, + primaryColor: color is String ? color : null); + + String message(String message, {dynamic color}) => + _primary.messageMultiple(message, primaryLabel, secondarySpans, + color: color == true || color is String, + primaryColor: color is String ? color : null); + + String highlightMultiple( + String newLabel, Map additionalSecondarySpans, + {bool color = false, String? primaryColor, String? secondaryColor}) => + _primary.highlightMultiple( + newLabel, {...secondarySpans, ...additionalSecondarySpans}, + color: color, + primaryColor: primaryColor, + secondaryColor: secondaryColor); + + String messageMultiple(String message, String newLabel, + Map additionalSecondarySpans, + {bool color = false, String? primaryColor, String? secondaryColor}) => + _primary.messageMultiple( + message, newLabel, {...secondarySpans, ...additionalSecondarySpans}, + color: color, + primaryColor: primaryColor, + secondaryColor: secondaryColor); + + /// Returns a copy of [this] with [newPrimary] as its primary span. + MultiSpan _withPrimary(FileSpan newPrimary) => + MultiSpan._(newPrimary, primaryLabel, secondarySpans); +} diff --git a/lib/src/utils.dart b/lib/src/utils.dart index 07f7fc046..a5054298c 100644 --- a/lib/src/utils.dart +++ b/lib/src/utils.dart @@ -23,7 +23,7 @@ final _traces = Expando(); String toSentence(Iterable iter, [String? conjunction]) { conjunction ??= "and"; if (iter.length == 1) return iter.first.toString(); - return iter.take(iter.length - 1).join(", ") + " $conjunction ${iter.last}"; + return iter.exceptLast.join(", ") + " $conjunction ${iter.last}"; } /// Returns [string] with every line indented [indentation] spaces. @@ -458,3 +458,14 @@ extension MapExtension on Map { ? this[key] = merge(this[key]!, value) : this[key] = value; } + +extension IterableExtension on Iterable { + /// Returns a view of this list that covers all elements except the last. + /// + /// Note this is only efficient for an iterable with a known length. + Iterable get exceptLast { + var size = length - 1; + if (size < 0) throw StateError('Iterable may not be empty'); + return take(size); + } +} diff --git a/lib/src/visitor/any_selector.dart b/lib/src/visitor/any_selector.dart new file mode 100644 index 000000000..c58c0cc4a --- /dev/null +++ b/lib/src/visitor/any_selector.dart @@ -0,0 +1,39 @@ +// Copyright 2018 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:meta/meta.dart'; + +import '../ast/selector.dart'; +import 'interface/selector.dart'; + +/// A visitor that visits each selector in a Sass selector AST and returns +/// `true` if any of the individual methods return `true`. +/// +/// Each method returns `false` by default. +@internal +abstract class AnySelectorVisitor implements SelectorVisitor { + const AnySelectorVisitor(); + + bool visitComplexSelector(ComplexSelector complex) => complex.components + .any((component) => visitCompoundSelector(component.selector)); + + bool visitCompoundSelector(CompoundSelector compound) => + compound.components.any((simple) => simple.accept(this)); + + bool visitPseudoSelector(PseudoSelector pseudo) { + var selector = pseudo.selector; + return selector == null ? false : selector.accept(this); + } + + bool visitSelectorList(SelectorList list) => + list.components.any(visitComplexSelector); + + bool visitAttributeSelector(AttributeSelector attribute) => false; + bool visitClassSelector(ClassSelector klass) => false; + bool visitIDSelector(IDSelector id) => false; + bool visitParentSelector(ParentSelector parent) => false; + bool visitPlaceholderSelector(PlaceholderSelector placeholder) => false; + bool visitTypeSelector(TypeSelector type) => false; + bool visitUniversalSelector(UniversalSelector universal) => false; +} diff --git a/lib/src/visitor/async_evaluate.dart b/lib/src/visitor/async_evaluate.dart index 5b4e6f6e9..f6d0c9553 100644 --- a/lib/src/visitor/async_evaluate.dart +++ b/lib/src/visitor/async_evaluate.dart @@ -38,6 +38,7 @@ import '../module/built_in.dart'; import '../parse/keyframe_selector.dart'; import '../syntax.dart'; import '../utils.dart'; +import '../util/multi_span.dart'; import '../util/nullable.dart'; import '../value.dart'; import 'interface/css.dart'; @@ -1191,6 +1192,20 @@ class _EvaluateVisitor "@extend may only be used within style rules.", node.span); } + for (var complex in styleRule.originalSelector.components) { + if (!complex.isBogus) continue; + _warn( + 'The selector "${complex.toString().trim()}" is invalid CSS and ' + + (complex.isUseless ? "can't" : "shouldn't") + + ' be an extender.\n' + 'This will be an error in Dart Sass 2.0.0.\n' + '\n' + 'More info: https://sass-lang.com/d/bogus-combinators', + MultiSpan(styleRule.selector.span, 'invalid selector', + {node.span: '@extend rule'}), + deprecation: true); + } + var targetText = await _interpolationToValue(node.selector, warnForColor: true); @@ -1202,16 +1217,16 @@ class _EvaluateVisitor allowParent: false)); for (var complex in list.components) { - if (complex.components.length != 1 || - complex.components.first is! CompoundSelector) { + var compound = complex.singleCompound; + if (compound == null) { // If the selector was a compound selector but not a simple // selector, emit a more explicit error. throw SassFormatException( "complex selectors may not be extended.", targetText.span); } - var compound = complex.components.first as CompoundSelector; - if (compound.components.length != 1) { + var simple = compound.singleSimple; + if (simple == null) { throw SassFormatException( "compound selectors may no longer be extended.\n" "Consider `@extend ${compound.components.join(', ')}` instead.\n" @@ -1220,7 +1235,7 @@ class _EvaluateVisitor } _extensionStore.addExtension( - styleRule.selector, compound.components.first, node, _mediaQueries); + styleRule.selector, simple, node, _mediaQueries); } return null; @@ -1886,6 +1901,50 @@ class _EvaluateVisitor scopeWhen: node.hasDeclarations); _atRootExcludingStyleRule = oldAtRootExcludingStyleRule; + if (!rule.isInvisibleOtherThanBogusCombinators) { + for (var complex in parsedSelector.components) { + if (!complex.isBogus) continue; + + if (complex.isUseless) { + _warn( + 'The selector "${complex.toString().trim()}" is invalid CSS. It ' + 'will be omitted from the generated CSS.\n' + 'This will be an error in Dart Sass 2.0.0.\n' + '\n' + 'More info: https://sass-lang.com/d/bogus-combinators', + node.selector.span, + deprecation: true); + } else if (complex.leadingCombinators.isNotEmpty) { + _warn( + 'The selector "${complex.toString().trim()}" is invalid CSS.\n' + 'This will be an error in Dart Sass 2.0.0.\n' + '\n' + 'More info: https://sass-lang.com/d/bogus-combinators', + node.selector.span, + deprecation: true); + } else { + _warn( + 'The selector "${complex.toString().trim()}" is only valid for ' + "nesting and shouldn't\n" + 'have children other than style rules.' + + (complex.isBogusOtherThanLeadingCombinator + ? ' It will be omitted from the generated CSS.' + : '') + + '\n' + 'This will be an error in Dart Sass 2.0.0.\n' + '\n' + 'More info: https://sass-lang.com/d/bogus-combinators', + MultiSpan(node.selector.span, 'invalid selector', { + rule.children.first.span: "this is not a style rule" + + (rule.children.every((child) => child is CssComment) + ? '\n(try converting to a //-style comment)' + : '') + }), + deprecation: true); + } + } + } + if (_styleRule == null && _parent.children.isNotEmpty) { var lastChild = _parent.children.last; lastChild.isGroupEnd = true; diff --git a/lib/src/visitor/evaluate.dart b/lib/src/visitor/evaluate.dart index 1d9a6124a..b02630d28 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: 62020db52400fe22132154e72fbb728f6282bf6d +// Checksum: c3bc020b07dc2376f57ef8e9990ff6b97cde3417 // // ignore_for_file: unused_import @@ -47,6 +47,7 @@ import '../module/built_in.dart'; import '../parse/keyframe_selector.dart'; import '../syntax.dart'; import '../utils.dart'; +import '../util/multi_span.dart'; import '../util/nullable.dart'; import '../value.dart'; import 'interface/css.dart'; @@ -1194,6 +1195,20 @@ class _EvaluateVisitor "@extend may only be used within style rules.", node.span); } + for (var complex in styleRule.originalSelector.components) { + if (!complex.isBogus) continue; + _warn( + 'The selector "${complex.toString().trim()}" is invalid CSS and ' + + (complex.isUseless ? "can't" : "shouldn't") + + ' be an extender.\n' + 'This will be an error in Dart Sass 2.0.0.\n' + '\n' + 'More info: https://sass-lang.com/d/bogus-combinators', + MultiSpan(styleRule.selector.span, 'invalid selector', + {node.span: '@extend rule'}), + deprecation: true); + } + var targetText = _interpolationToValue(node.selector, warnForColor: true); var list = _adjustParseError( @@ -1204,16 +1219,16 @@ class _EvaluateVisitor allowParent: false)); for (var complex in list.components) { - if (complex.components.length != 1 || - complex.components.first is! CompoundSelector) { + var compound = complex.singleCompound; + if (compound == null) { // If the selector was a compound selector but not a simple // selector, emit a more explicit error. throw SassFormatException( "complex selectors may not be extended.", targetText.span); } - var compound = complex.components.first as CompoundSelector; - if (compound.components.length != 1) { + var simple = compound.singleSimple; + if (simple == null) { throw SassFormatException( "compound selectors may no longer be extended.\n" "Consider `@extend ${compound.components.join(', ')}` instead.\n" @@ -1222,7 +1237,7 @@ class _EvaluateVisitor } _extensionStore.addExtension( - styleRule.selector, compound.components.first, node, _mediaQueries); + styleRule.selector, simple, node, _mediaQueries); } return null; @@ -1881,6 +1896,50 @@ class _EvaluateVisitor scopeWhen: node.hasDeclarations); _atRootExcludingStyleRule = oldAtRootExcludingStyleRule; + if (!rule.isInvisibleOtherThanBogusCombinators) { + for (var complex in parsedSelector.components) { + if (!complex.isBogus) continue; + + if (complex.isUseless) { + _warn( + 'The selector "${complex.toString().trim()}" is invalid CSS. It ' + 'will be omitted from the generated CSS.\n' + 'This will be an error in Dart Sass 2.0.0.\n' + '\n' + 'More info: https://sass-lang.com/d/bogus-combinators', + node.selector.span, + deprecation: true); + } else if (complex.leadingCombinators.isNotEmpty) { + _warn( + 'The selector "${complex.toString().trim()}" is invalid CSS.\n' + 'This will be an error in Dart Sass 2.0.0.\n' + '\n' + 'More info: https://sass-lang.com/d/bogus-combinators', + node.selector.span, + deprecation: true); + } else { + _warn( + 'The selector "${complex.toString().trim()}" is only valid for ' + "nesting and shouldn't\n" + 'have children other than style rules.' + + (complex.isBogusOtherThanLeadingCombinator + ? ' It will be omitted from the generated CSS.' + : '') + + '\n' + 'This will be an error in Dart Sass 2.0.0.\n' + '\n' + 'More info: https://sass-lang.com/d/bogus-combinators', + MultiSpan(node.selector.span, 'invalid selector', { + rule.children.first.span: "this is not a style rule" + + (rule.children.every((child) => child is CssComment) + ? '\n(try converting to a //-style comment)' + : '') + }), + deprecation: true); + } + } + } + if (_styleRule == null && _parent.children.isNotEmpty) { var lastChild = _parent.children.last; lastChild.isGroupEnd = true; diff --git a/lib/src/visitor/every_css.dart b/lib/src/visitor/every_css.dart new file mode 100644 index 000000000..e8d22a548 --- /dev/null +++ b/lib/src/visitor/every_css.dart @@ -0,0 +1,33 @@ +// Copyright 2022 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:meta/meta.dart'; + +import '../ast/css.dart'; +import 'interface/css.dart'; + +/// A visitor that visits each statements in a CSS AST and returns `true` if all +/// of the individual methods return `true`. +/// +/// Each method returns `false` by default. +@internal +abstract class EveryCssVisitor implements CssVisitor { + const EveryCssVisitor(); + + bool visitCssAtRule(CssAtRule node) => + node.children.every((child) => child.accept(this)); + bool visitCssComment(CssComment node) => false; + bool visitCssDeclaration(CssDeclaration node) => false; + bool visitCssImport(CssImport node) => false; + bool visitCssKeyframeBlock(CssKeyframeBlock node) => + node.children.every((child) => child.accept(this)); + bool visitCssMediaRule(CssMediaRule node) => + node.children.every((child) => child.accept(this)); + bool visitCssStyleRule(CssStyleRule node) => + node.children.every((child) => child.accept(this)); + bool visitCssStylesheet(CssStylesheet node) => + node.children.every((child) => child.accept(this)); + bool visitCssSupportsRule(CssSupportsRule node) => + node.children.every((child) => child.accept(this)); +} diff --git a/lib/src/visitor/recursive_selector.dart b/lib/src/visitor/recursive_selector.dart index 390e92bf6..b66b8d939 100644 --- a/lib/src/visitor/recursive_selector.dart +++ b/lib/src/visitor/recursive_selector.dart @@ -22,7 +22,7 @@ abstract class RecursiveSelectorVisitor implements SelectorVisitor { void visitComplexSelector(ComplexSelector complex) { for (var component in complex.components) { - if (component is CompoundSelector) visitCompoundSelector(component); + visitCompoundSelector(component.selector); } } diff --git a/lib/src/visitor/serialize.dart b/lib/src/visitor/serialize.dart index d13cd145b..d098923af 100644 --- a/lib/src/visitor/serialize.dart +++ b/lib/src/visitor/serialize.dart @@ -1175,26 +1175,28 @@ class _SerializeVisitor } void visitComplexSelector(ComplexSelector complex) { - ComplexSelectorComponent? lastComponent; - for (var component in complex.components) { - if (lastComponent != null && - !_omitSpacesAround(lastComponent) && - !_omitSpacesAround(component)) { - _buffer.write(" "); - } - if (component is CompoundSelector) { - visitCompoundSelector(component); - } else { - _buffer.write(component); + _writeCombinators(complex.leadingCombinators); + if (complex.leadingCombinators.isNotEmpty && + complex.components.isNotEmpty) { + _writeOptionalSpace(); + } + + for (var i = 0; i < complex.components.length; i++) { + var component = complex.components[i]; + visitCompoundSelector(component.selector); + if (component.combinators.isNotEmpty) _writeOptionalSpace(); + _writeCombinators(component.combinators); + if (i != complex.components.length - 1 && + (!_isCompressed || component.combinators.isEmpty)) { + _buffer.writeCharCode($space); } - lastComponent = component; } } - /// When [_style] is [OutputStyle.compressed], omit spaces around combinators. - bool _omitSpacesAround(ComplexSelectorComponent component) { - return _isCompressed && component is Combinator; - } + /// Writes [combinators] to [_buffer], with spaces in between in expanded + /// mode. + void _writeCombinators(List combinators) => + _writeBetween(combinators, _isCompressed ? '' : ' ', _buffer.write); void visitCompoundSelector(CompoundSelector compound) { var start = _buffer.length; @@ -1425,21 +1427,9 @@ class _SerializeVisitor } /// Returns whether [node] is considered invisible. - bool _isInvisible(CssNode node) { - if (_inspect) return false; - if (_isCompressed && node is CssComment && !node.isPreserved) return true; - if (node is CssParentNode) { - // An unknown at-rule is never invisible. Because we don't know the - // semantics of unknown rules, we can't guarantee that (for example) - // `@foo {}` isn't meaningful. - if (node is CssAtRule) return false; - - if (node is CssStyleRule && node.selector.value.isInvisible) return true; - return node.children.every(_isInvisible); - } else { - return false; - } - } + bool _isInvisible(CssNode node) => + !_inspect && + (_isCompressed ? node.isInvisibleHidingComments : node.isInvisible); } /// An enum of generated CSS styles. diff --git a/pubspec.yaml b/pubspec.yaml index f156b18c3..7204bef0d 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -49,3 +49,9 @@ dev_dependencies: test_descriptor: ^2.0.0 test_process: ^2.0.0 yaml: ^3.1.0 + +dependency_overrides: + source_span: + git: + url: https://github.com/dart-lang/source_span + ref: multi-line-label