This is an automated email from the ASF dual-hosted git repository. asf-gitbox-commits pushed a commit to branch GROOVY-12169 in repository https://gitbox.apache.org/repos/asf/groovy.git
commit 04791b21276f6d654771e9978370cfbd0aeb901b Author: Daniel Sun <[email protected]> AuthorDate: Sat Jul 18 05:21:34 2026 +0900 GROOVY-12169: Improve syntax error messages and caret positions for missing ')', ']', and '}' --- .../antlr4/internal/DescriptiveErrorStrategy.java | 28 +- .../internal/MissingDelimiterDiagnostic.java | 523 +++++++++++++++ .../groovy/parser/antlr4/SyntaxErrorTest.groovy | 232 ++++++- .../internal/MissingDelimiterDiagnosticTest.groovy | 700 +++++++++++++++++++++ .../antlr4/internal/ThrowingTokenStream.java | 103 +++ 5 files changed, 1580 insertions(+), 6 deletions(-) diff --git a/src/main/java/org/apache/groovy/parser/antlr4/internal/DescriptiveErrorStrategy.java b/src/main/java/org/apache/groovy/parser/antlr4/internal/DescriptiveErrorStrategy.java index 1fbb41350d..aa581e1d36 100644 --- a/src/main/java/org/apache/groovy/parser/antlr4/internal/DescriptiveErrorStrategy.java +++ b/src/main/java/org/apache/groovy/parser/antlr4/internal/DescriptiveErrorStrategy.java @@ -34,9 +34,16 @@ import org.antlr.v4.runtime.misc.ParseCancellationException; /** * Provide friendly error messages when parsing errors occurred. + * <p> + * Extends {@link BailErrorStrategy} so the successful-parse path stays + * allocation-free and ATN-light (no grammar-level error alternatives — + * those were removed after GROOVY-9588). Missing-delimiter diagnostics + * ({@code ')'}, {@code ']'}, {@code '}'}) run only after a recognition + * failure via {@link MissingDelimiterDiagnostic}. + * </p> */ public class DescriptiveErrorStrategy extends BailErrorStrategy { - private CharStream charStream; + private final CharStream charStream; public DescriptiveErrorStrategy(CharStream charStream) { this.charStream = charStream; @@ -69,6 +76,21 @@ public class DescriptiveErrorStrategy extends BailErrorStrategy { return null; } + /** + * Prefer a precise "Missing …" delimiter diagnostic when the token stream + * clearly indicates an unclosed / incomplete construct; otherwise fall + * back to the generic "Unexpected input" message. + */ + private void reportError(Parser recognizer, RecognitionException e, String fallbackMessage) { + MissingDelimiterDiagnostic.Hit hit = + MissingDelimiterDiagnostic.locate(recognizer.getInputStream(), e); + if (hit != null) { + recognizer.notifyErrorListeners(hit.at, hit.message, e); + return; + } + notifyErrorListeners(recognizer, fallbackMessage, e); + } + protected String createNoViableAlternativeErrorMessage(Parser recognizer, NoViableAltException e) { TokenStream tokens = recognizer.getInputStream(); String input; @@ -89,7 +111,7 @@ public class DescriptiveErrorStrategy extends BailErrorStrategy { protected void reportNoViableAlternative(Parser recognizer, NoViableAltException e) { - notifyErrorListeners(recognizer, this.createNoViableAlternativeErrorMessage(recognizer, e), e); + reportError(recognizer, e, this.createNoViableAlternativeErrorMessage(recognizer, e)); } protected String createInputMismatchErrorMessage(Parser recognizer, @@ -101,7 +123,7 @@ public class DescriptiveErrorStrategy extends BailErrorStrategy { protected void reportInputMismatch(Parser recognizer, InputMismatchException e) { - notifyErrorListeners(recognizer, this.createInputMismatchErrorMessage(recognizer, e), e); + reportError(recognizer, e, this.createInputMismatchErrorMessage(recognizer, e)); } protected String createFailedPredicateErrorMessage(Parser recognizer, diff --git a/src/main/java/org/apache/groovy/parser/antlr4/internal/MissingDelimiterDiagnostic.java b/src/main/java/org/apache/groovy/parser/antlr4/internal/MissingDelimiterDiagnostic.java new file mode 100644 index 0000000000..d960f25e20 --- /dev/null +++ b/src/main/java/org/apache/groovy/parser/antlr4/internal/MissingDelimiterDiagnostic.java @@ -0,0 +1,523 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.groovy.parser.antlr4.internal; + +import org.antlr.v4.runtime.BufferedTokenStream; +import org.antlr.v4.runtime.CommonToken; +import org.antlr.v4.runtime.RecognitionException; +import org.antlr.v4.runtime.Token; +import org.antlr.v4.runtime.TokenStream; +import org.antlr.v4.runtime.misc.IntervalSet; + +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Deque; +import java.util.List; + +import static org.apache.groovy.parser.antlr4.GroovyParser.AT; +import static org.apache.groovy.parser.antlr4.GroovyParser.BITAND; +import static org.apache.groovy.parser.antlr4.GroovyParser.BooleanLiteral; +import static org.apache.groovy.parser.antlr4.GroovyParser.BuiltInPrimitiveType; +import static org.apache.groovy.parser.antlr4.GroovyParser.CapitalizedIdentifier; +import static org.apache.groovy.parser.antlr4.GroovyParser.DOT; +import static org.apache.groovy.parser.antlr4.GroovyParser.FloatingPointLiteral; +import static org.apache.groovy.parser.antlr4.GroovyParser.GT; +import static org.apache.groovy.parser.antlr4.GroovyParser.GStringBegin; +import static org.apache.groovy.parser.antlr4.GroovyParser.Identifier; +import static org.apache.groovy.parser.antlr4.GroovyParser.IntegerLiteral; +import static org.apache.groovy.parser.antlr4.GroovyParser.LBRACE; +import static org.apache.groovy.parser.antlr4.GroovyParser.LBRACK; +import static org.apache.groovy.parser.antlr4.GroovyParser.LPAREN; +import static org.apache.groovy.parser.antlr4.GroovyParser.LT; +import static org.apache.groovy.parser.antlr4.GroovyParser.NL; +import static org.apache.groovy.parser.antlr4.GroovyParser.RBRACE; +import static org.apache.groovy.parser.antlr4.GroovyParser.RBRACK; +import static org.apache.groovy.parser.antlr4.GroovyParser.RPAREN; +import static org.apache.groovy.parser.antlr4.GroovyParser.SAFE_INDEX; +import static org.apache.groovy.parser.antlr4.GroovyParser.StringLiteral; +import static org.apache.groovy.parser.antlr4.GroovyParser.VOID; + +/** + * Error-path-only diagnostic for a missing closing delimiter + * ({@code ')'}, {@code ']'}, or {@code '}'}). + * <p> + * Successful parses never call into this class, so there is no steady-state + * cost. When a parse has already failed, a single linear scan of the token + * stream recovers a precise "Missing …" location — something ANTLR4's default + * mismatch reporting cannot do without grammar-level error alternatives + * (removed after GROOVY-9588 because they hurt parsing performance). + * </p> + * <p> + * Detection is ordered from most specific to least specific: + * <ol> + * <li>RecognitionException whose sole expected token is a single closer + * ({@code RPAREN} / {@code RBRACK} / {@code RBRACE})</li> + * <li>Cast / parenthesised-type pattern: + * {@code '(' type <expr-start>} without the closing {@code ')'}</li> + * <li>Unclosed or mismatched {@code (} / {@code [} / {@code ?[} / {@code \{} + * on a delimiter stack (innermost first)</li> + * </ol> + */ +final class MissingDelimiterDiagnostic { + + /** + * A located missing-delimiter finding. + */ + static final class Hit { + final String message; + final Token at; + + Hit(final String message, final Token at) { + this.message = message; + this.at = at; + } + } + + private static final String MSG_RPAREN = "Missing ')'"; + private static final String MSG_RBRACK = "Missing ']'"; + private static final String MSG_RBRACE = "Missing '}'"; + + private MissingDelimiterDiagnostic() { + } + + /** + * @return a hit whose message and caret location describe a missing + * closer, or {@code null} if this failure does not look like one + */ + static Hit locate(final TokenStream tokens, final RecognitionException e) { + if (tokens == null) { + return null; + } + + List<Token> defaultChannel = collectDefaultChannelTokens(tokens); + if (defaultChannel.isEmpty()) { + return null; + } + + // (1) Parser already knows it wanted only one specific closer + Hit fromExpected = fromSoleExpectedCloser(e, defaultChannel); + if (fromExpected != null) { + return fromExpected; + } + + // (2) Cast form: (type <expr> — ')' of the cast is missing + Hit fromCast = fromCastPattern(defaultChannel); + if (fromCast != null) { + return fromCast; + } + + // (3) Unclosed / mismatched delimiters on a stack + return fromDelimiterStack(defaultChannel); + } + + //-------------------------------------------------------------------------- + // Strategy 1 — sole expected token is a closer + //-------------------------------------------------------------------------- + + private static Hit fromSoleExpectedCloser(final RecognitionException e, + final List<Token> defaultChannel) { + if (e == null) { + return null; + } + IntervalSet expected = e.getExpectedTokens(); + if (expected == null || expected.size() != 1) { + return null; + } + + final String message; + final int openDepth; + if (expected.contains(RPAREN)) { + message = MSG_RPAREN; + openDepth = delimiterDepth(defaultChannel, true, false, false); + } else if (expected.contains(RBRACK)) { + message = MSG_RBRACK; + openDepth = delimiterDepth(defaultChannel, false, true, false); + } else if (expected.contains(RBRACE)) { + message = MSG_RBRACE; + openDepth = delimiterDepth(defaultChannel, false, false, true); + } else { + return null; + } + + // If the corresponding openers/closers are already balanced, the + // failure is an unexpected intermediate token (e.g. `foo(1;2;3)`), + // not a missing closer — leave the generic error alone. + if (openDepth == 0) { + return null; + } + + Token offending = e.getOffendingToken(); + if (offending == null) { + return null; + } + // EOF's line/column often sit on the following empty line after a + // trailing newline; point just past the previous real token instead. + if (offending.getType() == Token.EOF) { + Token lastReal = lastNonEof(defaultChannel); + if (lastReal != null) { + return new Hit(message, insertionPointAfter(lastReal)); + } + } + return new Hit(message, offending); + } + + /** + * Net open depth for the requested delimiter family at end of stream. + * Families are counted independently so a missing {@code ')'} is not + * masked by balanced braces, and vice versa. + */ + private static int delimiterDepth(final List<Token> tokens, + final boolean parens, + final boolean brackets, + final boolean braces) { + int depth = 0; + for (Token t : tokens) { + int type = t.getType(); + if (parens && type == LPAREN) { + depth++; + } else if (parens && type == RPAREN && depth > 0) { + depth--; + } else if (brackets && isOpenBracket(type)) { + depth++; + } else if (brackets && type == RBRACK && depth > 0) { + depth--; + } else if (braces && type == LBRACE) { + depth++; + } else if (braces && type == RBRACE && depth > 0) { + depth--; + } + } + return depth; + } + + private static Token lastNonEof(final List<Token> tokens) { + for (int i = tokens.size() - 1; i >= 0; i--) { + Token t = tokens.get(i); + if (t.getType() != Token.EOF) { + return t; + } + } + return null; + } + + //-------------------------------------------------------------------------- + // Strategy 2 — cast / parenthesised-type pattern (')' only) + //-------------------------------------------------------------------------- + + /** + * Scan for {@code '(' type <token>} where the {@code ')'} of a cast is + * missing. The earliest high-confidence site is reported. + * <p> + * Confidence rules avoid flagging parenthesised command expressions + * such as {@code (foo bar)}: + * <ul> + * <li>type is a built-in primitive / {@code void} (e.g. {@code (int 123)}), or</li> + * <li>the token after the type is a literal or {@code '('} + * (e.g. {@code (Foo)1}, {@code (Foo)(x)})</li> + * </ul> + */ + private static Hit fromCastPattern(final List<Token> tokens) { + final int n = tokens.size(); + for (int i = 0; i < n; i++) { + if (tokens.get(i).getType() != LPAREN) { + continue; + } + int typeStart = skipAnnotations(tokens, i + 1); + if (typeStart < 0 || typeStart >= n) { + continue; + } + boolean primitiveType = isPrimitiveOrVoid(tokens.get(typeStart).getType()); + int afterType = matchType(tokens, i + 1); + if (afterType <= i + 1) { + continue; // no type after '(' + } + if (afterType >= n) { + return new Hit(MSG_RPAREN, insertionPointAfter(tokens.get(afterType - 1))); + } + Token next = tokens.get(afterType); + int nt = next.getType(); + if (nt == RPAREN || nt == BITAND) { + continue; // well-formed cast or intersection type + } + // Type continuators that matchType should already have consumed; + // if they remain, skip rather than false-positive. + if (nt == DOT || nt == LT || nt == LBRACK) { + continue; + } + if (primitiveType || isHighConfidenceCastOperand(nt)) { + return new Hit(MSG_RPAREN, next); + } + } + return null; + } + + private static boolean isHighConfidenceCastOperand(final int tokenType) { + return isLiteral(tokenType) || tokenType == LPAREN; + } + + //-------------------------------------------------------------------------- + // Strategy 3 — delimiter stack (unclosed / mismatched) + //-------------------------------------------------------------------------- + + /** + * Walk default-channel tokens with a stack of open delimiters. Reports + * the innermost missing closer on mismatch or at EOF. + */ + private static Hit fromDelimiterStack(final List<Token> tokens) { + Deque<Token> openStack = new ArrayDeque<>(); + Token lastInside = null; + + for (Token t : tokens) { + int type = t.getType(); + if (type == Token.EOF) { + break; + } + + if (isOpenDelimiter(type)) { + openStack.push(t); + lastInside = t; + continue; + } + + if (isCloseDelimiter(type)) { + if (openStack.isEmpty()) { + // Extra closer — not a *missing* closer. + continue; + } + Token open = openStack.peek(); + if (closes(open.getType(), type)) { + openStack.pop(); + lastInside = t; + continue; + } + // Mismatched closer: the opener on top still needs its own + // closer (e.g. `foo([1, 2)` → missing ']' before ')'). + return hitForOpen(open, lastInside, t); + } + + // Skip NL so the caret sits after the last meaningful token on a + // line (e.g. missing '}' after `println 1`), not past the newline. + if (!openStack.isEmpty() && type != NL) { + lastInside = t; + } + } + + if (!openStack.isEmpty()) { + Token open = openStack.peek(); + return hitForOpen(open, lastInside, null); + } + return null; + } + + private static Hit hitForOpen(final Token open, final Token lastInside, final Token mismatchCloser) { + String message = messageForOpen(open.getType()); + // Prefer the insertion point after the last token inside the open + // delimiter; fall back to the mismatched closer or the opener itself. + if (lastInside != null && lastInside != open) { + return new Hit(message, insertionPointAfter(lastInside)); + } + if (lastInside != null) { + // Opener with no interior tokens yet — point just past it, or at + // a mismatched closer if present (e.g. `m( }` → caret at '}'). + if (mismatchCloser != null) { + return new Hit(message, mismatchCloser); + } + return new Hit(message, insertionPointAfter(lastInside)); + } + if (mismatchCloser != null) { + return new Hit(message, mismatchCloser); + } + return new Hit(message, open); + } + + private static boolean isOpenDelimiter(final int type) { + return type == LPAREN || isOpenBracket(type) || type == LBRACE; + } + + private static boolean isCloseDelimiter(final int type) { + return type == RPAREN || type == RBRACK || type == RBRACE; + } + + private static boolean isOpenBracket(final int type) { + return type == LBRACK || type == SAFE_INDEX; + } + + private static boolean closes(final int openType, final int closeType) { + if (openType == LPAREN) { + return closeType == RPAREN; + } + if (isOpenBracket(openType)) { + return closeType == RBRACK; + } + if (openType == LBRACE) { + return closeType == RBRACE; + } + return false; + } + + private static String messageForOpen(final int openType) { + if (openType == LPAREN) { + return MSG_RPAREN; + } + if (isOpenBracket(openType)) { + return MSG_RBRACK; + } + return MSG_RBRACE; + } + + //-------------------------------------------------------------------------- + // Type matching (conservative, token-level) — cast strategy only + //-------------------------------------------------------------------------- + + private static int skipAnnotations(final List<Token> tokens, final int start) { + int i = start; + final int n = tokens.size(); + while (i < n && tokens.get(i).getType() == AT) { + i++; + if (i >= n || !isIdentifierLike(tokens.get(i).getType())) { + return -1; + } + i++; + if (i < n && tokens.get(i).getType() == LPAREN) { + i = skipBalanced(tokens, i, LPAREN, RPAREN); + if (i < 0) { + return -1; + } + } + } + return i; + } + + private static int matchType(final List<Token> tokens, final int start) { + int i = skipAnnotations(tokens, start); + if (i < 0) { + return start; + } + final int n = tokens.size(); + if (i >= n) { + return start; + } + + int head = tokens.get(i).getType(); + if (isPrimitiveOrVoid(head)) { + i++; + } else if (isIdentifierLike(head)) { + i++; + while (i + 1 < n + && tokens.get(i).getType() == DOT + && isIdentifierLike(tokens.get(i + 1).getType())) { + i += 2; + } + if (i < n && tokens.get(i).getType() == LT) { + i = skipBalanced(tokens, i, LT, GT); + if (i < 0) { + return start; + } + } + } else { + return start; + } + + while (i + 1 < n + && tokens.get(i).getType() == LBRACK + && tokens.get(i + 1).getType() == RBRACK) { + i += 2; + } + + return i; + } + + private static int skipBalanced(final List<Token> tokens, final int openIdx, + final int openType, final int closeType) { + int depth = 0; + for (int i = openIdx; i < tokens.size(); i++) { + int t = tokens.get(i).getType(); + if (t == openType) { + depth++; + } else if (t == closeType) { + depth--; + if (depth == 0) { + return i + 1; + } + } else if (t == Token.EOF) { + return -1; + } + } + return -1; + } + + //-------------------------------------------------------------------------- + // Token helpers + //-------------------------------------------------------------------------- + + private static List<Token> collectDefaultChannelTokens(final TokenStream tokens) { + // Must fill through EOF so trailing closers are visible — otherwise an + // early failure inside a delimiter looks like an unclosed one + // (e.g. `foo(1;2;3)` fails at ';' before ')' has been pulled). + if (tokens instanceof BufferedTokenStream) { + ((BufferedTokenStream) tokens).fill(); + } + + List<Token> result = new ArrayList<>(); + try { + for (int i = 0; ; i++) { + Token t = tokens.get(i); + if (t.getType() == Token.EOF) { + result.add(t); + break; + } + if (t.getChannel() == Token.DEFAULT_CHANNEL) { + result.add(t); + } + } + } catch (IndexOutOfBoundsException | IllegalArgumentException ignored) { + // Non-buffered stream: keep whatever was collected + } + return result; + } + + private static boolean isPrimitiveOrVoid(final int tokenType) { + return tokenType == BuiltInPrimitiveType || tokenType == VOID; + } + + private static boolean isIdentifierLike(final int tokenType) { + return tokenType == Identifier || tokenType == CapitalizedIdentifier; + } + + private static boolean isLiteral(final int tokenType) { + return tokenType == IntegerLiteral + || tokenType == FloatingPointLiteral + || tokenType == StringLiteral + || tokenType == GStringBegin + || tokenType == BooleanLiteral; + } + + /** + * Caret sits just past {@code token} (the usual "insert closer here" point). + */ + private static Token insertionPointAfter(final Token token) { + String text = token.getText(); + int length = text != null ? text.codePointCount(0, text.length()) : 0; + CommonToken point = new CommonToken(Token.INVALID_TYPE); + point.setLine(token.getLine()); + point.setCharPositionInLine(token.getCharPositionInLine() + length); + point.setText(""); + return point; + } +} diff --git a/src/test/groovy/org/apache/groovy/parser/antlr4/SyntaxErrorTest.groovy b/src/test/groovy/org/apache/groovy/parser/antlr4/SyntaxErrorTest.groovy index bbe0175f72..ab1463934b 100644 --- a/src/test/groovy/org/apache/groovy/parser/antlr4/SyntaxErrorTest.groovy +++ b/src/test/groovy/org/apache/groovy/parser/antlr4/SyntaxErrorTest.groovy @@ -567,7 +567,7 @@ final class SyntaxErrorTest { TestUtils.doRunAndShouldFail('fail/SwitchExpression_10x.groovy') } - @NotYetImplemented @Test + @Test void 'error alternative - Missing ")" 1'() { expectParseError '''\ |println ((int 123) @@ -580,7 +580,7 @@ final class SyntaxErrorTest { |'''.stripMargin() } - @NotYetImplemented @Test + @Test void 'error alternative - Missing ")" 2'() { expectParseError '''\ |def x() { @@ -595,7 +595,7 @@ final class SyntaxErrorTest { |'''.stripMargin() } - @NotYetImplemented @Test + @Test void 'error alternative - Missing ")" 3'() { expectParseError '''\ |def m( { @@ -609,6 +609,232 @@ final class SyntaxErrorTest { |'''.stripMargin() } + @Test + void 'error alternative - Missing ")" 4'() { + expectParseError '''\ + |foo(1, 2 + |'''.stripMargin(), '''\ + |Missing ')' @ line 1, column 9. + | foo(1, 2 + | ^ + | + |1 error + |'''.stripMargin() + } + + @Test + void 'error alternative - Missing ")" 5'() { + expectParseError '''\ + |def f(int x + |'''.stripMargin(), '''\ + |Missing ')' @ line 1, column 12. + | def f(int x + | ^ + | + |1 error + |'''.stripMargin() + } + + @Test + void 'error alternative - Missing ")" 6'() { + expectParseError '''\ + |println ((int) 123 + |'''.stripMargin(), '''\ + |Missing ')' @ line 1, column 19. + | println ((int) 123 + | ^ + | + |1 error + |'''.stripMargin() + } + + @Test + void 'error alternative - Missing "]" 1'() { + expectParseError '''\ + |[1, 2 + |'''.stripMargin(), '''\ + |Missing ']' @ line 1, column 6. + | [1, 2 + | ^ + | + |1 error + |'''.stripMargin() + } + + @Test + void 'error alternative - Missing "]" 2'() { + expectParseError '''\ + |foo[1 + |'''.stripMargin(), '''\ + |Missing ']' @ line 1, column 6. + | foo[1 + | ^ + | + |1 error + |'''.stripMargin() + } + + @Test + void 'error alternative - Missing "]" 3'() { + expectParseError '''\ + |def x = [[1, 2] + |'''.stripMargin(), '''\ + |Missing ']' @ line 1, column 16. + | def x = [[1, 2] + | ^ + | + |1 error + |'''.stripMargin() + } + + @Test + void 'error alternative - Missing "]" 4'() { + expectParseError '''\ + |foo([1, 2) + |'''.stripMargin(), '''\ + |Missing ']' @ line 1, column 10. + | foo([1, 2) + | ^ + | + |1 error + |'''.stripMargin() + } + + @Test + void 'error alternative - Missing "]" 5'() { + expectParseError '''\ + |a?[0 + |'''.stripMargin(), '''\ + |Missing ']' @ line 1, column 5. + | a?[0 + | ^ + | + |1 error + |'''.stripMargin() + } + + @Test + void 'error alternative - Missing "]" 6'() { + expectParseError '''\ + |def x() { + | a[0 + |} + |'''.stripMargin(), '''\ + |Missing ']' @ line 2, column 8. + | a[0 + | ^ + | + |1 error + |'''.stripMargin() + } + + @Test + void 'error alternative - Missing "}" 1'() { + expectParseError '''\ + |def m() { + | println 1 + |'''.stripMargin(), '''\ + |Missing '}' @ line 2, column 14. + | println 1 + | ^ + | + |1 error + |'''.stripMargin() + } + + @Test + void 'error alternative - Missing "}" 2'() { + expectParseError '''\ + |class C { + | def x + |'''.stripMargin(), '''\ + |Missing '}' @ line 2, column 10. + | def x + | ^ + | + |1 error + |'''.stripMargin() + } + + @Test + void 'error alternative - Missing "}" 3'() { + expectParseError '''\ + |def c = { it + |'''.stripMargin(), '''\ + |Missing '}' @ line 1, column 13. + | def c = { it + | ^ + | + |1 error + |'''.stripMargin() + } + + @Test + void 'error alternative - Missing "}" 4'() { + expectParseError '''\ + |if (true) { + | x = 1 + |'''.stripMargin(), '''\ + |Missing '}' @ line 2, column 10. + | x = 1 + | ^ + | + |1 error + |'''.stripMargin() + } + + @Test + void 'error alternative - Missing ")" cast with generic type'() { + expectParseError '''\ + |def x = (List<String> 1 + |'''.stripMargin(), '''\ + |Missing ')' @ line 1, column 23. + | def x = (List<String> 1 + | ^ + | + |1 error + |'''.stripMargin() + } + + @Test + void 'error alternative - Missing ")" empty open paren'() { + expectParseError '''\ + |( + |'''.stripMargin(), '''\ + |Missing ')' @ line 1, column 2. + | ( + | ^ + | + |1 error + |'''.stripMargin() + } + + @Test + void 'error alternative - Missing "]" nested list'() { + expectParseError '''\ + |[[1] + |'''.stripMargin(), '''\ + |Missing ']' @ line 1, column 5. + | [[1] + | ^ + | + |1 error + |'''.stripMargin() + } + + @Test + void 'error alternative - Missing "}" nested block'() { + expectParseError '''\ + |{{1} + |'''.stripMargin(), '''\ + |Missing '}' @ line 1, column 5. + | {{1} + | ^ + | + |1 error + |'''.stripMargin() + } + @NotYetImplemented @Test void 'CompilerErrorTest_001'() { unzipScriptAndShouldFail('scripts/CompilerErrorTest_001.groovy', []) diff --git a/src/test/groovy/org/apache/groovy/parser/antlr4/internal/MissingDelimiterDiagnosticTest.groovy b/src/test/groovy/org/apache/groovy/parser/antlr4/internal/MissingDelimiterDiagnosticTest.groovy new file mode 100644 index 0000000000..7188e60f49 --- /dev/null +++ b/src/test/groovy/org/apache/groovy/parser/antlr4/internal/MissingDelimiterDiagnosticTest.groovy @@ -0,0 +1,700 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.groovy.parser.antlr4.internal + +import org.antlr.v4.runtime.CharStreams +import org.antlr.v4.runtime.CommonToken +import org.antlr.v4.runtime.CommonTokenStream +import org.antlr.v4.runtime.RecognitionException +import org.antlr.v4.runtime.Token +import org.antlr.v4.runtime.misc.IntervalSet +import org.apache.groovy.parser.antlr4.GroovyLangLexer +import org.apache.groovy.parser.antlr4.GroovyParser +import org.junit.jupiter.api.Test + +import java.lang.reflect.Method + +import static org.apache.groovy.parser.antlr4.GroovyParser.AT +import static org.apache.groovy.parser.antlr4.GroovyParser.BITAND +import static org.apache.groovy.parser.antlr4.GroovyParser.BuiltInPrimitiveType +import static org.apache.groovy.parser.antlr4.GroovyParser.COMMA +import static org.apache.groovy.parser.antlr4.GroovyParser.CapitalizedIdentifier +import static org.apache.groovy.parser.antlr4.GroovyParser.DOT +import static org.apache.groovy.parser.antlr4.GroovyParser.GT +import static org.apache.groovy.parser.antlr4.GroovyParser.Identifier +import static org.apache.groovy.parser.antlr4.GroovyParser.IntegerLiteral +import static org.apache.groovy.parser.antlr4.GroovyParser.LBRACE +import static org.apache.groovy.parser.antlr4.GroovyParser.LBRACK +import static org.apache.groovy.parser.antlr4.GroovyParser.LPAREN +import static org.apache.groovy.parser.antlr4.GroovyParser.LT +import static org.apache.groovy.parser.antlr4.GroovyParser.RBRACE +import static org.apache.groovy.parser.antlr4.GroovyParser.RBRACK +import static org.apache.groovy.parser.antlr4.GroovyParser.RPAREN +import static org.junit.jupiter.api.Assertions.assertEquals +import static org.junit.jupiter.api.Assertions.assertNotNull +import static org.junit.jupiter.api.Assertions.assertNull +import static org.junit.jupiter.api.Assertions.assertTrue + +/** + * Branch-level coverage for {@link MissingDelimiterDiagnostic} (GROOVY-12169). + * Complements the end-to-end cases in {@code SyntaxErrorTest} by exercising + * edge paths that are hard to reach through full compilation alone. + */ +final class MissingDelimiterDiagnosticTest { + + //-------------------------------------------------------------------------- + // locate() entry points + //-------------------------------------------------------------------------- + + @Test + void 'locate returns null for null token stream'() { + assertNull MissingDelimiterDiagnostic.locate(null, null) + } + + @Test + void 'locate returns null when token collection fails'() { + // get(i) throws → empty collection → null + assertNull MissingDelimiterDiagnostic.locate(new ThrowingTokenStream(), null) + } + + @Test + void 'locate with null exception still finds unclosed paren via stack'() { + def tokens = tokenStream('foo(1') + def hit = MissingDelimiterDiagnostic.locate(tokens, null) + assertNotNull hit + assertEquals "Missing ')'", hit.message + } + + @Test + void 'private constructor is callable for utility-class coverage'() { + def ctor = MissingDelimiterDiagnostic.getDeclaredConstructor() + ctor.accessible = true + assertNotNull ctor.newInstance() + } + + //-------------------------------------------------------------------------- + // Strategy 1 — sole expected closer + //-------------------------------------------------------------------------- + + @Test + void 'sole expected RPAREN with open depth reports Missing paren'() { + def tokens = tokenStream('m(x') + def eof = lastToken(tokens) + def hit = MissingDelimiterDiagnostic.locate(tokens, stubException(setOf(RPAREN), eof)) + assertNotNull hit + assertEquals "Missing ')'", hit.message + } + + @Test + void 'sole expected RBRACK with open depth reports Missing bracket'() { + def tokens = tokenStream('[1') + def eof = lastToken(tokens) + def hit = MissingDelimiterDiagnostic.locate(tokens, stubException(setOf(RBRACK), eof)) + assertNotNull hit + assertEquals "Missing ']'", hit.message + } + + @Test + void 'sole expected RBRACE with open depth reports Missing brace'() { + def tokens = tokenStream('{1') + def eof = lastToken(tokens) + def hit = MissingDelimiterDiagnostic.locate(tokens, stubException(setOf(RBRACE), eof)) + assertNotNull hit + assertEquals "Missing '}'", hit.message + } + + @Test + void 'sole expected closer ignored when delimiter family is balanced'() { + def tokens = tokenStream('foo(1;2;3)') + // Parser might claim it wanted only RPAREN at ';', but parens are balanced overall + def semi = findFirst(tokens) { it.text == ';' } + def hit = MissingDelimiterDiagnostic.locate(tokens, stubException(setOf(RPAREN), semi)) + // depth of parens is 0 after fill → strategy 1 returns null; stack is also balanced + assertNull hit + } + + @Test + void 'sole expected set that is not a single closer is ignored'() { + def tokens = tokenStream('foo(1') + def eof = lastToken(tokens) + def hit = MissingDelimiterDiagnostic.locate(tokens, stubException(setOf(COMMA), eof)) + // falls through to cast/stack — still reports missing ')' via stack + assertNotNull hit + assertEquals "Missing ')'", hit.message + } + + @Test + void 'sole expected multi-token set is ignored by strategy 1'() { + def tokens = tokenStream('foo(1') + def eof = lastToken(tokens) + def multi = new IntervalSet() + multi.add(RPAREN) + multi.add(COMMA) + // strategy 1 skips (size != 1); stack still finds unclosed '(' + def hit = MissingDelimiterDiagnostic.locate(tokens, stubException(multi, eof)) + assertNotNull hit + assertEquals "Missing ')'", hit.message + } + + @Test + void 'sole expected with null IntervalSet falls through'() { + def tokens = tokenStream('[1') + def hit = MissingDelimiterDiagnostic.locate(tokens, stubException(null, lastToken(tokens))) + assertNotNull hit + assertEquals "Missing ']'", hit.message + } + + @Test + void 'sole expected with null offending token falls through'() { + def tokens = tokenStream('m(a') + def hit = MissingDelimiterDiagnostic.locate(tokens, stubException(setOf(RPAREN), null)) + assertNotNull hit + assertEquals "Missing ')'", hit.message + } + + @Test + void 'sole expected EOF uses insertion point after last real token'() { + def tokens = tokenStream('def f(int x') + def eof = lastToken(tokens) + assertEquals Token.EOF, eof.type + def hit = MissingDelimiterDiagnostic.locate(tokens, stubException(setOf(RPAREN), eof)) + assertNotNull hit + assertEquals "Missing ')'", hit.message + // caret after 'x', not on the following empty line of EOF + assertEquals 1, hit.at.line + assertTrue hit.at.charPositionInLine > 0 + } + + @Test + void 'sole expected non-EOF offending token keeps that token as caret'() { + def tokens = tokenStream('def m( { }') + def lbrace = findFirst(tokens) { it.type == LBRACE } + def hit = MissingDelimiterDiagnostic.locate(tokens, stubException(setOf(RPAREN), lbrace)) + assertNotNull hit + assertEquals "Missing ')'", hit.message + assertEquals LBRACE, /* token type may not be on Hit.at if synthetic */ lbrace.type + // when non-EOF offending is used, caret is the brace token + assertEquals lbrace.line, hit.at.line + assertEquals lbrace.charPositionInLine, hit.at.charPositionInLine + } + + //-------------------------------------------------------------------------- + // Strategy 2 — cast patterns + //-------------------------------------------------------------------------- + + @Test + void 'cast pattern primitive then literal'() { + def hit = MissingDelimiterDiagnostic.locate(tokenStream('(int 123'), null) + assertNotNull hit + assertEquals "Missing ')'", hit.message + } + + @Test + void 'cast pattern void then literal'() { + // void is not a realistic cast target but exercises VOID branch in isPrimitiveOrVoid + def hit = MissingDelimiterDiagnostic.locate(tokenStream('(void 1'), null) + assertNotNull hit + assertEquals "Missing ')'", hit.message + } + + @Test + void 'cast pattern CapitalizedIdentifier then literal'() { + def hit = MissingDelimiterDiagnostic.locate(tokenStream('(Foo 1'), null) + // Foo is CapitalizedIdentifier; operand 1 is literal → high-confidence cast miss + assertNotNull hit + assertEquals "Missing ')'", hit.message + } + + @Test + void 'cast pattern qualified type then literal'() { + def hit = MissingDelimiterDiagnostic.locate(tokenStream('(java.lang.Integer 1'), null) + assertNotNull hit + assertEquals "Missing ')'", hit.message + } + + @Test + void 'cast pattern generic type then literal'() { + def hit = MissingDelimiterDiagnostic.locate(tokenStream('(List<String> 1'), null) + assertNotNull hit + assertEquals "Missing ')'", hit.message + } + + @Test + void 'cast pattern array type then literal'() { + def hit = MissingDelimiterDiagnostic.locate(tokenStream('(int[] 1'), null) + assertNotNull hit + assertEquals "Missing ')'", hit.message + } + + @Test + void 'cast pattern annotated primitive then literal'() { + def hit = MissingDelimiterDiagnostic.locate(tokenStream('(@Deprecated int 1'), null) + assertNotNull hit + assertEquals "Missing ')'", hit.message + } + + @Test + void 'cast pattern annotation with args then literal'() { + def hit = MissingDelimiterDiagnostic.locate(tokenStream('(@Anno(1) int 2'), null) + assertNotNull hit + assertEquals "Missing ')'", hit.message + } + + @Test + void 'cast pattern floating point operand'() { + def hit = MissingDelimiterDiagnostic.locate(tokenStream('(int 1.5'), null) + assertNotNull hit + assertEquals "Missing ')'", hit.message + } + + @Test + void 'cast pattern string operand'() { + def hit = MissingDelimiterDiagnostic.locate(tokenStream('(int "x"'), null) + assertNotNull hit + assertEquals "Missing ')'", hit.message + } + + @Test + void 'cast pattern boolean operand'() { + def hit = MissingDelimiterDiagnostic.locate(tokenStream('(int true'), null) + assertNotNull hit + assertEquals "Missing ')'", hit.message + } + + @Test + void 'cast pattern nested paren operand'() { + def hit = MissingDelimiterDiagnostic.locate(tokenStream('(int (1'), null) + assertNotNull hit + assertEquals "Missing ')'", hit.message + } + + @Test + void 'cast pattern type only at EOF'() { + def hit = MissingDelimiterDiagnostic.locate(tokenStream('(int'), null) + assertNotNull hit + assertEquals "Missing ')'", hit.message + } + + @Test + void 'well-formed cast does not fire cast strategy alone when parens close'() { + // balanced (int)1 — no missing delimiter + assertNull MissingDelimiterDiagnostic.locate(tokenStream('(int)1'), null) + } + + @Test + void 'intersection-type cast continues past BITAND without false positive'() { + // (A & B) x with missing outer — still has well-formed structure for first type+BITAND + // Use incomplete: (A & so after BITAND we need another type + def hit = MissingDelimiterDiagnostic.locate(tokenStream('(A & B 1'), null) + // (A is type CapitalizedIdentifier, next is BITAND → continue cast scan; + // may still report via stack or later cast of incomplete form + assertNotNull hit + assertEquals "Missing ')'", hit.message + } + + @Test + void 'parenthesised command-like form does not use low-confidence cast'() { + // (foo bar) incomplete: (foo bar — identifier after identifier is low confidence for cast + def hit = MissingDelimiterDiagnostic.locate(tokenStream('(foo bar'), null) + // stack still reports missing ')' + assertNotNull hit + assertEquals "Missing ')'", hit.message + } + + @Test + void 'malformed annotation after LPAREN skips cast type match'() { + // (@ alone — skipAnnotations returns -1 + def hit = MissingDelimiterDiagnostic.locate(tokenStream('(@'), null) + assertNotNull hit + assertEquals "Missing ')'", hit.message // via stack + } + + @Test + void 'cast pattern continues past DOT after primitive without false cast hit on DOT'() { + // afterType lands on DOT → continue in cast scan; still unclosed via stack + def hit = MissingDelimiterDiagnostic.locate(tokenStream('(int .x'), null) + assertNotNull hit + assertEquals "Missing ')'", hit.message + } + + @Test + void 'cast pattern continues past LT fragment without treating as cast operand'() { + def hit = MissingDelimiterDiagnostic.locate(tokenStream('(Foo<'), null) + assertNotNull hit + assertEquals "Missing ')'", hit.message + } + + @Test + void 'unbalanced annotation arguments in cast position'() { + def hit = MissingDelimiterDiagnostic.locate(tokenStream('(@Anno(1 int 2'), null) + assertNotNull hit + assertEquals "Missing ')'", hit.message + } + + @Test + void 'unbalanced generics in cast type position'() { + def hit = MissingDelimiterDiagnostic.locate(tokenStream('(List<String 1'), null) + assertNotNull hit + assertEquals "Missing ')'", hit.message + } + + @Test + void 'bracket depth counts nested open and close before trailing unclosed'() { + // [[1] → RBRACK decrements depth then one open remains (strategy 1 or stack) + def tokens = tokenStream('[[1]') + def hit = MissingDelimiterDiagnostic.locate(tokens, stubException(setOf(RBRACK), lastToken(tokens))) + assertNotNull hit + assertEquals "Missing ']'", hit.message + } + + @Test + void 'brace depth counts close then still unclosed outer'() { + def tokens = tokenStream('{{1}') + def hit = MissingDelimiterDiagnostic.locate(tokens, stubException(setOf(RBRACE), lastToken(tokens))) + assertNotNull hit + assertEquals "Missing '}'", hit.message + } + + @Test + void 'paren depth counts close then still unclosed outer'() { + def tokens = tokenStream('((1)') + def hit = MissingDelimiterDiagnostic.locate(tokens, stubException(setOf(RPAREN), lastToken(tokens))) + assertNotNull hit + assertEquals "Missing ')'", hit.message + } + + //-------------------------------------------------------------------------- + // Strategy 3 — delimiter stack + //-------------------------------------------------------------------------- + + @Test + void 'extra closer alone is not reported as missing'() { + assertNull MissingDelimiterDiagnostic.locate(tokenStream(')'), null) + assertNull MissingDelimiterDiagnostic.locate(tokenStream(']'), null) + assertNull MissingDelimiterDiagnostic.locate(tokenStream('}'), null) + } + + @Test + void 'mismatched closer prefers innermost opener'() { + def hit = MissingDelimiterDiagnostic.locate(tokenStream('foo([1, 2)'), null) + assertNotNull hit + assertEquals "Missing ']'", hit.message + } + + @Test + void 'mismatched brace against open paren'() { + def hit = MissingDelimiterDiagnostic.locate(tokenStream('m(}'), null) + assertNotNull hit + assertEquals "Missing ')'", hit.message + } + + @Test + void 'opener with no interior tokens at EOF'() { + def hit = MissingDelimiterDiagnostic.locate(tokenStream('('), null) + assertNotNull hit + assertEquals "Missing ')'", hit.message + } + + @Test + void 'opener with no interior tokens and mismatched closer'() { + def hit = MissingDelimiterDiagnostic.locate(tokenStream('(]'), null) + assertNotNull hit + assertEquals "Missing ')'", hit.message + } + + @Test + void 'safe-index unclosed reports Missing bracket'() { + def hit = MissingDelimiterDiagnostic.locate(tokenStream('a?[0'), null) + assertNotNull hit + assertEquals "Missing ']'", hit.message + } + + @Test + void 'newlines inside braces do not move caret past last statement token'() { + def hit = MissingDelimiterDiagnostic.locate(tokenStream('def m() {\n println 1\n'), null) + assertNotNull hit + assertEquals "Missing '}'", hit.message + assertEquals 2, hit.at.line + } + + @Test + void 'balanced nested delimiters yield null'() { + assertNull MissingDelimiterDiagnostic.locate(tokenStream('foo([1, 2]) { x }'), null) + } + + //-------------------------------------------------------------------------- + // insertionPointAfter / collectDefaultChannelTokens edge cases + //-------------------------------------------------------------------------- + + @Test + void 'insertion point after multi-code-point token'() { + // emoji as identifier content is not valid, but insertionPointAfter uses codePointCount + // Exercise via unclosed list ending with a multi-byte string literal + def hit = MissingDelimiterDiagnostic.locate(tokenStream('["\uD83D\uDE00"'), null) + assertNotNull hit + assertEquals "Missing ']'", hit.message + } + + //-------------------------------------------------------------------------- + // DescriptiveErrorStrategy integration (fallback path) + //-------------------------------------------------------------------------- + + @Test + void 'balanced illegal construct does not force Missing delimiter message'() { + // end-to-end: foo(1;2;3) must not become Missing ')' + def err = compileError('foo(1;2;3)') + assertTrue !err.contains("Missing ')'"), err + } + + @Test + void 'end-to-end sole expected RBRACE style unclosed class'() { + def err = compileError('class C {\n def x\n') + assertTrue err.contains("Missing '}'"), err + } + + @Test + void 'end-to-end void cast missing paren'() { + // may surface as Missing ')' via cast or stack + def err = compileError('def x = (int true') + assertTrue err.contains("Missing ')'"), err + } + + @Test + void 'end-to-end generic cast missing paren'() { + def err = compileError('def x = (List<String> 1') + assertTrue err.contains("Missing ')'"), err + } + + @Test + void 'end-to-end annotation cast missing paren'() { + def err = compileError('def x = (@Deprecated int 1') + assertTrue err.contains("Missing ')'"), err + } + + @Test + void 'end-to-end empty open paren'() { + def err = compileError('(') + assertTrue err.contains("Missing ')'"), err + } + + @Test + void 'end-to-end mismatched paren bracket'() { + def err = compileError('(]') + assertTrue err.contains("Missing ')'") || err.contains("Missing ']'"), err + } + + //-------------------------------------------------------------------------- + // Reflection-based branch coverage for defensive / hard-to-reach paths + //-------------------------------------------------------------------------- + + @Test + void 'fromCastPattern typeStart negative continues'() { + // LPAREN + bare AT (no annotation name) → skipAnnotations returns -1 → continue + def tokens = [tok(LPAREN, '('), tok(AT, '@')] + assertEquals (-1), invoke('skipAnnotations', tokens, 1) + assertNull invoke('fromCastPattern', tokens) + + // also with EOF sentinel + def withEof = [tok(LPAREN, '('), tok(AT, '@'), tok(Token.EOF, null)] + assertEquals (-1), invoke('skipAnnotations', withEof, 1) + assertNull invoke('fromCastPattern', withEof) + } + + @Test + void 'fromCastPattern typeStart past end continues'() { + // skipAnnotations returns index == n (empty type region) + def tokens = [tok(LPAREN, '(')] + assertEquals 1, invoke('skipAnnotations', tokens, 1) // start==n + assertNull invoke('fromCastPattern', tokens) + } + + @Test + void 'fromCastPattern afterType at end of list without EOF sentinel'() { + // No room for operand token → afterType >= n + def tokens = [tok(LPAREN, '('), tok(BuiltInPrimitiveType, 'int')] + def hit = (MissingDelimiterDiagnostic.Hit) invoke('fromCastPattern', tokens) + assertNotNull hit + assertEquals "Missing ')'", hit.message + } + + @Test + void 'fromCastPattern continues when next is DOT LT or LBRACK'() { + def base = [tok(LPAREN, '('), tok(BuiltInPrimitiveType, 'int')] + for (def next : [[tok(DOT, '.'), tok(Identifier, 'x')], + [tok(LT, '<'), tok(CapitalizedIdentifier, 'T')], + [tok(LBRACK, '['), tok(IntegerLiteral, '0')]]) { + def tokens = base + next + // continues cast loop; no high-confidence hit → null from cast alone + assertNull invoke('fromCastPattern', tokens) + } + } + + @Test + void 'fromCastPattern well-formed cast and intersection continue'() { + assertNull invoke('fromCastPattern', [ + tok(LPAREN, '('), tok(BuiltInPrimitiveType, 'int'), tok(RPAREN, ')'), tok(IntegerLiteral, '1') + ]) + assertNull invoke('fromCastPattern', [ + tok(LPAREN, '('), tok(CapitalizedIdentifier, 'A'), tok(BITAND, '&'), tok(CapitalizedIdentifier, 'B'), + tok(RPAREN, ')'), tok(IntegerLiteral, '1') + ]) + } + + @Test + void 'hitForOpen lastInside null branches'() { + def open = tok(LPAREN, '(') + def closer = tok(RBRACK, ']') + // lastInside null + mismatchCloser + def hit1 = (MissingDelimiterDiagnostic.Hit) invoke('hitForOpen', open, null, closer) + assertEquals "Missing ')'", hit1.message + assertEquals closer, hit1.at + // lastInside null + no mismatch → caret on open + def hit2 = (MissingDelimiterDiagnostic.Hit) invoke('hitForOpen', open, null, null) + assertEquals open, hit2.at + } + + @Test + void 'closes returns false for unknown open type'() { + assertEquals Boolean.FALSE, invoke('closes', -999, RPAREN) + } + + @Test + void 'matchType returns start when annotations fail or input exhausted'() { + // annotations fail → start + assertEquals 0, invoke('matchType', [tok(AT, '@')], 0) + // start past end + assertEquals 1, invoke('matchType', [tok(LPAREN, '(')], 1) + } + + @Test + void 'skipBalanced returns -1 when unclosed through end of list'() { + def tokens = [tok(LT, '<'), tok(CapitalizedIdentifier, 'T')] + assertEquals (-1), invoke('skipBalanced', tokens, 0, LT, GT) + } + + @Test + void 'skipBalanced returns -1 on EOF token in stream'() { + def tokens = [tok(LPAREN, '('), tok(Token.EOF, null)] + assertEquals (-1), invoke('skipBalanced', tokens, 0, LPAREN, RPAREN) + } + + @Test + void 'lastNonEof returns null when only EOF present'() { + assertNull invoke('lastNonEof', [tok(Token.EOF, null)]) + } + + @Test + void 'insertionPointAfter null text uses zero length'() { + def t = tok(Identifier, null) + t.text = null + def point = (Token) invoke('insertionPointAfter', t) + assertEquals t.line, point.line + assertEquals t.charPositionInLine, point.charPositionInLine + } + + //-------------------------------------------------------------------------- + // helpers + //-------------------------------------------------------------------------- + + private static CommonTokenStream tokenStream(String source) { + def lexer = new GroovyLangLexer(CharStreams.fromString(source)) + def tokens = new CommonTokenStream(lexer) + tokens.fill() + return tokens + } + + private static CommonToken tok(int type, String text) { + def t = new CommonToken(type, text) + t.line = 1 + t.charPositionInLine = 0 + return t + } + + private static Object invoke(String name, Object... args) { + Method match = null + for (Method m : MissingDelimiterDiagnostic.declaredMethods) { + if (m.name == name && m.parameterCount == args.length) { + match = m + break + } + } + assertNotNull match, "method $name with ${args.length} args" + match.accessible = true + return match.invoke(null, args) + } + + private static Token lastToken(CommonTokenStream tokens) { + tokens.get(tokens.size() - 1) + } + + private static Token findFirst(CommonTokenStream tokens, Closure pred) { + for (int i = 0; i < tokens.size(); i++) { + def t = tokens.get(i) + if (pred(t)) return t + } + return null + } + + private static IntervalSet setOf(int... types) { + def set = new IntervalSet() + types.each { set.add(it) } + return set + } + + private static RecognitionException stubException(IntervalSet expected, Token offending) { + new StubRecognitionException(expected, offending) + } + + private static String compileError(String source) { + try { + def cu = new org.codehaus.groovy.control.CompilationUnit() + cu.addSource('t.groovy', source) + cu.compile(org.codehaus.groovy.control.Phases.CONVERSION) + return '' + } catch (Throwable e) { + return e.message ?: '' + } + } + + /** + * Minimal RecognitionException stand-in that returns a fixed expected set + * and offending token (package-local unit tests only). + */ + private static final class StubRecognitionException extends RecognitionException { + private final IntervalSet expected + private final Token offending + + StubRecognitionException(IntervalSet expected, Token offending) { + super(null, null, null) + this.expected = expected + this.offending = offending + } + + @Override + IntervalSet getExpectedTokens() { + return expected + } + + @Override + Token getOffendingToken() { + return offending + } + } + +} diff --git a/src/test/java/org/apache/groovy/parser/antlr4/internal/ThrowingTokenStream.java b/src/test/java/org/apache/groovy/parser/antlr4/internal/ThrowingTokenStream.java new file mode 100644 index 0000000000..ac1655cecb --- /dev/null +++ b/src/test/java/org/apache/groovy/parser/antlr4/internal/ThrowingTokenStream.java @@ -0,0 +1,103 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.groovy.parser.antlr4.internal; + +import org.antlr.v4.runtime.RuleContext; +import org.antlr.v4.runtime.Token; +import org.antlr.v4.runtime.TokenSource; +import org.antlr.v4.runtime.TokenStream; +import org.antlr.v4.runtime.misc.Interval; + +/** + * Token stream that fails on every access — used to exercise defensive + * catch paths in {@link MissingDelimiterDiagnostic}. + */ +final class ThrowingTokenStream implements TokenStream { + @Override + public Token LT(int k) { + throw new IndexOutOfBoundsException("test"); + } + + @Override + public Token get(int index) { + throw new IndexOutOfBoundsException("test"); + } + + @Override + public TokenSource getTokenSource() { + return null; + } + + @Override + public String getText(Interval interval) { + return ""; + } + + @Override + public String getText() { + return ""; + } + + @Override + public String getText(RuleContext ctx) { + return ""; + } + + @Override + public String getText(Object start, Object stop) { + return ""; + } + + @Override + public void consume() { + } + + @Override + public int LA(int i) { + return Token.EOF; + } + + @Override + public int mark() { + return 0; + } + + @Override + public void release(int marker) { + } + + @Override + public int index() { + return 0; + } + + @Override + public void seek(int index) { + } + + @Override + public int size() { + return 0; + } + + @Override + public String getSourceName() { + return "throwing"; + } +}
