From 7d88742109b0c729f97b3a11277a6bb79016d8d3 Mon Sep 17 00:00:00 2001 From: Justin Haygood Date: Wed, 22 Jul 2026 17:12:34 -0400 Subject: [PATCH] Fix function tokens terminating at a nested closing parenthesis Lexer.NewFunction consumed argument tokens until the first RoundBracketClose, so a bare parenthesized group inside a function's arguments ended the function early. "calc((1px + 2px) * 3)" tokenized as a calc function containing only "(1px + 2px)", leaving "* 3)" behind as stray top-level tokens and corrupting the rest of the value. Track the parenthesis depth instead, so only the ')' matching the function's own '(' terminates it. A nested function call's parens are already consumed by its own recursive call before being added here as a single token, so they never surface as bare bracket tokens at this level and cannot skew the count. --- src/ExCSS.Tests/Tokenization.cs | 53 +++++++++++++++++++++++++++++++++ src/ExCSS/Parser/Lexer.cs | 10 ++++--- 2 files changed, 59 insertions(+), 4 deletions(-) diff --git a/src/ExCSS.Tests/Tokenization.cs b/src/ExCSS.Tests/Tokenization.cs index dad4680..ea3dee0 100644 --- a/src/ExCSS.Tests/Tokenization.cs +++ b/src/ExCSS.Tests/Tokenization.cs @@ -96,6 +96,59 @@ public void CssParserUnicodeRangeSelectedRangeIsExpandedOnDemand() Assert.Equal(new[] {"A", "B", "C"}, range.SelectedRange); } + [Fact] + public void CssParserFunctionWithNestedParentheses() + { + // A bare parenthesized group inside a function's arguments must not terminate the function: + // only the ')' matching the function's own '(' does. Previously "calc((1px + 2px) * 3)" ended + // at the inner ')', leaving "* 3)" behind as stray top-level tokens. + var teststring = "calc((1px + 2px) * 3)"; + var tokenizer = new Lexer(new TextSource(teststring)); + var token = tokenizer.Get(); + + Assert.Equal(TokenType.Function, token.Type); + Assert.Equal("calc", token.Data); + Assert.Equal(teststring, token.ToValue()); + Assert.Equal(TokenType.EndOfFile, tokenizer.Get().Type); + } + + [Fact] + public void CssParserFunctionWithDeeplyNestedParentheses() + { + var teststring = "calc(((1px + 2px) * (3 - 1)) / 2)"; + var tokenizer = new Lexer(new TextSource(teststring)); + var token = tokenizer.Get(); + + Assert.Equal(TokenType.Function, token.Type); + Assert.Equal(teststring, token.ToValue()); + Assert.Equal(TokenType.EndOfFile, tokenizer.Get().Type); + } + + [Fact] + public void CssParserFunctionWithNestedFunctionAndParentheses() + { + // A nested function's own parens are consumed by its own recursive call, so they never surface + // as bare bracket tokens here - this asserts the depth counter doesn't double-count them. + var teststring = "calc(min(10px, (2px + 3px)) * 2)"; + var tokenizer = new Lexer(new TextSource(teststring)); + var token = tokenizer.Get(); + + Assert.Equal(TokenType.Function, token.Type); + Assert.Equal(teststring, token.ToValue()); + Assert.Equal(TokenType.EndOfFile, tokenizer.Get().Type); + } + + [Fact] + public void CssParserFunctionWithUnbalancedParenthesesStopsAtEof() + { + var teststring = "calc((1px + 2px"; + var tokenizer = new Lexer(new TextSource(teststring)); + var token = tokenizer.Get(); + + Assert.Equal(TokenType.Function, token.Type); + Assert.Equal(TokenType.EndOfFile, tokenizer.Get().Type); + } + [Fact] public void LexerOnlyCarriageReturn() { diff --git a/src/ExCSS/Parser/Lexer.cs b/src/ExCSS/Parser/Lexer.cs index e7ffdbe..f54dd45 100644 --- a/src/ExCSS/Parser/Lexer.cs +++ b/src/ExCSS/Parser/Lexer.cs @@ -1061,10 +1061,12 @@ private Token NewFunction(string value) { var function = new FunctionToken(value, _position); - // Track paren depth so a bare parenthesized group nested in the arguments - e.g. - // calc((100% - 20px) / 3) - isn't mistaken for the end of the function; only the ')' matching - // this function's own '(' terminates it. (Also submitted standalone as the nested-parentheses - // lexer fix; if that merges first, this rebases cleanly.) + // Tracks paren depth so a bare (non-function) parenthesized group nested inside the + // function's arguments - e.g. calc((1px + 2px) * 3) - isn't mistaken for the end of the + // function: only the closing paren matching this function's own opening paren (depth + // returning to 0) terminates it. A nested function call's own parens are already fully + // consumed by its own (recursive) call to this method before it's added as a single token + // here, so they never surface as bare RoundBracketOpen/Close tokens at this level. var depth = 1; var token = Get(); while (token.Type != TokenType.EndOfFile)