From c5ee8c34917162e576b4ffacba4abcc4092d6ce9 Mon Sep 17 00:00:00 2001 From: Dennis Zhuang Date: Mon, 20 Jul 2026 00:08:25 +0800 Subject: [PATCH] fix: preserve dynamic array index on dotted property paths The lexer/parser refactor for `a.b.c` property access made scanVariable greedily absorb any `[...]` following a dotted name into the variable lexeme, which is then resolved via Reflector.fastGetProperty. That path only supports integer-literal indices (Integer.valueOf), so it regressed expressions that used to work through the parser's array-access path: - `a.b[i]` -> NumberFormatException at runtime (variable not found) - `a.b[i + 1]` -> compile-time syntax error - `a.b[0][1]` -> broken multi-dimensional index scanVariable now only absorbs a mid-chain integer-literal index (a `[digits]` group immediately followed by another `.`, e.g. the `[0]` in `foo.bars[0].name`), which is the only case that cannot be expressed via array access. Dynamic, expression, trailing and multi-dimensional indices are left for the parser's getElement handling, restoring the old behavior while keeping the new property-chain feature. Add lexer tokenization tests and end-to-end tests covering dynamic and multi-dimensional indices on dotted paths. --- .../aviator/lexer/ExpressionLexer.java | 64 ++++++++++++++--- .../lexer/ExpressionLexerUnitTest.java | 72 +++++++++++++++++++ .../aviator/test/function/QuoteVarTest.java | 32 +++++++++ 3 files changed, 160 insertions(+), 8 deletions(-) diff --git a/src/main/java/com/googlecode/aviator/lexer/ExpressionLexer.java b/src/main/java/com/googlecode/aviator/lexer/ExpressionLexer.java index e7564c37..392492e9 100644 --- a/src/main/java/com/googlecode/aviator/lexer/ExpressionLexer.java +++ b/src/main/java/com/googlecode/aviator/lexer/ExpressionLexer.java @@ -434,15 +434,23 @@ private Token scanVariable() { StringBuilder sb = new StringBuilder(); boolean hasDot = false; - do { - if (this.peek == '.') { - hasDot = true; + // The first character is a valid Java identifier start, never a dot. + sb.append(this.peek); + nextChar(); + while (true) { + if (Character.isJavaIdentifierPart(this.peek) || this.peek == '.') { + if (this.peek == '.') { + hasDot = true; + } + sb.append(this.peek); + nextChar(); + } else if (hasDot && this.peek == '[' && tryAbsorbChainedIndex(sb)) { + // The "[digits]" text has been appended and the lexer now points at the + // following '.', which the next iteration consumes. + } else { + break; } - sb.append(this.peek); - nextChar(); - // Only allow [] after a dot has been seen (property access syntax) - } while (Character.isJavaIdentifierPart(this.peek) || this.peek == '.' - || (hasDot && (this.peek == '[' || this.peek == ']'))); + } String lexeme = sb.toString(); Variable variable = new Variable(lexeme, this.lineNo, startIndex); @@ -450,6 +458,46 @@ private Token scanVariable() { } + /** + * Try to absorb a mid-chain integer-literal index into the variable lexeme, e.g. the "[0]" in + * "foo.bars[0].name". + * + *

+ * Only a non-negative integer literal index immediately followed by another property segment + * ('.') is absorbed, because such a chain cannot be expressed through the parser's array-access + * handling. Dynamic indices (foo.bars[i]), expression indices (foo.bars[i + 1]), trailing indices + * (foo.bars[0]) and multi-dimensional indices (foo.bars[0][1]) are left untouched so the parser + * resolves them via {@code getElement}, which preserves dynamic and multi-dimensional indexing. + * + *

+ * On success the "[digits]" text is appended to {@code sb} and the lexer is positioned at the + * following '.'. On failure the lexer is restored to the original '[' and the method returns + * false. + * + * @param sb the variable lexeme being built, positioned at '[' + * @return true if a chained literal index was absorbed + */ + private boolean tryAbsorbChainedIndex(final StringBuilder sb) { + int mark = this.iterator.getIndex(); // points at '[' + StringBuilder digits = new StringBuilder(); + nextChar(); // skip '[' + while (Character.isDigit(this.peek)) { + digits.append(this.peek); + nextChar(); + } + if (digits.length() > 0 && this.peek == ']') { + nextChar(); // skip ']' + if (this.peek == '.') { + sb.append('[').append(digits).append(']'); + return true; + } + } + // Not a chained literal index: restore the lexer to the original '['. + this.peek = this.iterator.setIndex(mark); + return false; + } + + /** * Scan operator character. * diff --git a/src/test/java/com/googlecode/aviator/lexer/ExpressionLexerUnitTest.java b/src/test/java/com/googlecode/aviator/lexer/ExpressionLexerUnitTest.java index 242a1a64..3033ec31 100644 --- a/src/test/java/com/googlecode/aviator/lexer/ExpressionLexerUnitTest.java +++ b/src/test/java/com/googlecode/aviator/lexer/ExpressionLexerUnitTest.java @@ -453,6 +453,78 @@ public void testNormalVarWithDotOnly() { } + @Test + public void testDottedVarWithDynamicIndexNotAbsorbed() { + // A dynamic index must not be swallowed into the variable lexeme; it is left to + // the parser's array-access handling: variable "a.b", then '[', 'i', ']'. + this.lexer = new ExpressionLexer(this.instance, "a.b[i]"); + Token token = this.lexer.scan(); + assertEquals(TokenType.Variable, token.getType()); + assertEquals("a.b", token.getValue(null)); + + token = this.lexer.scan(); + assertEquals(TokenType.Char, token.getType()); + assertEquals('[', token.getValue(null)); + + token = this.lexer.scan(); + assertEquals(TokenType.Variable, token.getType()); + assertEquals("i", token.getValue(null)); + + token = this.lexer.scan(); + assertEquals(TokenType.Char, token.getType()); + assertEquals(']', token.getValue(null)); + assertNull(this.lexer.scan()); + } + + + @Test + public void testDottedVarWithTrailingLiteralIndexNotAbsorbed() { + // A trailing literal index (not followed by another '.') is left to the parser too. + this.lexer = new ExpressionLexer(this.instance, "a.b[0]"); + Token token = this.lexer.scan(); + assertEquals(TokenType.Variable, token.getType()); + assertEquals("a.b", token.getValue(null)); + + token = this.lexer.scan(); + assertEquals(TokenType.Char, token.getType()); + assertEquals('[', token.getValue(null)); + + token = this.lexer.scan(); + assertEquals(TokenType.Number, token.getType()); + assertEquals(0, token.getValue(null)); + + token = this.lexer.scan(); + assertEquals(TokenType.Char, token.getType()); + assertEquals(']', token.getValue(null)); + assertNull(this.lexer.scan()); + } + + + @Test + public void testDottedVarWithMultiDimensionalIndexNotAbsorbed() { + // Consecutive indices are left to the parser (no mid-chain '.' between them). + this.lexer = new ExpressionLexer(this.instance, "a.b[0][1]"); + Token token = this.lexer.scan(); + assertEquals(TokenType.Variable, token.getType()); + assertEquals("a.b", token.getValue(null)); + + token = this.lexer.scan(); + assertEquals(TokenType.Char, token.getType()); + assertEquals('[', token.getValue(null)); + } + + + @Test + public void testDottedVarWithMidChainLiteralIndexAbsorbed() { + // A literal index followed by another property segment is part of the lexeme. + this.lexer = new ExpressionLexer(this.instance, "a.b[0].c"); + Token token = this.lexer.scan(); + assertEquals(TokenType.Variable, token.getType()); + assertEquals("a.b[0].c", token.getValue(null)); + assertNull(this.lexer.scan()); + } + + @Test public void testExpression_Logic_Join() { this.lexer = new ExpressionLexer(this.instance, "a || c "); diff --git a/src/test/java/com/googlecode/aviator/test/function/QuoteVarTest.java b/src/test/java/com/googlecode/aviator/test/function/QuoteVarTest.java index 19d95114..ee23ae34 100644 --- a/src/test/java/com/googlecode/aviator/test/function/QuoteVarTest.java +++ b/src/test/java/com/googlecode/aviator/test/function/QuoteVarTest.java @@ -1,5 +1,6 @@ package com.googlecode.aviator.test.function; +import java.util.Arrays; import java.util.Date; import java.util.HashMap; import java.util.Map; @@ -149,4 +150,35 @@ public void testPropertyChainWhenNoFullKey() { public void testInvalidPropertyChainWithEmptySegment() { AviatorEvaluator.execute("a..b"); } + + + @Test + public void testDottedVarWithDynamicIndex() { + Map env = new HashMap(); + Map a = new HashMap(); + a.put("list", Arrays.asList(10, 20, 30)); + env.put("a", a); + env.put("i", 1); + + // Dynamic index on a dotted path must be evaluated as an expression. + assertEquals(20, AviatorEvaluator.execute("a.list[i]", env)); + assertEquals(30, AviatorEvaluator.execute("a.list[i + 1]", env)); + // A literal index expression is also evaluated, not parsed as a bare literal key. + assertEquals(30, AviatorEvaluator.execute("a.list[0 + 2]", env)); + // Trailing literal index still works. + assertEquals(10, AviatorEvaluator.execute("a.list[0]", env)); + } + + + @Test + public void testDottedVarWithMultiDimensionalIndex() { + Map env = new HashMap(); + Map a = new HashMap(); + a.put("grid", Arrays.asList(Arrays.asList(1, 2), Arrays.asList(3, 4))); + env.put("a", a); + env.put("i", 1); + + assertEquals(2, AviatorEvaluator.execute("a.grid[0][1]", env)); + assertEquals(3, AviatorEvaluator.execute("a.grid[i][0]", env)); + } }