From a6ea7db86f1f213fea6061c55888d222d9dad386 Mon Sep 17 00:00:00 2001 From: Justin Haygood Date: Wed, 22 Jul 2026 18:27:52 -0400 Subject: [PATCH] Add :is(), retain argument selectors for :not()/:has()/:matches(), fix forgiving-list specificity :not(), :has() and :matches() collapsed their parsed argument to a formatted string and wrapped it in an opaque PseudoClassSelector, so the inner selector object - and any way to inspect or re-match it - was lost. Introduce NotSelector, HasSelector and MatchesSelector, each retaining the parsed argument ISelector, and add :is() as the modern alias of :matches() (they share MatchesSelector; a Keyword field controls which form it serializes to). Fix specificity to match CSS Selectors 4 16.1: the specificity of an :is()/:not()/:has() is that of the most specific complex selector in its argument. That requires a selector list to report the static max of its alternatives rather than their sum, so Selectors.Specificity becomes virtual and ListSelector overrides it. CompoundSelector keeps the sum, which is correct - its members all constrain one element. Verified against the spec's own examples: :is(em, #foo) is (1,0,0) and :not(em, strong#foo) is (1,0,1). --- src/ExCSS.Tests/SelectorObjectModelTests.cs | 92 +++++++++++++++++++++ src/ExCSS/Enumerations/PseudoClassNames.cs | 1 + src/ExCSS/Parser/SelectorConstructor.cs | 33 ++------ src/ExCSS/Selectors/HasSelector.cs | 31 +++++++ src/ExCSS/Selectors/ListSelector.cs | 10 +++ src/ExCSS/Selectors/MatchesSelector.cs | 37 +++++++++ src/ExCSS/Selectors/NotSelector.cs | 31 +++++++ src/ExCSS/Selectors/Selectors.cs | 5 +- 8 files changed, 214 insertions(+), 26 deletions(-) create mode 100644 src/ExCSS.Tests/SelectorObjectModelTests.cs create mode 100644 src/ExCSS/Selectors/HasSelector.cs create mode 100644 src/ExCSS/Selectors/MatchesSelector.cs create mode 100644 src/ExCSS/Selectors/NotSelector.cs diff --git a/src/ExCSS.Tests/SelectorObjectModelTests.cs b/src/ExCSS.Tests/SelectorObjectModelTests.cs new file mode 100644 index 00000000..3f4dffdf --- /dev/null +++ b/src/ExCSS.Tests/SelectorObjectModelTests.cs @@ -0,0 +1,92 @@ +using System.Linq; +using Xunit; + +namespace ExCSS.Tests +{ + public class SelectorObjectModelTests : CssConstructionFunctions + { + private static ISelector ParseSelector(string selector) + { + var sheet = ParseStyleSheet(selector + " { color: red }"); + return ((StyleRule)sheet.Rules[0]).Selector; + } + + private static ISelector Subject(string selector) + { + var sel = ParseSelector(selector); + return sel is CompoundSelector compound ? compound.Last() : sel; + } + + [Fact] + public void NotProducesNotSelectorRetainingInnerSelector() + { + var not = Assert.IsType(Subject("a:not(.foo)")); + + Assert.IsType(not.Inner); + Assert.Equal(".foo", not.Inner.Text); + Assert.Equal("a:not(.foo)", ParseSelector("a:not(.foo)").Text); + } + + [Fact] + public void HasProducesHasSelectorRetainingInnerSelector() + { + var has = Assert.IsType(Subject("a:has(.foo)")); + + Assert.Equal(".foo", has.Inner.Text); + Assert.Equal("a:has(.foo)", ParseSelector("a:has(.foo)").Text); + } + + [Fact] + public void MatchesProducesMatchesSelectorRetainingInnerSelector() + { + var matches = Assert.IsType(Subject("a:matches(.foo, #bar)")); + + Assert.IsType(matches.Inner); + Assert.Equal("matches", matches.Keyword); + Assert.Equal("a:matches(.foo,#bar)", ParseSelector("a:matches(.foo, #bar)").Text); + } + + [Fact] + public void IsIsAnAliasOfMatchesAndRoundTripsAsIs() + { + var matches = Assert.IsType(Subject("a:is(.foo, #bar)")); + + Assert.Equal("is", matches.Keyword); + Assert.Equal("a:is(.foo,#bar)", ParseSelector("a:is(.foo, #bar)").Text); + } + + [Theory] + // CSS Selectors 4 16.1: the specificity of :is()/:not()/:has() is that of the most specific complex + // selector in the argument. Priority is (inline, id, class, type). + [InlineData(":is(em, #foo)", 0, 1, 0, 0)] // spec's own example -> like #foo + [InlineData(":not(em, strong#foo)", 0, 1, 0, 1)] // spec's own example -> #foo + type + [InlineData("a:not(.foo)", 0, 0, 1, 1)] + [InlineData("a:has(.foo)", 0, 0, 1, 1)] + [InlineData("a:is(.foo, #bar)", 0, 1, 0, 1)] + public void ForgivingPseudoClassSpecificityIsMostSpecificArgument(string selector, + int inline, int id, int cls, int type) + { + var specificity = ParseSelector(selector).Specificity; + + Assert.Equal(new Priority((byte)inline, (byte)id, (byte)cls, (byte)type), specificity); + } + + [Fact] + public void ListSelectorSpecificityIsMaxNotSum() + { + // A comma-separated list's specificity is the static max of its alternatives, not their sum. + var list = Assert.IsType(ParseSelector(".a, #b, c")); + + Assert.Equal(new Priority(0, 1, 0, 0), list.Specificity); + } + + [Fact] + public void CompoundSelectorSpecificityRemainsTheSum() + { + // A compound selector's members all constrain one element, so its specificity is still the sum. + var compound = Assert.IsType(ParseSelector("a.foo.bar")); + + Assert.Equal(new Priority(0, 0, 2, 1), compound.Specificity); + } + } +} diff --git a/src/ExCSS/Enumerations/PseudoClassNames.cs b/src/ExCSS/Enumerations/PseudoClassNames.cs index aa6bbabb..669e8d27 100644 --- a/src/ExCSS/Enumerations/PseudoClassNames.cs +++ b/src/ExCSS/Enumerations/PseudoClassNames.cs @@ -39,6 +39,7 @@ public static class PseudoClassNames public static readonly string Dir = "dir"; public static readonly string Has = "has"; public static readonly string Matches = "matches"; + public static readonly string Is = "is"; public static readonly string NthChild = "nth-child"; public static readonly string NthLastChild = "nth-last-child"; public static readonly string NthOfType = "nth-of-type"; diff --git a/src/ExCSS/Parser/SelectorConstructor.cs b/src/ExCSS/Parser/SelectorConstructor.cs index c29678bf..517a7b49 100644 --- a/src/ExCSS/Parser/SelectorConstructor.cs +++ b/src/ExCSS/Parser/SelectorConstructor.cs @@ -54,7 +54,8 @@ private enum State : byte {PseudoClassNames.Lang, _ => new LangFunctionState()}, {PseudoClassNames.Contains, _ => new ContainsFunctionState()}, {PseudoClassNames.Has, ctx => new HasFunctionState(ctx)}, - {PseudoClassNames.Matches, ctx => new MatchesFunctionState(ctx)}, + {PseudoClassNames.Matches, ctx => new MatchesFunctionState(ctx, PseudoClassNames.Matches)}, + {PseudoClassNames.Is, ctx => new MatchesFunctionState(ctx, PseudoClassNames.Is)}, {PseudoClassNames.HostContext, ctx => new HostContextFunctionState(ctx)} }; @@ -523,13 +524,7 @@ public override ISelector Produce() { var valid = _selector.IsValid; var sel = _selector.GetResult(); - if (valid) - { - var code = PseudoClassNames.Not.StylesheetFunction(sel.Text); - return PseudoClassSelector.Create( /*el => !sel.Match(el),*/ code); - } - - return null; + return valid ? new NotSelector(sel) : null; } public override void Dispose() @@ -563,14 +558,7 @@ public override ISelector Produce() { var valid = _nested.IsValid; var sel = _nested.GetResult(); - - if (!valid) - { - return null; - } - - var code = PseudoClassNames.Has.StylesheetFunction(sel.Text); - return PseudoClassSelector.Create( /*el => el.ChildNodes.QuerySelector(sel) != null,*/ code); + return valid ? new HasSelector(sel) : null; } public override void Dispose() @@ -583,10 +571,12 @@ public override void Dispose() private sealed class MatchesFunctionState : FunctionState { private readonly SelectorConstructor _selector; + private readonly string _keyword; - public MatchesFunctionState(SelectorConstructor parent) + public MatchesFunctionState(SelectorConstructor parent, string keyword) { _selector = parent.CreateChild(); + _keyword = keyword; } protected override bool OnToken(Token token) @@ -605,14 +595,7 @@ public override ISelector Produce() { var valid = _selector.IsValid; var sel = _selector.GetResult(); - if (!valid) - { - return null; - } - - var code = PseudoClassNames.Matches.StylesheetFunction(sel.Text); - return PseudoClassSelector.Create( /*el => sel.Match(el),*/ code); - + return valid ? new MatchesSelector(sel, _keyword) : null; } public override void Dispose() diff --git a/src/ExCSS/Selectors/HasSelector.cs b/src/ExCSS/Selectors/HasSelector.cs new file mode 100644 index 00000000..59554b6f --- /dev/null +++ b/src/ExCSS/Selectors/HasSelector.cs @@ -0,0 +1,31 @@ +using System.IO; + +namespace ExCSS +{ + /// + /// The relational pseudo-class :has(S) - matches an element with a descendant matching S. + /// Retains the parsed argument selector rather than collapsing it to a formatted string. + /// + public sealed class HasSelector : StylesheetNode, ISelector + { + public HasSelector(ISelector inner) + { + Inner = inner; + } + + public ISelector Inner { get; } + + // The specificity of :has() is the specificity of its most specific argument (CSS Selectors 4 + // 16.1), which ListSelector.Specificity supplies as the static max when the argument is a list. + public Priority Specificity => Inner.Specificity; + + public string Text => this.ToCss(); + + public override void ToCss(TextWriter writer, IStyleFormatter formatter) + { + writer.Write(":has("); + writer.Write(Inner.Text); + writer.Write(')'); + } + } +} diff --git a/src/ExCSS/Selectors/ListSelector.cs b/src/ExCSS/Selectors/ListSelector.cs index d246ddd9..381cd9b2 100644 --- a/src/ExCSS/Selectors/ListSelector.cs +++ b/src/ExCSS/Selectors/ListSelector.cs @@ -1,4 +1,5 @@ using System.IO; +using System.Linq; namespace ExCSS { @@ -6,6 +7,15 @@ public sealed class ListSelector : Selectors, ISelector { public bool IsInvalid { get; internal set; } + // A comma-separated selector list's specificity is not the sum of its alternatives (that sum is + // only meaningful for a CompoundSelector, whose members all constrain one element at once). As the + // argument of :is()/:not()/:has() it is the static max across the alternatives - the most specific + // one, regardless of which (if any) matches a given element (CSS Selectors 4 16.1). A top-level + // list (".a, .b { }") is shorthand for separate rules, each with its own per-alternative + // specificity, so no single value is meaningful there; max is the closest sensible answer. + public override Priority Specificity => + _selectors.Count == 0 ? Priority.Zero : _selectors.Max(s => s.Specificity); + public override void ToCss(TextWriter writer, IStyleFormatter formatter) { if (_selectors.Count <= 0) return; diff --git a/src/ExCSS/Selectors/MatchesSelector.cs b/src/ExCSS/Selectors/MatchesSelector.cs new file mode 100644 index 00000000..28c398a0 --- /dev/null +++ b/src/ExCSS/Selectors/MatchesSelector.cs @@ -0,0 +1,37 @@ +using System.IO; + +namespace ExCSS +{ + /// + /// The matches-any pseudo-class :is(S) / :matches(S) - matches an element that matches + /// any selector in S. :is() is the current name and :matches() the legacy alias; both + /// share this class, and controls which form it round-trips to. + /// + public sealed class MatchesSelector : StylesheetNode, ISelector + { + public MatchesSelector(ISelector inner, string keyword) + { + Inner = inner; + Keyword = keyword; + } + + public ISelector Inner { get; } + + public string Keyword { get; } + + // The specificity of :is()/:matches() is the specificity of its most specific argument (CSS + // Selectors 4 16.1), which ListSelector.Specificity supplies as the static max for a list argument. + public Priority Specificity => Inner.Specificity; + + public string Text => this.ToCss(); + + public override void ToCss(TextWriter writer, IStyleFormatter formatter) + { + writer.Write(':'); + writer.Write(Keyword); + writer.Write('('); + writer.Write(Inner.Text); + writer.Write(')'); + } + } +} diff --git a/src/ExCSS/Selectors/NotSelector.cs b/src/ExCSS/Selectors/NotSelector.cs new file mode 100644 index 00000000..9c8cc0a7 --- /dev/null +++ b/src/ExCSS/Selectors/NotSelector.cs @@ -0,0 +1,31 @@ +using System.IO; + +namespace ExCSS +{ + /// + /// The negation pseudo-class :not(S) - matches an element that does not match S. Retains the + /// parsed argument selector rather than collapsing it to a formatted string. + /// + public sealed class NotSelector : StylesheetNode, ISelector + { + public NotSelector(ISelector inner) + { + Inner = inner; + } + + public ISelector Inner { get; } + + // The specificity of :not() is the specificity of its most specific argument (CSS Selectors 4 + // 16.1), which ListSelector.Specificity supplies as the static max when the argument is a list. + public Priority Specificity => Inner.Specificity; + + public string Text => this.ToCss(); + + public override void ToCss(TextWriter writer, IStyleFormatter formatter) + { + writer.Write(":not("); + writer.Write(Inner.Text); + writer.Write(')'); + } + } +} diff --git a/src/ExCSS/Selectors/Selectors.cs b/src/ExCSS/Selectors/Selectors.cs index bfd099a0..0cc72983 100644 --- a/src/ExCSS/Selectors/Selectors.cs +++ b/src/ExCSS/Selectors/Selectors.cs @@ -13,7 +13,10 @@ protected Selectors() _selectors = new List(); } - public Priority Specificity + // A CompoundSelector's specificity is the sum of its members - they all constrain the same + // element. A ListSelector overrides this, because a comma-separated list's specificity is not a + // sum (see ListSelector). + public virtual Priority Specificity { get {