This is an automated email from the ASF dual-hosted git repository.
daniellansun pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/groovy.git
The following commit(s) were added to refs/heads/master by this push:
new 2f0a29712f GROOVY-12169: Improve syntax error messages and caret
positions for missing ')', ']', and '}' (#2718)
2f0a29712f is described below
commit 2f0a29712f211481fc82ac127b43fed26d2b7e20
Author: Daniel Sun <[email protected]>
AuthorDate: Sat Jul 18 11:37:32 2026 +0800
GROOVY-12169: Improve syntax error messages and caret positions for missing
')', ']', and '}' (#2718)
* GROOVY-12169: Improve syntax error messages and caret positions for
missing ')', ']', and '}'
* Minor tweak
---
.../antlr4/internal/DescriptiveErrorStrategy.java | 28 +-
.../internal/MissingDelimiterDiagnostic.java | 558 ++++++++++++++
.../groovy/parser/antlr4/SyntaxErrorTest.groovy | 267 ++++++-
.../internal/MissingDelimiterDiagnosticTest.groovy | 817 +++++++++++++++++++++
.../antlr4/internal/ThrowingTokenStream.java | 103 +++
5 files changed, 1767 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..1da2c21fbd
--- /dev/null
+++
b/src/main/java/org/apache/groovy/parser/antlr4/internal/MissingDelimiterDiagnostic.java
@@ -0,0 +1,558 @@
+/*
+ * 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 groovy.lang.Tuple2;
+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 org.apache.groovy.parser.antlr4.util.PositionConfigureUtils;
+
+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;
+ }
+
+ /**
+ * Last non-EOF, non-NL token. Skipping NL keeps the caret on the last
+ * meaningful line (same idea as {@link #fromDelimiterStack}) instead of
+ * after a trailing newline that often sits alone on the next source line.
+ */
+ private static Token lastNonEof(final List<Token> tokens) {
+ for (int i = tokens.size() - 1; i >= 0; i--) {
+ Token t = tokens.get(i);
+ int type = t.getType();
+ if (type != Token.EOF && type != NL) {
+ 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>
+ * Reporting the earliest site is intentional: with
+ * {@link org.antlr.v4.runtime.BailErrorStrategy} the parser stops at the
+ * first recognition failure, so the first incomplete cast is the relevant
+ * one. In a hypothetical multi-error buffer the caret could theoretically
+ * point at an earlier cast-like fragment than the failure ANTLR reported;
+ * that trade-off is accepted to keep the scan linear and free of false
+ * coupling to ANTLR's sometimes-unrelated offending token.
+ */
+ 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 / partially filled streams may reject absolute
get(i)
+ // (e.g. UnbufferedTokenStream outside its window). Partial
default-
+ // channel tokens are still useful for best-effort diagnostics.
+ }
+ 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).
+ * <p>
+ * Multi-line tokens (triple-quoted strings, multi-line GString fragments,
+ * …) place the caret on the <em>last</em> line of the token, after its
+ * final content. Adding the full code-point length to the start column
+ * alone would overshoot on the start line when the token text contains
+ * newlines.
+ * </p>
+ * <p>
+ * End line/column rules match {@link PositionConfigureUtils#endPosition}
+ * so diagnostic carets stay consistent with AST last-position handling
+ * (code-point based columns, newline counting).
+ * </p>
+ */
+ private static Token insertionPointAfter(final Token token) {
+ CommonToken point = new CommonToken(Token.INVALID_TYPE);
+ point.setText("");
+ String text = token.getText();
+ if (text == null) {
+ point.setLine(token.getLine());
+ point.setCharPositionInLine(token.getCharPositionInLine());
+ return point;
+ }
+ // endPosition → (line, 1-based exclusive column); ANTLR columns are
0-based
+ Tuple2<Integer, Integer> end =
PositionConfigureUtils.endPosition(token);
+ point.setLine(end.getV1());
+ point.setCharPositionInLine(end.getV2() - 1);
+ 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..046ad00374 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,267 @@ 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()
+ }
+
+ @Test
+ void 'error alternative - Missing "}" after multi-line string'() {
+ // Caret must sit on the last line of the triple-quoted string, not on
+ // the start line with an overshot column (multi-line insertion point).
+ expectParseError '''\
+ |def m() {
+ | s = """
+ |line2
+ |line3"""
+ |'''.stripMargin(), '''\
+ |Missing '}' @ line 4, column 9.
+ | line3"""
+ | ^
+ |
+ |1 error
+ |'''.stripMargin()
+ }
+
+ @Test
+ void 'error alternative - Missing "}" after multi-line single-quoted
string'() {
+ // Outer double-quoted long strings avoid clashing with ''' inside the
source.
+ expectParseError("""\
+ |def m() {
+ | s = '''
+ |x
+ |y'''
+ |""".stripMargin(), """\
+ |Missing '}' @ line 4, column 5.
+ | y'''
+ | ^
+ |
+ |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..88dba5f9c1
--- /dev/null
+++
b/src/test/groovy/org/apache/groovy/parser/antlr4/internal/MissingDelimiterDiagnosticTest.groovy
@@ -0,0 +1,817 @@
+/*
+ * 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.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.StringLiteral
+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
+ // caret just past the string: '[' + '"' is col 0..1, string starts at
1; emoji is 1 code point + quotes
+ assertEquals 1, hit.at.line
+ assertTrue hit.at.charPositionInLine > 1
+ }
+
+ @Test
+ void 'insertion point after multi-line string does not overshoot start
line'() {
+ // paulk-asert review: multi-line anchor before missing '}' must put
caret on last line
+ def src = 'def m() {\n s = """\nline2\nline3"""\n'
+ def hit = MissingDelimiterDiagnostic.locate(tokenStream(src), null)
+ assertNotNull hit
+ assertEquals "Missing '}'", hit.message
+ assertEquals 4, hit.at.line
+ // "line3""" is 8 code points → exclusive end column 8 (0-based)
+ assertEquals 8, hit.at.charPositionInLine
+ }
+
+ @Test
+ void 'insertion point after multi-line GString-like triple quote single
quotes'() {
+ def src = "def m() {\n s = '''a\nb'''\n"
+ def hit = MissingDelimiterDiagnostic.locate(tokenStream(src), null)
+ assertNotNull hit
+ assertEquals "Missing '}'", hit.message
+ assertEquals 3, hit.at.line
+ // b''' → 4 code points, 0-based exclusive end = 4
+ assertEquals 4, hit.at.charPositionInLine
+ }
+
+ @Test
+ void 'insertionPointAfter multi-line matches PositionConfigureUtils end
line'() {
+ def t = tok(StringLiteral, '"""\nline2\nend"""')
+ t.line = 2
+ t.charPositionInLine = 6
+ def point = (Token) invoke('insertionPointAfter', t)
+ assertEquals 4, point.line
+ assertEquals 6, point.charPositionInLine // "end""" length 6
+ }
+
+ @Test
+ void 'lastNonEof skips trailing NL tokens'() {
+ def tokens = [
+ tok(Identifier, 'x'),
+ tok(NL, '\n'),
+ tok(Token.EOF, null)
+ ]
+ def last = (Token) invoke('lastNonEof', tokens)
+ assertEquals Identifier, last.type
+ assertEquals 'x', last.text
+ }
+
+ @Test
+ void 'sole expected EOF after multi-line string uses last line of
string'() {
+ def tokens = tokenStream('def m() {\n s = """\nline2\nline3"""\n')
+ def eof = lastToken(tokens)
+ def hit = MissingDelimiterDiagnostic.locate(tokens,
stubException(setOf(RBRACE), eof))
+ assertNotNull hit
+ assertEquals "Missing '}'", hit.message
+ assertEquals 4, hit.at.line
+ assertEquals 8, hit.at.charPositionInLine
+ }
+
+ @Test
+ void 'collectDefaultChannelTokens tolerates IllegalArgumentException'() {
+ assertNull MissingDelimiterDiagnostic.locate(new
IllegalArgTokenStream(), null)
+ }
+
+
//--------------------------------------------------------------------------
+ // 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
+ }
+ }
+
+ /**
+ * Token stream that throws {@link IllegalArgumentException} on {@code
get},
+ * covering the non-IndexOutOfBounds defensive catch branch.
+ */
+ private static final class IllegalArgTokenStream implements
org.antlr.v4.runtime.TokenStream {
+ @Override
+ Token LT(int k) { throw new IllegalArgumentException('test') }
+
+ @Override
+ Token get(int index) { throw new IllegalArgumentException('test') }
+
+ @Override
+ org.antlr.v4.runtime.TokenSource getTokenSource() { null }
+
+ @Override
+ String getText(org.antlr.v4.runtime.misc.Interval interval) { '' }
+
+ @Override
+ String getText() { '' }
+
+ @Override
+ String getText(org.antlr.v4.runtime.RuleContext ctx) { '' }
+
+ @Override
+ String getText(Object start, Object stop) { '' }
+
+ @Override
+ void consume() {}
+
+ @Override
+ int LA(int i) { Token.EOF }
+
+ @Override
+ int mark() { 0 }
+
+ @Override
+ void release(int marker) {}
+
+ @Override
+ int index() { 0 }
+
+ @Override
+ void seek(int index) {}
+
+ @Override
+ int size() { 0 }
+
+ @Override
+ String getSourceName() { 'illegal-arg' }
+ }
+
+}
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";
+ }
+}