Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
92 changes: 92 additions & 0 deletions src/ExCSS.Tests/SelectorObjectModelTests.cs
Original file line number Diff line number Diff line change
@@ -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<NotSelector>(Subject("a:not(.foo)"));

Assert.IsType<ClassSelector>(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<HasSelector>(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<MatchesSelector>(Subject("a:matches(.foo, #bar)"));

Assert.IsType<ListSelector>(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<MatchesSelector>(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<ListSelector>(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<CompoundSelector>(ParseSelector("a.foo.bar"));

Assert.Equal(new Priority(0, 0, 2, 1), compound.Specificity);
}
}
}
1 change: 1 addition & 0 deletions src/ExCSS/Enumerations/PseudoClassNames.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
33 changes: 8 additions & 25 deletions src/ExCSS/Parser/SelectorConstructor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)}
};

Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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()
Expand All @@ -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)
Expand All @@ -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()
Expand Down
31 changes: 31 additions & 0 deletions src/ExCSS/Selectors/HasSelector.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
using System.IO;

namespace ExCSS
{
/// <summary>
/// The relational pseudo-class <c>:has(S)</c> - matches an element with a descendant matching S.
/// Retains the parsed argument selector rather than collapsing it to a formatted string.
/// </summary>
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(')');
}
}
}
10 changes: 10 additions & 0 deletions src/ExCSS/Selectors/ListSelector.cs
Original file line number Diff line number Diff line change
@@ -1,11 +1,21 @@
using System.IO;
using System.Linq;

namespace ExCSS
{
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;
Expand Down
37 changes: 37 additions & 0 deletions src/ExCSS/Selectors/MatchesSelector.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
using System.IO;

namespace ExCSS
{
/// <summary>
/// The matches-any pseudo-class <c>:is(S)</c> / <c>:matches(S)</c> - matches an element that matches
/// any selector in S. <c>:is()</c> is the current name and <c>:matches()</c> the legacy alias; both
/// share this class, and <see cref="Keyword"/> controls which form it round-trips to.
/// </summary>
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(')');
}
}
}
31 changes: 31 additions & 0 deletions src/ExCSS/Selectors/NotSelector.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
using System.IO;

namespace ExCSS
{
/// <summary>
/// The negation pseudo-class <c>:not(S)</c> - matches an element that does not match S. Retains the
/// parsed argument selector rather than collapsing it to a formatted string.
/// </summary>
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(')');
}
}
}
5 changes: 4 additions & 1 deletion src/ExCSS/Selectors/Selectors.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,10 @@ protected Selectors()
_selectors = new List<ISelector>();
}

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
{
Expand Down