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";
+    }
+}

Reply via email to