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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
Released on Monday, November 2 2026.

- Improved the parser with a nesting limit for values and conditions (`MaxValueNestingDepth`) (#260) @michaelthyregod
- Improved indexing rules with deeply nested `:is()`, `:where()`, or `:matches()` (#261) @michaelthyregod

# 1.3.1

Expand Down
5 changes: 5 additions & 0 deletions src/AngleSharp.Css.Tests/Styling/StyleRuleIndexTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ public async Task GroupedAndUngroupedRulesKeepTheCascadeOrder(string css)
[TestCase(":where(.x .a, div) { color: green }", "<div id=t>A</div>")]
[TestCase(":matches(.x, .b) { color: green }", "<div id=t class=b>A</div>")]
[TestCase(":is(:is(.x, .b)) { color: green }", "<div id=t class=b>A</div>")]
[TestCase(":is(:is(:is(:is(:is(:is(:is(:is(:is(.b))))))))) { color: green }", "<div id=t class=b>A</div>")]
[TestCase(":is(.x, *) { color: green }", "<div id=t>A</div>")]
[TestCase(":is(.x, :not(.y)) { color: green }", "<div id=t>A</div>")]
[TestCase(".parent { :is(&) { color: green } }", "<p id=t class=parent>A</p>")]
Expand All @@ -89,6 +90,7 @@ public async Task RulesAreFoundViaTheirSubject(string css, string body)
[TestCase(".a .b { color: red }", "<div class=b><p id=t class=a>A</p></div>")]
[TestCase("[data-x] { color: red }", "<p id=t data-y>A</p>")]
[TestCase(":is(.x .a, .b) { color: red }", "<p id=t class=a>A</p>")]
[TestCase(":is(:is(:is(:is(:is(:is(:is(:is(:is(.x))))))))) { color: red }", "<p id=t class=a>A</p>")]
public async Task RulesForOtherSubjectsDoNotApply(string css, string body)
{
var color = await ColorOfAsync(css, body);
Expand All @@ -102,6 +104,9 @@ public async Task RulesForOtherSubjectsDoNotApply(string css, string body)
[TestCase(":is(.x, .a) { color: red }", 1)]
[TestCase(":where(.x .y, #z) { color: red }", 0)]
[TestCase(":is(.x, :not(.y)) { color: red }", 1)]
// the index looks into 8 levels of :is(); a deeper rule stays ungrouped, i.e., a candidate
[TestCase(":is(:is(:is(:is(:is(:is(:is(:is(.x)))))))) { color: red }", 0)]
[TestCase(":is(:is(:is(:is(:is(:is(:is(:is(:is(.x))))))))) { color: red }", 1)]
[TestCase("! { color: red }", 0)]
public void OnlyRulesThatMayMatchAreCandidates(string css, int count)
{
Expand Down
26 changes: 18 additions & 8 deletions src/AngleSharp.Css/Dom/Internal/StyleRuleIndex.cs
Original file line number Diff line number Diff line change
Expand Up @@ -355,25 +355,35 @@ private sealed class SubjectKeyVisitor : ISelectorVisitor
PseudoClassNames.Matches,
};

// How many :is() levels are looked into. Each one parses its arguments again, so a
// deeply nested selector would cost quadratic time (and stack) to index; real ones
// nest a level or two. Deeper, the rule just stays ungrouped, which is always correct.
private const Int32 MaxArgumentDepth = 8;

private readonly ISelector _selector;
private readonly ICssSelectorParser? _parser;
private readonly Int32 _depth;
private SubjectKey[]? _keys;

private SubjectKeyVisitor(ISelector selector, ICssSelectorParser? parser)
private SubjectKeyVisitor(ISelector selector, ICssSelectorParser? parser, Int32 depth)
{
_selector = selector;
_parser = parser;
_depth = depth;
}

public static SubjectKey[]? GetKeys(ISelector? selector, ICssSelectorParser? parser)
public static SubjectKey[]? GetKeys(ISelector? selector, ICssSelectorParser? parser) =>
GetKeys(selector, parser, 0);

private static SubjectKey[]? GetKeys(ISelector? selector, ICssSelectorParser? parser, Int32 depth)
{
// a forced pseudo-class matches independent of its own selector
if (selector is null || selector is ForcingPseudoClassSelector)
{
return null;
}

var visitor = new SubjectKeyVisitor(selector, parser);
var visitor = new SubjectKeyVisitor(selector, parser, depth);
selector.Accept(visitor);
return visitor._keys;
}
Expand Down Expand Up @@ -409,7 +419,7 @@ void ISelectorVisitor.PseudoClass(String name)
{
// :is(.a .b, .c) only exposes its text, but an element matching it matches one
// of the arguments, i.e., it requires one of their keys - just as a list does
var arguments = GetArguments(name);
var arguments = _depth < MaxArgumentDepth ? GetArguments(name) : null;

if (arguments is not null && _parser is not null)
{
Expand All @@ -419,7 +429,7 @@ void ISelectorVisitor.PseudoClass(String name)
// are valid only where the rule is (e.g., nested); then the keys are incomplete
if (selector is not null && selector.Text.Equals(arguments, StringComparison.Ordinal))
{
_keys = GetKeys(selector, _parser);
_keys = GetKeys(selector, _parser, _depth + 1);
}
}
}
Expand All @@ -434,7 +444,7 @@ void ISelectorVisitor.List(IEnumerable<ISelector> selectors)

foreach (var selector in selectors)
{
var alternative = GetKeys(selector, _parser);
var alternative = GetKeys(selector, _parser, _depth);

if (alternative is null)
{
Expand All @@ -449,15 +459,15 @@ void ISelectorVisitor.List(IEnumerable<ISelector> selectors)

void ISelectorVisitor.Combinator(IEnumerable<ISelector> selectors, IEnumerable<String> symbols)
{
_keys = GetKeys(selectors.LastOrDefault(), _parser);
_keys = GetKeys(selectors.LastOrDefault(), _parser, _depth);
}

void ISelectorVisitor.Many(IEnumerable<ISelector> selectors)
{
// all parts of a compound must match, so any key of a part is required
foreach (var selector in selectors)
{
var keys = GetKeys(selector, _parser);
var keys = GetKeys(selector, _parser, _depth);

if (keys is not null && (_keys is null || IsBetter(keys, _keys)))
{
Expand Down
44 changes: 44 additions & 0 deletions src/AngleSharp.Performance.Css/DeepSelectorBenchmarks.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
namespace AngleSharp.Performance.Css
{
using AngleSharp;
using AngleSharp.Css.Dom;
using AngleSharp.Dom;
using AngleSharp.Html.Parser;
using BenchmarkDotNet.Attributes;
using System;
using System.Text;

/// <summary>
/// Computing a style with a rule whose selector nests :is(): the cascade indexes the rule by
/// looking into the arguments. The depths stay within the 75 levels AngleSharp allows from
/// 1.9.1 on (Firefox's limit).
/// </summary>
[MemoryDiagnoser]
public class DeepSelectorBenchmarks
{
private IHtmlParser _parser = null!;
private String _source = null!;

[Params(10, 50, 75)]
public Int32 Depth { get; set; }

[GlobalSetup]
public void Setup()
{
var context = BrowsingContext.New(Configuration.Default.WithCss());
_parser = context.GetService<IHtmlParser>()!;
_source = "<style>" + Repeat(":is(", Depth) + ".x" + Repeat(")", Depth) + "{color:red}</style><div class=x>x</div>";
}

[Benchmark]
public String ComputedStyle()
{
// a fresh document, so the rule is indexed again (the index is kept with the rule)
var document = _parser.ParseDocument(_source);
return document.DefaultView!.GetComputedStyle(document.QuerySelector("div")!).GetColor();
}

private static String Repeat(String value, Int32 count) =>
new StringBuilder(value.Length * count).Insert(0, value, count).ToString();
}
}
Loading