From 6468c3ff970445c4a74356f1b396d861dc1f6ee4 Mon Sep 17 00:00:00 2001 From: Justin Haygood Date: Wed, 22 Jul 2026 17:18:47 -0400 Subject: [PATCH] Accept whitespace around the offset sign in :nth-child(An+B) includes the production ['+' | '-'] and "whitespace is permitted on either side of the + or - that separates the An and B parts when both are present", with "3n + 1" given as a valid example (CSS Syntax 3 6.1, 6.2). The two spellings lex differently: "10n+1" yields a single signed , while "10n + 1" yields a standalone '+' delim followed by a signless integer. ChildFunctionState.OnOffset handled only whitespace and numbers, so the standalone sign fell through to OnBeforeOf, which is looking for "of" or ')', and marked the selector invalid. The rule was then dropped. Handle the delim in OnOffset and add an AfterOffsetSign state for the digits that follow. That state requires a - "a with its type flag set to integer, and no sign character" - so "3n + -6", listed as an invalid example in 6.1, stays invalid, as do "10n + +1" and a repeated sign. --- src/ExCSS.Tests/SelectorsTests.cs | 57 +++++++++++++++++++++++++ src/ExCSS/Parser/SelectorConstructor.cs | 30 +++++++++++++ 2 files changed, 87 insertions(+) diff --git a/src/ExCSS.Tests/SelectorsTests.cs b/src/ExCSS.Tests/SelectorsTests.cs index 9c171f09..71013a56 100644 --- a/src/ExCSS.Tests/SelectorsTests.cs +++ b/src/ExCSS.Tests/SelectorsTests.cs @@ -89,6 +89,63 @@ public async Task FindAllStandardPseudoElementSelectors(bool allowInvalidSelecto Assert.Equal(expectedCount, list.Count()); } + [Theory] + // "Whitespace is permitted on either side of the + or - that separates the An and B parts when both + // are present" (CSS Syntax 3 6.1), which lists "3n + 1" among its valid examples. The sign then + // arrives as a standalone delim token rather than as part of a signed . + [InlineData(":nth-child(10n + 1)", 10, 1)] + [InlineData(":nth-child(10n - 1)", 10, -1)] + [InlineData(":nth-child(2n + 0)", 2, 0)] + [InlineData(":nth-child(-2n + 3)", -2, 3)] + [InlineData(":nth-child( 10n + 1 )", 10, 1)] + // The compact form has always worked and must keep working. + [InlineData(":nth-child(10n+1)", 10, 1)] + [InlineData(":nth-child(10n-1)", 10, -1)] + [InlineData(":nth-child(odd)", 2, 1)] + [InlineData(":nth-child(even)", 2, 0)] + public void NthChildAcceptsAnBWithAndWithoutSpaces(string selector, int step, int offset) + { + var sheet = new StylesheetParser().Parse(selector + " { color: red }"); + + Assert.Equal(1, sheet.Rules.Length); + var child = Assert.IsAssignableFrom(((StyleRule)sheet.Rules[0]).Selector); + Assert.Equal(step, child.Step); + Assert.Equal(offset, child.Offset); + } + + [Theory] + [InlineData(":nth-last-child(3n + 2)", 3, 2)] + [InlineData(":nth-of-type(3n + 2)", 3, 2)] + [InlineData(":nth-last-of-type(3n + 2)", 3, 2)] + public void NthVariantsAcceptSpacedSign(string selector, int step, int offset) + { + var sheet = new StylesheetParser().Parse(selector + " { color: red }"); + + Assert.Equal(1, sheet.Rules.Length); + var child = Assert.IsAssignableFrom(((StyleRule)sheet.Rules[0]).Selector); + Assert.Equal(step, child.Step); + Assert.Equal(offset, child.Offset); + } + + [Theory] + // The production requires a after a standalone sign, so a second sign is invalid. + // CSS Syntax 3 6.1 lists "3n + -6" among its invalid examples. + [InlineData(":nth-child(3n + -6)")] + [InlineData(":nth-child(10n + -1)")] + [InlineData(":nth-child(10n + +1)")] + [InlineData(":nth-child(10n + + 1)")] + [InlineData(":nth-child(10n + n)")] + // 6.1's invalid whitespace examples: the An part and the leading sign may not be split. + [InlineData(":nth-child(3 n)")] + [InlineData(":nth-child(+ 2n)")] + [InlineData(":nth-child(+ 2)")] + public void NthChildRejectsMalformedSpacedOffset(string selector) + { + var sheet = new StylesheetParser().Parse(selector + " { color: red }"); + + Assert.Equal(0, sheet.Rules.Length); + } + private static bool HasStandardPseudoElementSelector(ISelector selector, bool negate = false) { if (selector is PseudoElementSelector pes) diff --git a/src/ExCSS/Parser/SelectorConstructor.cs b/src/ExCSS/Parser/SelectorConstructor.cs index c29678bf..bfcc3621 100644 --- a/src/ExCSS/Parser/SelectorConstructor.cs +++ b/src/ExCSS/Parser/SelectorConstructor.cs @@ -803,6 +803,7 @@ protected override bool OnToken(Token token) ParseState.Initial => OnInitial(token), ParseState.AfterInitialSign => OnAfterInitialSign(token), ParseState.Offset => OnOffset(token), + ParseState.AfterOffsetSign => OnAfterOffsetSign(token), ParseState.BeforeOf => OnBeforeOf(token), _ => OnAfter(token) }; @@ -879,11 +880,39 @@ private bool OnOffset(Token token) _offset *= _sign; _state = ParseState.BeforeOf; return false; + case TokenType.Delim when token.Data.IsOneOf("+", "-"): + // When whitespace separates the offset's sign from its digits the sign arrives as a + // standalone delim token - the " ['+' | '-'] " + // production of (CSS Syntax 3 6.2). The compact form "10n+1" instead + // lexes as one signed and is handled by the Number case above. + _sign = token.Data == "-" ? -1 : +1; + _state = ParseState.AfterOffsetSign; + return false; default: return OnBeforeOf(token); } } + private bool OnAfterOffsetSign(Token token) + { + switch (token.Type) + { + case TokenType.Whitespace: + return false; + case TokenType.Number when !token.Data.StartsWith("+") && !token.Data.StartsWith("-"): + // The production requires a here, defined as "a + // with its type flag set to integer, and no sign character" (CSS Syntax 3 6.2), so + // "10n + -1" and "10n + +1" are invalid - 6.1 lists "3n + -6" as an invalid example. + _valid = _valid && ((NumberToken) token).IsInteger && int.TryParse(token.Data, out _offset); + _offset *= _sign; + _state = ParseState.BeforeOf; + return false; + default: + _valid = false; + return token.Type == TokenType.RoundBracketClose; + } + } + private bool OnInitial(Token token) { if (token.Type == TokenType.Whitespace) return false; @@ -918,6 +947,7 @@ private enum ParseState : byte Initial, AfterInitialSign, Offset, + AfterOffsetSign, BeforeOf, AfterOf }