From cb4cbf28a5d5fbaf47928bcd456d5acf0d8fc57b Mon Sep 17 00:00:00 2001 From: Justin Haygood Date: Fri, 24 Jul 2026 15:45:35 -0400 Subject: [PATCH] Implement CSS Nesting (& selector and nested style rules) Parse nested style rules and the & nesting selector (CSS Nesting 1), resolving each nested rule at parse time into an ordinary flat rule with an absolute selector (& -> :is(parent)), exposed on the object model via IStyleRule.NestedRules so the parser needs no matcher/cascade awareness. - StylesheetComposer classifies a declaration-block construct as a nested rule vs a declaration by scanning bracket-aware for the first top-level { (vs ;/}), with a -- custom-property guard. On the declaration branch it rewinds the lexer (LexerBase.RewindTo, a faithful source-index restore -- not the InsertionPoint setter, whose BackNative loop mis-counts across \r\n) and re-lexes cleanly in value mode, avoiding the hex-color value-mode tokenization hazard a token buffer would hit. - Nested selectors resolve against the enclosing rule's SelectorText and parse via Parser.ParseSelector; the & substitution is string-aware so a & inside an attribute-value string is preserved. StyleRule gains a NestedRules list. Ported from PeachPDF, which vendors ExCSS. --- src/ExCSS.Tests/CssNestingTests.cs | 130 ++++++++++++++++++ src/ExCSS/Parser/LexerBase.cs | 16 +++ src/ExCSS/Parser/StylesheetComposer.cs | 177 ++++++++++++++++++++++++- src/ExCSS/Rules/IStyleRule.cs | 12 +- src/ExCSS/Rules/MarginStyleRule.cs | 7 +- src/ExCSS/Rules/StyleRule.cs | 12 +- 6 files changed, 348 insertions(+), 6 deletions(-) create mode 100644 src/ExCSS.Tests/CssNestingTests.cs diff --git a/src/ExCSS.Tests/CssNestingTests.cs b/src/ExCSS.Tests/CssNestingTests.cs new file mode 100644 index 00000000..f5f9a444 --- /dev/null +++ b/src/ExCSS.Tests/CssNestingTests.cs @@ -0,0 +1,130 @@ +using System.Linq; +using Xunit; + +namespace ExCSS.Tests +{ + /// + /// CSS Nesting (CSS Nesting 1): the & + /// nesting selector and nested style rules inside a declaration block. Nested rules are resolved at + /// parse time into ordinary rules with absolute selectors (&:is(parent)) and + /// exposed via . Includes the regression guards that the + /// declaration path is unaffected (hex colors, custom properties, a declaration after a nested rule). + /// + public class CssNestingTests : CssConstructionFunctions + { + private static StyleRule ParseRule(string source) => + (StyleRule)ParseStyleSheet(source).Rules.First(); + + [Fact] + public void ImplicitAmpersandCompoundResolvesToIsParent() + { + // `&.active` == `:is(.box).active` — the same element carrying both classes. + var rule = ParseRule(".box { &.active { color: #0000ff; } }"); + var nested = Assert.Single(rule.NestedRules); + Assert.Equal(":is(.box).active", nested.SelectorText); + } + + [Fact] + public void ImplicitDescendantResolvesToIsParentDescendant() + { + // `& span` == `:is(.box) span` (descendant). + var rule = ParseRule(".box { color: #ff0000; & span { color: #0000ff; } }"); + Assert.Equal(".box", rule.SelectorText); + var nested = Assert.Single(rule.NestedRules); + Assert.Equal(":is(.box) span", nested.SelectorText); + } + + [Fact] + public void TypeSelectorNestedRuleIsNotMistakenForADeclaration() + { + // `p { ... }` inside a block starts with an Ident (like a property name) — the classifier must + // still see the `{` before any `;` and treat it as a nested rule. + var rule = ParseRule(".card { p { color: #0000ff; } }"); + var nested = Assert.Single(rule.NestedRules); + Assert.Equal(":is(.card) p", nested.SelectorText); + } + + [Fact] + public void NoAmpersandPreludeIsScopedUnderParent() + { + // A prelude with no `&` is made relative to the parent: `.inner` → `:is(.card) .inner`. + var rule = ParseRule(".card { .inner { color: #0000ff; } }"); + var nested = Assert.Single(rule.NestedRules); + Assert.Equal(":is(.card) .inner", nested.SelectorText); + } + + [Fact] + public void LeadingChildCombinatorIsScopedUnderParent() + { + // `> p` == `:is(.card) > p` — only a direct child p matches. + var rule = ParseRule(".card { > p { color: #0000ff; } }"); + var nested = Assert.Single(rule.NestedRules); + Assert.Equal(":is(.card)>p", nested.SelectorText); + } + + [Fact] + public void HexColorInNestedValueSurvivesTheLookahead() + { + // Regression: the classify-then-rewind must not corrupt a `#rrggbb` value (value-mode `#` + // tokenization) the way a token buffer would. Compare against a non-nested equivalent. + var nested = Assert.Single(ParseRule(".card { p { color: #123456; } }").NestedRules); + var direct = ParseRule("p { color: #123456; }"); + Assert.Equal(direct.Style["color"], nested.Style["color"]); + Assert.NotEqual(string.Empty, nested.Style["color"]); + } + + [Fact] + public void HexColorInTopLevelValueUnaffected() + { + // The whole feature must leave an ordinary (non-nested) declaration block unaffected. + var direct = ParseRule("p { color: #123456; }"); + var control = ParseRule("q { color: #123456; }"); + Assert.Equal(control.Style["color"], direct.Style["color"]); + Assert.Empty(direct.NestedRules); + } + + [Fact] + public void DeclarationAfterNestedRuleStillApplies() + { + // A declaration written AFTER a nested rule still applies to the parent (the outer loop resumes + // correctly); here the later blue wins over the earlier red. + var rule = ParseRule(".card { color: #ff0000; & span { color: #00ff00; } color: #0000ff; }"); + var blue = ParseRule("x { color: #0000ff; }").Style["color"]; + Assert.Equal(blue, rule.Style["color"]); + Assert.Single(rule.NestedRules); + } + + [Fact] + public void CustomPropertyIsNotMistakenForANestedRule() + { + // `--x: …` is always a declaration, never a nested rule, even though the classifier scans for `{`. + // (Custom-property value storage is a separate feature; here we only assert the `--x` declaration + // is not swallowed as a nested rule and the real nested rule is still captured.) + var rule = ParseRule(".card { --x: 1; & span { color: #0000ff; } }"); + var nested = Assert.Single(rule.NestedRules); + Assert.Equal(":is(.card) span", nested.SelectorText); + } + + [Fact] + public void MultiLevelNestingResolvesAgainstEachParent() + { + // `.a { & .b { & .c { … } } }` → `:is(.a) .b`, then `:is(:is(.a) .b) .c`. + var a = ParseRule(".a { & .b { & .c { color: #0000ff; } } }"); + var b = Assert.Single(a.NestedRules); + Assert.Equal(":is(.a) .b", b.SelectorText); + var c = Assert.Single(b.NestedRules); + Assert.Equal(":is(:is(.a) .b) .c", c.SelectorText); + } + + [Fact] + public void AmpersandInsideAttributeStringIsPreserved() + { + // Regression: a `&` inside an attribute-value string must NOT be substituted, and a prelude whose + // only `&` is inside a string is still scoped under the parent (not the replace branch). + var rule = ParseRule(".card { [data-x=\"a&b\"] { color: #0000ff; } }"); + var nested = Assert.Single(rule.NestedRules); + Assert.StartsWith(":is(.card)", nested.SelectorText); + Assert.Contains("a&b", nested.SelectorText); + } + } +} diff --git a/src/ExCSS/Parser/LexerBase.cs b/src/ExCSS/Parser/LexerBase.cs index b734aa68..e259528a 100644 --- a/src/ExCSS/Parser/LexerBase.cs +++ b/src/ExCSS/Parser/LexerBase.cs @@ -42,6 +42,22 @@ public TextPosition GetCurrentPosition() return new(Line, Column, Position); } + /// + /// Repositions the lexer so the next token is re-lexed from character index + /// (a raw index, as returned by + /// ). Unlike setting (whose + /// BackNative loop is not a faithful inverse across \r\n normalization), this + /// restores the character stream exactly — line/column tracking is not rewound (it only affects + /// reported positions, never token content). Used by to rewind a + /// CSS-Nesting classification look-ahead so a declaration re-lexes cleanly in value mode. + /// + public void RewindTo(int sourceIndex) + { + Source.Index = sourceIndex; + // Non-EOF so the next Advance() actually reads Source[sourceIndex] rather than short-circuiting. + Current = Symbols.Null; + } + protected char SkipSpaces() { var c = GetNext(); diff --git a/src/ExCSS/Parser/StylesheetComposer.cs b/src/ExCSS/Parser/StylesheetComposer.cs index 5ab8cce6..93b50e56 100644 --- a/src/ExCSS/Parser/StylesheetComposer.cs +++ b/src/ExCSS/Parser/StylesheetComposer.cs @@ -10,6 +10,12 @@ internal sealed class StylesheetComposer private readonly StylesheetParser _parser; private readonly Stack _nodes; + // The source index (raw, into _lexer.Source) immediately before the most recently read token. + // Captured by NextToken so a CSS-Nesting classification look-ahead can rewind to a construct's + // exact start without any position arithmetic (which is unreliable across \r\n normalization and + // unicode escapes) and can slice the nested prelude's source text. + private int _markBeforeLastToken; + public StylesheetComposer(Lexer lexer, StylesheetParser parser) { _lexer = lexer; @@ -663,9 +669,13 @@ public TextPosition FillDeclarations(StyleDeclaration style) else { // Advance to the next token or this is an endless loop - token = _lexer.Get(); + token = NextToken(); } } + else if (TryCreateNestedRule(ref token)) + { + // CSS Nesting: a nested style rule was parsed and attached to the enclosing rule. + } else { var sourceProperty = CreateDeclarationWith(PropertyFactory.Instance.Create, ref token); @@ -719,6 +729,168 @@ public TextPosition FillDeclarations(StyleDeclaration style) return token.Position; } + /// + /// CSS Nesting ([CSS Nesting 1](https://www.w3.org/TR/css-nesting-1/)): if the current construct + /// in a declaration block is a nested style rule (a selector prelude followed by a { } + /// block) rather than a declaration, parses it, resolves its selector against the enclosing rule, + /// attaches it, and returns true (with advanced past the block). Returns + /// false for an ordinary declaration, having rewound the lexer so the caller re-reads it. + /// + private bool TryCreateNestedRule(ref Token token) + { + // Raw source index just before the current (first) token — the construct start, captured by + // NextToken. Faithful across \r\n normalization, unlike deriving it from token.Position. + var preludeStart = _markBeforeLastToken; + + if (!IsNestedRuleAhead(ref token, out var braceStart)) + return false; + + CreateNestedStyleRule(preludeStart, braceStart); + token = NextToken(); + return true; + } + + /// + /// Looks ahead (bracket-aware) to classify the construct starting at as a + /// nested style rule vs a declaration: the first top-level { means a nested rule (the stream + /// is left just after it and its source index returned via ); a + /// top-level ;/}/EOF means a declaration, in which case the lexer is rewound to the + /// construct start and re-read so the unchanged declaration path re-lexes + /// it in value mode (avoiding the # value-mode tokenization hazard a token buffer would hit). + /// Custom properties (--x) are always declarations, even with a { in their value. + /// Function tokens (e.g. url(…)) are opaque — the lexer already consumed their parentheses — + /// so only bare round/square brackets contribute to depth. + /// + private bool IsNestedRuleAhead(ref Token token, out int braceStart) + { + braceStart = 0; + + if (token.Type == TokenType.Ident && token.Data is { } name && + name.StartsWith("--", StringComparison.Ordinal)) + return false; + + var rewindMark = _markBeforeLastToken; // raw source index before `token` (the first token) + var depth = 0; + var scan = token; + var scanStart = rewindMark; // raw source index before `scan` + + while (scan.Type != TokenType.EndOfFile) + { + switch (scan.Type) + { + case TokenType.RoundBracketOpen: + case TokenType.SquareBracketOpen: + depth++; + break; + case TokenType.RoundBracketClose: + case TokenType.SquareBracketClose: + if (depth > 0) depth--; + break; + case TokenType.CurlyBracketOpen when depth == 0: + braceStart = scanStart; + token = scan; + return true; + case TokenType.CurlyBracketClose when depth == 0: + case TokenType.Semicolon when depth == 0: + _lexer.RewindTo(rewindMark); + token = NextToken(); + return false; + } + + scanStart = _lexer.InsertionPoint; // raw index just before the next token + scan = _lexer.Get(); + } + + _lexer.RewindTo(rewindMark); + token = NextToken(); + return false; + } + + /// + /// Builds a from a nested prelude (source span + /// [preludeStart, braceStart)) resolved against the enclosing rule's selector, consumes + /// its declaration block from the live stream, and attaches it to the parent rule via + /// . The resolved selector is absolute (& → + /// :is(parent)) so the cascade/matcher need no nesting awareness. + /// + private void CreateNestedStyleRule(int preludeStart, int braceStart) + { + var parent = _nodes.OfType().FirstOrDefault(); + var preludeText = _lexer.Source.Text.Substring(preludeStart, braceStart - preludeStart); + + var selector = parent == null + ? null + : _parser.ParseSelector(ResolveNestedSelector(preludeText, parent.SelectorText)); + + // Always consume the block (via a rule pushed on _nodes so nested-within-nested resolves) to + // keep the parser in sync; only attach it when the selector actually resolved. + var rule = new StyleRule(_parser); + + if (selector != null) + rule.Selector = selector; + + _nodes.Push(rule); + FillDeclarations(rule.Style); + _nodes.Pop(); + + if (parent != null && selector != null) + parent.AddNestedRule(rule); + } + + /// + /// Resolves a nested selector's prelude text against its parent's selector text per CSS Nesting: + /// each & becomes :is(parent) (giving & the parent's specificity); a + /// prelude with no & is made relative to the parent (:is(parent) <prelude>), + /// which correctly yields both the implicit-descendant (.b:is(parent) .b) and the + /// leading-combinator (> .b:is(parent) > .b) forms. + /// + private static string ResolveNestedSelector(string prelude, string parentText) + { + var trimmed = prelude.Trim(); + var parentIs = ":is(" + (string.IsNullOrEmpty(parentText) ? "*" : parentText) + ")"; + + // Substitute `&` token-aware: only a `&` that is a real nesting selector is replaced, never a + // `&` inside a string/attribute value (e.g. `[data-x="a&b"]`), which must be preserved. This + // also decides the "has &" branch correctly, so a prelude whose only `&` is inside a string is + // still scoped under the parent (:is(parent) ) rather than mis-taking the replace path. + var sb = Pool.NewStringBuilder(); + var hasNestingSelector = false; + var quote = '\0'; + + for (var i = 0; i < trimmed.Length; i++) + { + var c = trimmed[i]; + + if (quote != '\0') + { + sb.Append(c); + if (c == '\\' && i + 1 < trimmed.Length) + sb.Append(trimmed[++i]); // escaped char inside a string — copy verbatim + else if (c == quote) + quote = '\0'; + continue; + } + + switch (c) + { + case '"': + case '\'': + quote = c; + sb.Append(c); + break; + case '&': + hasNestingSelector = true; + sb.Append(parentIs); + break; + default: + sb.Append(c); + break; + } + } + + return hasNestingSelector ? sb.ToPool() : parentIs + " " + sb.ToPool(); + } + public Property CreateDeclarationWith(Func createProperty, ref Token token) { var property = default(Property); @@ -916,6 +1088,7 @@ private void JumpToDeclEnd(ref Token current) private Token NextToken() { + _markBeforeLastToken = _lexer.InsertionPoint; return _lexer.Get(); } @@ -942,7 +1115,7 @@ private void ParseComments(ref Token token) current.AppendChild(comment); } - token = _lexer.Get(); + token = NextToken(); } } diff --git a/src/ExCSS/Rules/IStyleRule.cs b/src/ExCSS/Rules/IStyleRule.cs index 95d4c30a..1d2d8908 100644 --- a/src/ExCSS/Rules/IStyleRule.cs +++ b/src/ExCSS/Rules/IStyleRule.cs @@ -1,9 +1,17 @@ -namespace ExCSS +using System.Collections.Generic; + +namespace ExCSS { public interface IStyleRule : IRule { string SelectorText { get; set; } StyleDeclaration Style { get; } ISelector Selector { get; set; } + + /// + /// CSS Nesting: style rules nested inside this rule's block, each already resolved to an + /// absolute selector against this (parent) rule. Empty for a non-nesting rule. + /// + IReadOnlyList NestedRules { get; } } -} \ No newline at end of file +} diff --git a/src/ExCSS/Rules/MarginStyleRule.cs b/src/ExCSS/Rules/MarginStyleRule.cs index 2b06e0aa..f00f9b92 100644 --- a/src/ExCSS/Rules/MarginStyleRule.cs +++ b/src/ExCSS/Rules/MarginStyleRule.cs @@ -1,10 +1,15 @@ -using System.IO; +using System; +using System.Collections.Generic; +using System.IO; using System.Linq; namespace ExCSS { public sealed class MarginStyleRule : Rule, IStyleRule { + // @page margin boxes don't participate in CSS Nesting. + public IReadOnlyList NestedRules => Array.Empty(); + public MarginStyleRule(StylesheetParser parser) : base(RuleType.Style, parser) { AppendChild(new StyleDeclaration(this)); diff --git a/src/ExCSS/Rules/StyleRule.cs b/src/ExCSS/Rules/StyleRule.cs index ffa027de..c99df59c 100644 --- a/src/ExCSS/Rules/StyleRule.cs +++ b/src/ExCSS/Rules/StyleRule.cs @@ -1,16 +1,26 @@ -using System.IO; +using System.Collections.Generic; +using System.IO; using System.Linq; namespace ExCSS { public sealed class StyleRule : Rule, IStyleRule { + // CSS Nesting: rules written inside this rule's block, resolved to absolute selectors against + // this parent. Kept in a dedicated list (not Children) so Selector/Style lookups and ToCss are + // unaffected. Populated by StylesheetComposer.FillDeclarations. + private readonly List _nestedRules = new List(); + public StyleRule(StylesheetParser parser) : base(RuleType.Style, parser) { AppendChild(AllSelector.Create()); AppendChild(new StyleDeclaration(this)); } + public IReadOnlyList NestedRules => _nestedRules; + + public void AddNestedRule(IStyleRule rule) => _nestedRules.Add(rule); + public override void ToCss(TextWriter writer, IStyleFormatter formatter) { writer.Write(formatter.Style(SelectorText, Style));