diff --git a/src/ExCSS.Tests/CssNestingTests.cs b/src/ExCSS.Tests/CssNestingTests.cs
new file mode 100644
index 0000000..f5f9a44
--- /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 b734aa6..e259528 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 5ab8cce..93b50e5 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 95d4c30..1d2d890 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 2b06e0a..f00f9b9 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 ffa027d..c99df59 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));