Split out an Extender class from Extension

This gets rid of the weird subset of "one-off" extensions which didn't
have target information available. Now instead, each method explicitly
declares whether it takes/returns extensions (which do have target
info) or extenders (which do not).
This commit is contained in:
Natalie Weizenbaum 2021-03-18 16:28:25 -07:00
parent 8fd3c1ba03
commit 3ead2e2bb5
3 changed files with 160 additions and 134 deletions

View File

@ -14,34 +14,19 @@ import '../utils.dart';
/// The target of the extension is represented externally, in the map that
/// contains this extender.
class Extension {
/// The selector in which the `@extend` appeared.
final ComplexSelector extender;
/// The extender (such as `A` in `A {@extend B}`).
final Extender extender;
/// The selector that's being extended.
///
/// `null` for one-off extensions.
final SimpleSelector? target;
final SimpleSelector target;
/// The minimum specificity required for any selector generated from this
/// extender.
final int specificity;
/// The media query context to which this extension is restricted, or `null`
/// if it can apply within any context.
final List<CssMediaQuery>? mediaContext;
/// Whether this extension is optional.
final bool isOptional;
/// Whether this is a one-off extender representing a selector that was
/// originally in the document, rather than one defined with `@extend`.
final bool isOriginal;
/// The media query context to which this extend is restricted, or `null` if
/// it can apply within any context.
final List<CssMediaQuery>? mediaContext;
/// The span in which [extender] was defined.
///
/// `null` for one-off extensions.
final FileSpan? extenderSpan;
/// The span for an `@extend` rule that defined this extension.
///
/// If any extend rule for this is extension is mandatory, this is guaranteed
@ -51,43 +36,68 @@ class Extension {
/// Creates a new extension.
///
/// If [specificity] isn't passed, it defaults to `extender.maxSpecificity`.
Extension(ComplexSelector extender, this.target, this.extenderSpan, this.span,
this.mediaContext,
{int? specificity, bool optional = false})
: extender = extender,
specificity = specificity ?? extender.maxSpecificity,
isOptional = optional,
isOriginal = false;
/// Creates a one-off extension that's not intended to be modified over time.
///
/// If [specificity] isn't passed, it defaults to `extender.maxSpecificity`.
Extension.oneOff(ComplexSelector extender,
{int? specificity, this.isOriginal = false})
: extender = extender,
target = null,
extenderSpan = null,
specificity = specificity ?? extender.maxSpecificity,
isOptional = true,
mediaContext = null,
span = null;
/// Asserts that the [mediaContext] for a selector is compatible with the
/// query context for this extender.
void assertCompatibleMediaContext(List<CssMediaQuery>? mediaContext) {
if (this.mediaContext == null) return;
if (mediaContext != null && listEquals(this.mediaContext, mediaContext)) {
return;
}
throw SassException(
"You may not @extend selectors across media queries.", span);
Extension(
ComplexSelector extender, FileSpan? extenderSpan, this.target, this.span,
{this.mediaContext, bool optional = false})
: extender = Extender(extender, extenderSpan),
isOptional = optional {
this.extender._extension = this;
}
Extension withExtender(ComplexSelector newExtender) =>
Extension(newExtender, target, extenderSpan, span, mediaContext,
specificity: specificity, optional: isOptional);
Extension(newExtender, extender.span, target, span,
mediaContext: mediaContext, optional: isOptional);
String toString() =>
"$extender {@extend $target${isOptional ? ' !optional' : ''}}";
}
/// A selector that's extending another selector, such as `A` in `A {@extend
/// B}`.
class Extender {
/// The selector in which the `@extend` appeared.
final ComplexSelector selector;
/// The minimum specificity required for any selector generated from this
/// extender.
final int specificity;
/// Whether this extender represents a selector that was originally in the
/// document, rather than one defined with `@extend`.
final bool isOriginal;
/// The extension that created this [Extender].
///
/// Not all [Extender]s are created by extensions. Some simply represent the
/// original selectors that exist in the document.
Extension? _extension;
/// The span in which this selector was defined.
final FileSpan? span;
/// Creates a new extender.
///
/// If [specificity] isn't passed, it defaults to `extender.maxSpecificity`.
Extender(this.selector, this.span, {int? specificity, bool original = false})
: specificity = specificity ?? selector.maxSpecificity,
isOriginal = original;
/// Asserts that the [mediaContext] for a selector is compatible with the
/// query context for this extender.
void assertCompatibleMediaContext(List<CssMediaQuery>? mediaContext) {
var extension = _extension;
if (extension == null) return;
var expectedMediaContext = extension.mediaContext;
if (expectedMediaContext == null) return;
if (mediaContext != null &&
listEquals(expectedMediaContext, mediaContext)) {
return;
}
throw SassException(
"You may not @extend selectors across media queries.", extension.span);
}
String toString() => selector.toString();
}

View File

@ -91,10 +91,6 @@ class ExtensionStore {
/// A helper function for [extend] and [replace].
static SelectorList _extendOrReplace(SelectorList selector,
SelectorList source, SelectorList targets, ExtendMode mode) {
var extenders = {
for (var complex in source.components) complex: Extension.oneOff(complex)
};
var compoundTargets = [
for (var complex in targets.components)
if (complex.components.length != 1)
@ -105,14 +101,18 @@ class ExtensionStore {
var extensions = {
for (var compound in compoundTargets)
for (var simple in compound.components) simple: extenders
for (var simple in compound.components)
simple: {
for (var complex in source.components)
complex: Extension(complex, null, simple, null, optional: true)
}
};
var extender = ExtensionStore._mode(mode);
if (!selector.isInvisible) {
extender._originals.addAll(selector.components);
}
selector = extender._extendList(selector, extensions, null);
selector = extender._extendList(selector, null /* listSpan */, extensions);
return selector;
}
@ -173,7 +173,7 @@ class ExtensionStore {
/// The [mediaContext] is the media query context in which the selector was
/// defined, or `null` if it was defined at the top level of the document.
ModifiableCssValue<SelectorList> addSelector(
SelectorList selector, FileSpan? span,
SelectorList selector, FileSpan? selectorSpan,
[List<CssMediaQuery>? mediaContext]) {
var originalSelector = selector;
if (!originalSelector.isInvisible) {
@ -184,7 +184,8 @@ class ExtensionStore {
if (_extensions.isNotEmpty) {
try {
selector = _extendList(originalSelector, _extensions, mediaContext);
selector = _extendList(
originalSelector, selectorSpan, _extensions, mediaContext);
} on SassException catch (error) {
var span = error.span;
if (span == null) rethrow;
@ -196,7 +197,7 @@ class ExtensionStore {
}
}
var modifiableSelector = ModifiableCssValue(selector, span);
var modifiableSelector = ModifiableCssValue(selector, selectorSpan);
if (mediaContext != null) _mediaContexts[modifiableSelector] = mediaContext;
_registerSelector(selector, modifiableSelector);
@ -242,25 +243,24 @@ class ExtensionStore {
Map<ComplexSelector, Extension>? newExtensions;
var sources = _extensions.putIfAbsent(target, () => {});
for (var complex in extender.value.components) {
var state = Extension(
complex, target, extender.span, extend.span, mediaContext,
optional: extend.isOptional);
var extension = Extension(complex, extender.span, target, extend.span,
mediaContext: mediaContext, optional: extend.isOptional);
var existingState = sources[complex];
if (existingState != null) {
var existingExtension = sources[complex];
if (existingExtension != null) {
// If there's already an extend from [extender] to [target], we don't need
// to re-run the extension. We may need to mark the extension as
// mandatory, though.
sources[complex] = MergedExtension.merge(existingState, state);
sources[complex] = MergedExtension.merge(existingExtension, extension);
continue;
}
sources[complex] = state;
sources[complex] = extension;
for (var component in complex.components) {
if (component is CompoundSelector) {
for (var simple in component.components) {
_extensionsByExtender.putIfAbsent(simple, () => []).add(state);
_extensionsByExtender.putIfAbsent(simple, () => []).add(extension);
// Only source specificity for the original selector is relevant.
// Selectors generated by `@extend` don't get new specificity.
_sourceSpecificity.putIfAbsent(
@ -271,7 +271,7 @@ class ExtensionStore {
if (selectors != null || existingExtensions != null) {
newExtensions ??= {};
newExtensions[complex] = state;
newExtensions[complex] = extension;
}
}
@ -312,16 +312,15 @@ class ExtensionStore {
Map<SimpleSelector, Map<ComplexSelector, Extension>>? additionalExtensions;
for (var extension in extensions.toList()) {
var sources = _extensions[extension.target!]!;
var sources = _extensions[extension.target]!;
// [_extendExistingSelectors] would have thrown already.
List<ComplexSelector>? selectors;
try {
selectors = _extendComplex(
extension.extender, newExtensions, extension.mediaContext);
selectors = _extendComplex(extension.extender.selector,
extension.extender.span, newExtensions, extension.mediaContext);
if (selectors == null) continue;
} on SassException catch (error) {
var extenderSpan = extension.extenderSpan;
var extenderSpan = extension.extender.span;
if (extenderSpan == null) rethrow;
throw SassException(
@ -330,7 +329,7 @@ class ExtensionStore {
error.span);
}
var containsExtension = selectors.first == extension.extender;
var containsExtension = selectors.first == extension.extender.selector;
var first = false;
for (var complex in selectors) {
// If the output contains the original complex selector, there's no
@ -361,7 +360,7 @@ class ExtensionStore {
if (newExtensions!.containsKey(extension.target)) {
additionalExtensions ??= {};
var additionalSources =
additionalExtensions.putIfAbsent(extension.target!, () => {});
additionalExtensions.putIfAbsent(extension.target, () => {});
additionalSources[complex] = withExtender;
}
}
@ -382,8 +381,8 @@ class ExtensionStore {
for (var selector in selectors) {
var oldValue = selector.value;
try {
selector.value = _extendList(
selector.value, newExtensions, _mediaContexts[selector]);
selector.value = _extendList(selector.value, selector.span,
newExtensions, _mediaContexts[selector]);
} on SassException catch (error) {
if (selector.span == null) rethrow;
@ -482,16 +481,16 @@ class ExtensionStore {
}
/// Extends [list] using [extensions].
SelectorList _extendList(
SelectorList list,
SelectorList _extendList(SelectorList list, FileSpan? listSpan,
Map<SimpleSelector, Map<ComplexSelector, Extension>?>? extensions,
List<CssMediaQuery>? mediaQueryContext) {
[List<CssMediaQuery>? mediaQueryContext]) {
// This could be written more simply using [List.map], but we want to avoid
// any allocations in the common case where no extends apply.
List<ComplexSelector>? extended;
for (var i = 0; i < list.components.length; i++) {
var complex = list.components[i];
var result = _extendComplex(complex, extensions, mediaQueryContext);
var result =
_extendComplex(complex, listSpan, extensions, mediaQueryContext);
if (result == null) {
if (extended != null) extended.add(complex);
} else {
@ -508,6 +507,7 @@ class ExtensionStore {
/// [SelectorList].
List<ComplexSelector>? _extendComplex(
ComplexSelector complex,
FileSpan? complexSpan,
Map<SimpleSelector, Map<ComplexSelector, Extension>?>? extensions,
List<CssMediaQuery>? mediaQueryContext) {
// The complex selectors that each compound selector in [complex.components]
@ -532,7 +532,8 @@ class ExtensionStore {
for (var i = 0; i < complex.components.length; i++) {
var component = complex.components[i];
if (component is CompoundSelector) {
var extended = _extendCompound(component, extensions, mediaQueryContext,
var extended = _extendCompound(
component, complexSpan, extensions, mediaQueryContext,
inOriginal: isOriginal);
if (extended == null) {
extendedNotExpanded?.add([
@ -583,6 +584,7 @@ class ExtensionStore {
/// complex selector, meaning that [compound] should not be trimmed out.
List<ComplexSelector>? _extendCompound(
CompoundSelector compound,
FileSpan? compoundSpan,
Map<SimpleSelector, Map<ComplexSelector, Extension>?>? extensions,
List<CssMediaQuery>? mediaQueryContext,
{bool? inOriginal}) {
@ -593,18 +595,20 @@ class ExtensionStore {
: <SimpleSelector>{};
// The complex selectors produced from each component of [compound].
List<List<Extension>>? options;
List<List<Extender>>? options;
for (var i = 0; i < compound.components.length; i++) {
var simple = compound.components[i];
var extended =
_extendSimple(simple, extensions, mediaQueryContext, targetsUsed);
var extended = _extendSimple(
simple, compoundSpan, extensions, mediaQueryContext, targetsUsed);
if (extended == null) {
options?.add([_extensionForSimple(simple)]);
options?.add([_extenderForSimple(simple, compoundSpan)]);
} else {
if (options == null) {
options = [];
if (i != 0) {
options.add([_extensionForCompound(compound.components.take(i))]);
options.add([
_extenderForCompound(compound.components.take(i), compoundSpan)
]);
}
}
@ -622,9 +626,9 @@ 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((state) {
state.assertCompatibleMediaContext(mediaQueryContext);
return state.extender;
return options.first.map((extender) {
extender.assertCompatibleMediaContext(mediaQueryContext);
return extender.selector;
}).toList();
}
@ -662,9 +666,9 @@ class ExtensionStore {
first = false;
complexes = [
[
CompoundSelector(path.expand((state) {
assert(state.extender.components.length == 1);
return (state.extender.components.last as CompoundSelector)
CompoundSelector(path.expand((extender) {
assert(extender.selector.components.length == 1);
return (extender.selector.components.last as CompoundSelector)
.components;
}))
]
@ -672,14 +676,14 @@ class ExtensionStore {
} else {
var toUnify = QueueList<List<ComplexSelectorComponent>>();
List<SimpleSelector>? originals;
for (var state in path) {
if (state.isOriginal) {
for (var extender in path) {
if (extender.isOriginal) {
originals ??= [];
originals.addAll(
(state.extender.components.last as CompoundSelector)
(extender.selector.components.last as CompoundSelector)
.components);
} else {
toUnify.add(state.extender.components);
toUnify.add(extender.selector.components);
}
}
@ -692,9 +696,9 @@ class ExtensionStore {
}
var lineBreak = false;
for (var state in path) {
state.assertCompatibleMediaContext(mediaQueryContext);
lineBreak = lineBreak || state.extender.lineBreak;
for (var extender in path) {
extender.assertCompatibleMediaContext(mediaQueryContext);
lineBreak = lineBreak || extender.selector.lineBreak;
}
return complexes
@ -719,57 +723,65 @@ class ExtensionStore {
isOriginal);
}
Iterable<List<Extension>>? _extendSimple(
Iterable<List<Extender>>? _extendSimple(
SimpleSelector simple,
FileSpan? simpleSpan,
Map<SimpleSelector, Map<ComplexSelector, Extension>?>? extensions,
List<CssMediaQuery>? mediaQueryContext,
Set<SimpleSelector>? targetsUsed) {
// Extends [simple] without extending the contents of any selector pseudos
// it contains.
List<Extension>? withoutPseudo(SimpleSelector simple) {
var extenders = extensions![simple];
if (extenders == null) return null;
List<Extender>? withoutPseudo(SimpleSelector simple) {
var extensionsForSimple = extensions![simple];
if (extensionsForSimple == null) return null;
targetsUsed?.add(simple);
if (_mode == ExtendMode.replace) return extenders.values.toList();
return [_extensionForSimple(simple), ...extenders.values];
return [
if (_mode != ExtendMode.replace) _extenderForSimple(simple, simpleSpan),
for (var extension in extensionsForSimple.values) extension.extender
];
}
if (simple is PseudoSelector && simple.selector != null) {
var extended = _extendPseudo(simple, extensions, mediaQueryContext);
var extended =
_extendPseudo(simple, simpleSpan, extensions, mediaQueryContext);
if (extended != null) {
return extended.map(
(pseudo) => withoutPseudo(pseudo) ?? [_extensionForSimple(pseudo)]);
return extended.map((pseudo) =>
withoutPseudo(pseudo) ?? [_extenderForSimple(pseudo, simpleSpan)]);
}
}
return withoutPseudo(simple).andThen((result) => [result]);
}
/// Returns a one-off [Extension] whose extender is composed solely of a
/// compound selector containing [simples].
Extension _extensionForCompound(Iterable<SimpleSelector> simples) {
/// Returns an [Extender] composed solely of a compound selector containing
/// [simples].
Extender _extenderForCompound(
Iterable<SimpleSelector> simples, FileSpan? span) {
var compound = CompoundSelector(simples);
return Extension.oneOff(ComplexSelector([compound]),
specificity: _sourceSpecificityFor(compound), isOriginal: true);
return Extender(ComplexSelector([compound]), span,
specificity: _sourceSpecificityFor(compound), original: true);
}
/// Returns a one-off [Extension] whose extender is composed solely of
/// [simple].
Extension _extensionForSimple(SimpleSelector simple) => Extension.oneOff(
ComplexSelector([
CompoundSelector([simple])
]),
specificity: _sourceSpecificity[simple] ?? 0,
isOriginal: true);
/// Returns an [Extender] composed solely of [simple].
Extender _extenderForSimple(SimpleSelector simple, FileSpan? span) =>
Extender(
ComplexSelector([
CompoundSelector([simple])
]),
span,
specificity: _sourceSpecificity[simple] ?? 0,
original: true);
/// Extends [pseudo] using [extensions], and returns a list of resulting
/// pseudo selectors.
List<PseudoSelector>? _extendPseudo(
PseudoSelector pseudo,
FileSpan? pseudoSpan,
Map<SimpleSelector, Map<ComplexSelector, Extension>?>? extensions,
List<CssMediaQuery>? mediaQueryContext) {
var extended = _extendList(pseudo.selector!, extensions, mediaQueryContext);
var extended = _extendList(
pseudo.selector!, pseudoSpan, extensions, mediaQueryContext);
if (identical(extended, pseudo.selector)) return null;
// For `:not()`, we usually want to get rid of any complex selectors because

View File

@ -26,7 +26,8 @@ class MergedExtension extends Extension {
/// Throws an [ArgumentError] if [left] and [right] don't have the same
/// extender and target.
static Extension merge(Extension left, Extension right) {
if (left.extender != right.extender || left.target != right.target) {
if (left.extender.selector != right.extender.selector ||
left.target != right.target) {
throw ArgumentError("$left and $right aren't the same extension.");
}
@ -49,20 +50,23 @@ class MergedExtension extends Extension {
}
MergedExtension._(this.left, this.right)
: super(left.extender, left.target, left.extenderSpan, left.span,
left.mediaContext ?? right.mediaContext,
specificity: left.specificity, optional: true);
: super(
left.extender.selector, left.extender.span, left.target, left.span,
mediaContext: left.mediaContext ?? right.mediaContext,
optional: true);
/// Returns all leaf-node [Extension]s in the tree or [MergedExtension]s.
/// Returns all leaf-node [Extension]s in the tree of [MergedExtension]s.
Iterable<Extension> unmerge() sync* {
var left = this.left;
if (left is MergedExtension) {
yield* (left as MergedExtension).unmerge();
yield* left.unmerge();
} else {
yield left;
}
var right = this.right;
if (right is MergedExtension) {
yield* (right as MergedExtension).unmerge();
yield* right.unmerge();
} else {
yield right;
}