Skip to content
Open
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
130 changes: 130 additions & 0 deletions src/ExCSS.Tests/CssNestingTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
using System.Linq;
using Xunit;

namespace ExCSS.Tests
{
/// <summary>
/// CSS Nesting (<see href="https://www.w3.org/TR/css-nesting-1/">CSS Nesting 1</see>): the <c>&amp;</c>
/// nesting selector and nested style rules inside a declaration block. Nested rules are resolved at
/// parse time into ordinary rules with absolute selectors (<c>&amp;</c> → <c>:is(parent)</c>) and
/// exposed via <see cref="IStyleRule.NestedRules"/>. Includes the regression guards that the
/// declaration path is unaffected (hex colors, custom properties, a declaration after a nested rule).
/// </summary>
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);
}
}
}
16 changes: 16 additions & 0 deletions src/ExCSS/Parser/LexerBase.cs
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,22 @@ public TextPosition GetCurrentPosition()
return new(Line, Column, Position);
}

/// <summary>
/// Repositions the lexer so the next token is re-lexed from character index
/// <paramref name="sourceIndex"/> (a raw <see cref="Source"/> index, as returned by
/// <see cref="InsertionPoint"/>). Unlike setting <see cref="InsertionPoint"/> (whose
/// <c>BackNative</c> loop is not a faithful inverse across <c>\r\n</c> normalization), this
/// restores the character stream exactly — line/column tracking is not rewound (it only affects
/// reported positions, never token content). Used by <see cref="StylesheetComposer"/> to rewind a
/// CSS-Nesting classification look-ahead so a declaration re-lexes cleanly in value mode.
/// </summary>
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();
Expand Down
177 changes: 175 additions & 2 deletions src/ExCSS/Parser/StylesheetComposer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,12 @@ internal sealed class StylesheetComposer
private readonly StylesheetParser _parser;
private readonly Stack<StylesheetNode> _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;
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -719,6 +729,168 @@ public TextPosition FillDeclarations(StyleDeclaration style)
return token.Position;
}

/// <summary>
/// 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 <c>{ }</c>
/// block) rather than a declaration, parses it, resolves its selector against the enclosing rule,
/// attaches it, and returns true (with <paramref name="token"/> advanced past the block). Returns
/// false for an ordinary declaration, having rewound the lexer so the caller re-reads it.
/// </summary>
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;
}

/// <summary>
/// Looks ahead (bracket-aware) to classify the construct starting at <paramref name="token"/> as a
/// nested style rule vs a declaration: the first top-level <c>{</c> means a nested rule (the stream
/// is left just after it and its source index returned via <paramref name="braceStart"/>); a
/// top-level <c>;</c>/<c>}</c>/EOF means a declaration, in which case the lexer is rewound to the
/// construct start and <paramref name="token"/> re-read so the unchanged declaration path re-lexes
/// it in value mode (avoiding the <c>#</c> value-mode tokenization hazard a token buffer would hit).
/// Custom properties (<c>--x</c>) are always declarations, even with a <c>{</c> in their value.
/// Function tokens (e.g. <c>url(…)</c>) are opaque — the lexer already consumed their parentheses —
/// so only bare round/square brackets contribute to depth.
/// </summary>
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;
}

/// <summary>
/// Builds a <see cref="StyleRule"/> from a nested prelude (source span
/// <c>[preludeStart, braceStart)</c>) resolved against the enclosing rule's selector, consumes
/// its declaration block from the live stream, and attaches it to the parent rule via
/// <see cref="StyleRule.AddNestedRule"/>. The resolved selector is absolute (<c>&amp;</c> →
/// <c>:is(parent)</c>) so the cascade/matcher need no nesting awareness.
/// </summary>
private void CreateNestedStyleRule(int preludeStart, int braceStart)
{
var parent = _nodes.OfType<StyleRule>().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);
}

/// <summary>
/// Resolves a nested selector's prelude text against its parent's selector text per CSS Nesting:
/// each <c>&amp;</c> becomes <c>:is(parent)</c> (giving <c>&amp;</c> the parent's specificity); a
/// prelude with no <c>&amp;</c> is made relative to the parent (<c>:is(parent) &lt;prelude&gt;</c>),
/// which correctly yields both the implicit-descendant (<c>.b</c> → <c>:is(parent) .b</c>) and the
/// leading-combinator (<c>&gt; .b</c> → <c>:is(parent) &gt; .b</c>) forms.
/// </summary>
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) <prelude>) 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<string, Property> createProperty, ref Token token)
{
var property = default(Property);
Expand Down Expand Up @@ -916,6 +1088,7 @@ private void JumpToDeclEnd(ref Token current)

private Token NextToken()
{
_markBeforeLastToken = _lexer.InsertionPoint;
return _lexer.Get();
}

Expand All @@ -942,7 +1115,7 @@ private void ParseComments(ref Token token)
current.AppendChild(comment);
}

token = _lexer.Get();
token = NextToken();
}
}

Expand Down
12 changes: 10 additions & 2 deletions src/ExCSS/Rules/IStyleRule.cs
Original file line number Diff line number Diff line change
@@ -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; }

/// <summary>
/// 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.
/// </summary>
IReadOnlyList<IStyleRule> NestedRules { get; }
}
}
}
Loading