You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@groovy.apache.org by su...@apache.org on 2026/09/05 06:54:05 UTC
(groovy) branch GROOVY-12353 updated (f522a55305 -> 7a2ad63221)
This is an automated email from the ASF dual-hosted git repository.
asf-gitbox-commits pushed a change to branch GROOVY-12353
in repository https://gitbox.apache.org/repos/asf/groovy.git
omit f522a55305 GROOVY-12353: Improve remaining common syntax error messages (unclosed literals, unexpected characters, missing punctuation, reserved keywords)
new 7a2ad63221 GROOVY-12353: Improve remaining common syntax error messages (unclosed literals, unexpected characters, missing punctuation, reserved keywords)
This update added new revisions after undoing existing revisions.
That is to say, some revisions that were in the old version of the
branch are not in the new version. This situation occurs
when a user --force pushes a change and generates a repository
containing something like this:
* -- * -- B -- O -- O -- O (f522a55305)
\
N -- N -- N refs/heads/GROOVY-12353 (7a2ad63221)
You should already have received notification emails for all of the O
revisions, and so the following emails describe only the N revisions
from the common base, B.
Any revisions marked "omit" are not gone; other references still
refer to them. Any revisions marked "discard" are gone forever.
The 1 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails. The revisions
listed as "add" were already present in the repository and have only
been added to this reference.
Summary of changes:
.../internal/AbstractFriendlyErrorStrategy.java | 7 +-
.../internal/MissingDelimiterDiagnostic.java | 71 +++++--
.../groovy/parser/antlr4/AbstractLexerTest.java | 5 +-
.../parser/antlr4/internal/ErrorStrategyTest.java | 209 +++++----------------
4 files changed, 107 insertions(+), 185 deletions(-)
(groovy) 01/01: GROOVY-12353: Improve remaining common syntax error messages (unclosed literals, unexpected characters, missing punctuation, reserved keywords)
Posted by su...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
asf-gitbox-commits pushed a commit to branch GROOVY-12353
in repository https://gitbox.apache.org/repos/asf/groovy.git
commit 7a2ad63221ba2451bcdb6fe30c2711b12591046e
Author: Daniel Sun <su...@apache.org>
AuthorDate: Sat Sep 5 01:13:05 2026 +0900
GROOVY-12353: Improve remaining common syntax error messages (unclosed literals, unexpected characters, missing punctuation, reserved keywords)
---
ARCHITECTURE.md | 17 +
src/antlr/GroovyLexer.g4 | 23 +-
.../apache/groovy/parser/antlr4/AbstractLexer.java | 132 ++++++
.../apache/groovy/parser/antlr4/AstBuilder.java | 3 +-
.../groovy/parser/antlr4/GroovySyntaxError.java | 14 +-
.../parser/antlr4/SyntaxErrorReportable.java | 7 +
.../internal/AbstractFriendlyErrorStrategy.java | 348 ++++++++++++++-
.../internal/MissingDelimiterDiagnostic.java | 98 ++++-
.../parser/antlr4/internal/package-info.java | 9 +-
src/spec/doc/core-operators.adoc | 2 +
src/spec/doc/core-syntax.adoc | 6 +
.../core/Comment_01x.groovy} | 21 +-
.../groovy/gls/generics/GenericsUsageTest.groovy | 6 +-
.../parser/antlr4/CommonSyntaxErrorTest.groovy | 373 ++++++++++++++++
.../groovy/parser/antlr4/GroovyParserTest.groovy | 5 +
.../groovy/parser/antlr4/SyntaxErrorTest.groovy | 28 +-
.../apache/groovy/parser/antlr4/TestUtils.groovy | 54 +++
.../internal/MissingDelimiterDiagnosticTest.groovy | 38 ++
.../groovy/parser/antlr4/AbstractLexerTest.java | 236 +++++++++++
.../parser/antlr4/internal/ErrorStrategyTest.java | 470 ++++++++++++++++++++-
20 files changed, 1809 insertions(+), 81 deletions(-)
diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index b32d4104d2..28ef8e4605 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -105,6 +105,23 @@ verbatim keeps the reference precise; paraphrasing tends to drift.
`GroovyLangParser`, `AstBuilder`, plus support classes:
`ModifierManager`, `GroovydocManager`, `SemanticPredicates`,
`PositionInfo`).
+- Recognition failures share one message path
+ (`DescriptiveErrorStrategy` / `RecoveringDescriptiveErrorStrategy` via
+ `AbstractFriendlyErrorStrategy`). `MissingDelimiterDiagnostic` may
+ relocate the caret for an unclosed `)` / `]` / `}` (Groovy 4 `?[` is
+ the same opener family as `[`); reserved keywords, a leading `?[`
+ that has no path to attach to, and sole-expected punctuation only
+ refine the fallback sentence.
+ Locate/refine stay in a defensive try; listener dispatch is a single
+ call afterwards. Lexer unexpected-character / GString-dollar display
+ goes through `Lexer.getCharErrorDisplay` (Unicode spaces other than
+ `U+0020`, curly quotes, and non-ASCII dashes are named as escapes so
+ they do not vanish into the caret line). Unclosed `/*` comments are
+ a lexer diagnostic at the opener (`requireUnclosedComment`), not a
+ parser fallback. Slashy strings cannot use an EOF closer: `/` after
+ an expression with newlines is division (`9 \n / \n 3`). All of that
+ runs only after recognition has already failed. Do not re-introduce
+ grammar-level parser error alternatives for this (GROOVY-9588).
- `AstBuilder` is the hand-off from CST to AST. It is large; almost
every parser-visible language change touches it.
diff --git a/src/antlr/GroovyLexer.g4 b/src/antlr/GroovyLexer.g4
index dabef396b9..5e7b22fcf5 100644
--- a/src/antlr/GroovyLexer.g4
+++ b/src/antlr/GroovyLexer.g4
@@ -515,10 +515,10 @@ IntegerLiteral
| HexIntegerLiteral
| OctalIntegerLiteral
| BinaryIntegerLiteral
- ) (Underscore { require(errorIgnored, "Number ending with underscores is invalid", -1, true); })?
+ ) (Underscore { require(errorIgnored, "Number ending with underscores is invalid", -1, false); })?
// !!! Error Alternative !!!
- | Zero ([0-9] { invalidDigitCount++; })+ { require(errorIgnored, "Invalid octal number", -(invalidDigitCount + 1), true); } IntegerTypeSuffix?
+ | Zero ([0-9] { invalidDigitCount++; })+ { require(errorIgnored, "Invalid octal number", -(invalidDigitCount + 1), false); } IntegerTypeSuffix?
;
fragment
@@ -657,7 +657,7 @@ BinaryDigitOrUnderscore
FloatingPointLiteral
: ( DecimalFloatingPointLiteral
| HexadecimalFloatingPointLiteral
- ) (Underscore { require(errorIgnored, "Number ending with underscores is invalid", -1, true); })?
+ ) (Underscore { require(errorIgnored, "Number ending with underscores is invalid", -1, false); })?
;
fragment
@@ -997,9 +997,14 @@ WS : ([ \t]+ | LineEscape+) -> skip
NL : LineTerminator { ignoreTokenInsideParens(); }
;
-// Multiple-line comments (including groovydoc comments)
+// Multiple-line comments (including groovydoc comments).
+// One outermost alt so `-> type(NL)` is legal. `.*?` then (`*/` | EOF) stops
+// at the first closer; a *separate* lexer rule for unclosed comments would
+// win by longest-match and swallow trailing source. javac reports
+// "unclosed comment" at the opener; requireUnclosedComment keeps the caret there.
ML_COMMENT
- : '/*' .*? '*/' { addComment(0); ignoreMultiLineCommentConditionally(); } -> type(NL)
+ : '/*' .*? ('*/' | EOF { requireUnclosedComment(errorIgnored); })
+ { addComment(0); ignoreMultiLineCommentConditionally(); } -> type(NL)
;
// Single-line comments
@@ -1010,10 +1015,12 @@ SL_COMMENT
// Script-header comments.
// The very first characters of the file may be "#!". If so, ignore the first line.
SH_COMMENT
- : '#!' { require(errorIgnored || 0 == this.tokenIndex, "Shebang comment should appear at the first line", -2, true); } ShCommand (LineTerminator '#!' ShCommand)* -> skip
+ : '#!' { require(errorIgnored || 0 == this.tokenIndex, "Shebang comment should appear at the first line", -2, false); } ShCommand (LineTerminator '#!' ShCommand)* -> skip
;
-// Unexpected characters will be handled by groovy parser later.
+// Unexpected characters (and unclosed quotes). Display goes through
+// AbstractLexer#getCharErrorDisplay; an unexpected quote is reported as
+// an unclosed string literal.
UNEXPECTED_CHAR
- : . { require(errorIgnored, "Unexpected character: '" + getText().replace("'", "\\'") + "'", -1, false); }
+ : . { require(errorIgnored, unexpectedCharacterMessage(), -1, false); }
;
diff --git a/src/main/java/org/apache/groovy/parser/antlr4/AbstractLexer.java b/src/main/java/org/apache/groovy/parser/antlr4/AbstractLexer.java
index b3781b4d73..213b6b41e9 100644
--- a/src/main/java/org/apache/groovy/parser/antlr4/AbstractLexer.java
+++ b/src/main/java/org/apache/groovy/parser/antlr4/AbstractLexer.java
@@ -18,15 +18,147 @@
*/
package org.apache.groovy.parser.antlr4;
+import groovy.lang.Tuple;
import org.antlr.v4.runtime.CharStream;
import org.antlr.v4.runtime.Lexer;
+import org.antlr.v4.runtime.Token;
/**
* Because antlr4 does not support generating lexer with specified interface,
* we have to create a super class for it and implement the interface.
+ * <p>
+ * Lexer-error-path helpers used from {@code GroovyLexer.g4} actions also live
+ * here so the grammar stays thin. Successful tokenisation never calls them.
+ * Character display for diagnostics goes through {@link #getCharErrorDisplay(int)}
+ * so GString dollar errors and {@code UNEXPECTED_CHAR} share one escape policy.
+ * </p>
*/
public abstract class AbstractLexer extends Lexer implements SyntaxErrorReportable {
+
public AbstractLexer(CharStream input) {
super(input);
}
+
+ /**
+ * User-facing message for {@code UNEXPECTED_CHAR}.
+ * <p>
+ * An unexpected {@code '} or {@code "} is reported as an unclosed string
+ * literal (javac: {@code unclosed string literal}). Other characters use
+ * {@code Unexpected character: '...'} via {@link #getCharErrorDisplay(int)}.
+ * </p>
+ */
+ String unexpectedCharacterMessage() {
+ return unexpectedCharacterMessage(getText());
+ }
+
+ /**
+ * @param text the unexpected character(s), or {@code null}/{@code ""} if the
+ * lexer has no current text (still reported, without a glyph)
+ */
+ static String unexpectedCharacterMessage(final String text) {
+ if (text == null || text.isEmpty()) {
+ return "Unexpected character";
+ }
+ int cp = text.codePointAt(0);
+ if (cp == '\'' || cp == '"') {
+ return "Unclosed string literal";
+ }
+ return "Unexpected character: " + quotedCodePoint(cp);
+ }
+
+ /**
+ * Report an unclosed {@code /}{@code *} comment at the opener (javac:
+ * {@code unclosed comment}), not at EOF after the scan.
+ *
+ * @param errorIgnored when {@code true}, keep tokenising (IDE highlighting)
+ */
+ void requireUnclosedComment(final boolean errorIgnored) {
+ requireAtTokenStart(errorIgnored, "Unclosed comment");
+ }
+
+ /**
+ * {@link SyntaxErrorReportable#require} positions relative to the current
+ * lexer cursor. After scanning to EOF that cursor is past the comment, so
+ * the offset is token-start minus current (line and column).
+ */
+ private void requireAtTokenStart(final boolean errorIgnored, final String msg) {
+ require(errorIgnored, msg,
+ Tuple.tuple(_tokenStartLine - getLine(),
+ _tokenStartCharPositionInLine - getCharPositionInLine()),
+ false);
+ }
+
+ /**
+ * Quoted diagnostic form of a code point. Overrides ANTLR's UTF-16
+ * {@code (char)} truncation and limited escapes so both unexpected-character
+ * and GString-dollar messages can name invisible code points.
+ */
+ @Override
+ public String getCharErrorDisplay(final int c) {
+ if (c == Token.EOF) {
+ return "'<EOF>'";
+ }
+ return quotedCodePoint(c);
+ }
+
+ private static String quotedCodePoint(final int cp) {
+ return "'" + displayCodePoint(cp) + "'";
+ }
+
+ /**
+ * Render {@code cp} for a diagnostic, matching javac's {@code Convert.quote}
+ * for the standard escapes and using a UTF-16 unicode escape for other
+ * non-printable / format / ignorable code points, Unicode spaces other
+ * than {@code U+0020}, curly quotes, and non-ASCII dashes — those glyphs
+ * are easy to confuse with ASCII in a caret line. Printable characters —
+ * including non-ASCII such as emoji — are left as-is so the message stays
+ * readable when the glyph is visible.
+ */
+ private static String displayCodePoint(final int cp) {
+ switch (cp) {
+ case '\b':
+ return "\\b";
+ case '\t':
+ return "\\t";
+ case '\n':
+ return "\\n";
+ case '\f':
+ return "\\f";
+ case '\r':
+ return "\\r";
+ case '\'':
+ return "\\'";
+ case '\\':
+ return "\\\\";
+ default:
+ if (shouldEscape(cp)) {
+ if (cp <= 0xFFFF) {
+ return String.format("\\u%04x", cp);
+ }
+ return String.format("\\u%04x\\u%04x",
+ (int) Character.highSurrogate(cp),
+ (int) Character.lowSurrogate(cp));
+ }
+ return new String(Character.toChars(cp));
+ }
+ }
+
+ private static boolean shouldEscape(final int cp) {
+ if (cp < 0x20 || cp == 0x7F) {
+ return true;
+ }
+ int type = Character.getType(cp);
+ return type == Character.CONTROL
+ || type == Character.FORMAT
+ || type == Character.LINE_SEPARATOR
+ || type == Character.PARAGRAPH_SEPARATOR
+ || type == Character.SURROGATE
+ || type == Character.PRIVATE_USE
+ || type == Character.UNASSIGNED
+ || (type == Character.SPACE_SEPARATOR && cp != ' ')
+ || type == Character.INITIAL_QUOTE_PUNCTUATION
+ || type == Character.FINAL_QUOTE_PUNCTUATION
+ || (type == Character.DASH_PUNCTUATION && cp > 0x7F)
+ || Character.isIdentifierIgnorable(cp);
+ }
}
diff --git a/src/main/java/org/apache/groovy/parser/antlr4/AstBuilder.java b/src/main/java/org/apache/groovy/parser/antlr4/AstBuilder.java
index c463ac4346..0a291629b5 100644
--- a/src/main/java/org/apache/groovy/parser/antlr4/AstBuilder.java
+++ b/src/main/java/org/apache/groovy/parser/antlr4/AstBuilder.java
@@ -4500,7 +4500,8 @@ public class AstBuilder extends GroovyParserBaseVisitor<Object> {
for (int i = 0, n = formalParameterList.size(); i < n - 1; i += 1) {
FormalParameterContext formalParameterContext = formalParameterList.get(i);
if (asBoolean(formalParameterContext.ELLIPSIS())) {
- throw createParsingFailedException("The var-arg parameter strs must be the last parameter", formalParameterContext);
+ String name = formalParameterContext.variableDeclaratorId().getText();
+ throw createParsingFailedException("The var-arg parameter " + name + " must be the last parameter", formalParameterContext);
}
}
}
diff --git a/src/main/java/org/apache/groovy/parser/antlr4/GroovySyntaxError.java b/src/main/java/org/apache/groovy/parser/antlr4/GroovySyntaxError.java
index 3ce09c2f7d..9a8b6bbd0e 100644
--- a/src/main/java/org/apache/groovy/parser/antlr4/GroovySyntaxError.java
+++ b/src/main/java/org/apache/groovy/parser/antlr4/GroovySyntaxError.java
@@ -19,7 +19,19 @@
package org.apache.groovy.parser.antlr4;
/**
- * Represents a syntax error of groovy program
+ * Represents a syntax error of a Groovy program, raised by the lexer or parser.
+ * <p>
+ * The message is the diagnostic as produced by the recogniser (for example
+ * {@code Unclosed string literal} or {@code Unexpected character: '\u005cu200b'}).
+ * Line and column are 1-based caret coordinates. On the compiler's normal lexer
+ * path ({@link SyntaxErrorReportable#require} with {@code toAttachPositionInfo}
+ * false), the location is not part of the message string —
+ * {@link org.codehaus.groovy.syntax.SyntaxException} appends
+ * {@code @ line N, column M} when wrapping the error. The public
+ * {@link SyntaxErrorReportable#throwSyntaxError} path can still append
+ * {@link PositionInfo} when {@code toAttachPositionInfo} is true (IDE /
+ * highlighter callers).
+ * </p>
*/
public class GroovySyntaxError extends AssertionError {
public static final int LEXER = 0;
diff --git a/src/main/java/org/apache/groovy/parser/antlr4/SyntaxErrorReportable.java b/src/main/java/org/apache/groovy/parser/antlr4/SyntaxErrorReportable.java
index 92c927ae9d..8f8518370d 100644
--- a/src/main/java/org/apache/groovy/parser/antlr4/SyntaxErrorReportable.java
+++ b/src/main/java/org/apache/groovy/parser/antlr4/SyntaxErrorReportable.java
@@ -56,6 +56,13 @@ public interface SyntaxErrorReportable {
default void throwSyntaxError(String msg, int offset, boolean toAttachPositionInfo) {
throwSyntaxError(msg, Tuple.tuple(0, offset), toAttachPositionInfo);
}
+ /**
+ * @param toAttachPositionInfo if {@code true}, append {@code " @ line N, column M"}
+ * to the exception message. Leave {@code false} when the
+ * error will be wrapped in {@link org.codehaus.groovy.syntax.SyntaxException},
+ * which already appends the same location (otherwise the
+ * displayed message repeats the position).
+ */
default void throwSyntaxError(String msg, Tuple2<Integer, Integer> offset, boolean toAttachPositionInfo) {
PositionInfo positionInfo = this.genPositionInfo(offset);
throw new GroovySyntaxError(msg + (toAttachPositionInfo ? positionInfo.toString() : ""),
diff --git a/src/main/java/org/apache/groovy/parser/antlr4/internal/AbstractFriendlyErrorStrategy.java b/src/main/java/org/apache/groovy/parser/antlr4/internal/AbstractFriendlyErrorStrategy.java
index 11fbb850e1..12c42d2f6b 100644
--- a/src/main/java/org/apache/groovy/parser/antlr4/internal/AbstractFriendlyErrorStrategy.java
+++ b/src/main/java/org/apache/groovy/parser/antlr4/internal/AbstractFriendlyErrorStrategy.java
@@ -28,20 +28,72 @@ import org.antlr.v4.runtime.RecognitionException;
import org.antlr.v4.runtime.Token;
import org.antlr.v4.runtime.TokenStream;
import org.antlr.v4.runtime.misc.Interval;
+import org.antlr.v4.runtime.misc.IntervalSet;
import java.util.Objects;
+import static org.apache.groovy.parser.antlr4.GroovyParser.ARROW;
+import static org.apache.groovy.parser.antlr4.GroovyParser.AT;
+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.CASE;
+import static org.apache.groovy.parser.antlr4.GroovyParser.CATCH;
+import static org.apache.groovy.parser.antlr4.GroovyParser.COLON;
+import static org.apache.groovy.parser.antlr4.GroovyParser.CONST;
+import static org.apache.groovy.parser.antlr4.GroovyParser.CapitalizedIdentifier;
+import static org.apache.groovy.parser.antlr4.GroovyParser.DEFAULT;
+import static org.apache.groovy.parser.antlr4.GroovyParser.DOT;
+import static org.apache.groovy.parser.antlr4.GroovyParser.ELSE;
+import static org.apache.groovy.parser.antlr4.GroovyParser.FINALLY;
+import static org.apache.groovy.parser.antlr4.GroovyParser.FloatingPointLiteral;
+import static org.apache.groovy.parser.antlr4.GroovyParser.GOTO;
+import static org.apache.groovy.parser.antlr4.GroovyParser.GStringEnd;
+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.METHOD_POINTER;
+import static org.apache.groovy.parser.antlr4.GroovyParser.METHOD_REFERENCE;
+import static org.apache.groovy.parser.antlr4.GroovyParser.NL;
+import static org.apache.groovy.parser.antlr4.GroovyParser.NullLiteral;
+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_CHAIN_DOT;
+import static org.apache.groovy.parser.antlr4.GroovyParser.SAFE_DOT;
+import static org.apache.groovy.parser.antlr4.GroovyParser.SAFE_INDEX;
+import static org.apache.groovy.parser.antlr4.GroovyParser.SEMI;
+import static org.apache.groovy.parser.antlr4.GroovyParser.SPREAD_DOT;
+import static org.apache.groovy.parser.antlr4.GroovyParser.StringLiteral;
+import static org.apache.groovy.parser.antlr4.GroovyParser.SUPER;
+import static org.apache.groovy.parser.antlr4.GroovyParser.THIS;
+import static org.apache.groovy.parser.antlr4.GroovyParser.THREADSAFE;
+import static org.apache.groovy.parser.antlr4.GroovyParser.VOID;
+import static org.apache.groovy.parser.antlr4.GroovyParser.WHILE;
+
/**
* Shared friendly recognition diagnostics for Parrot error strategies.
* <p>
* Subclasses choose control flow ({@link DescriptiveErrorStrategy} fail-fast vs
- * {@link RecoveringDescriptiveErrorStrategy} multi-error resync). Reporting —
- * including {@link MissingDelimiterDiagnostic} — stays here so both modes share
- * one message path.
+ * {@link RecoveringDescriptiveErrorStrategy} multi-error resync). Reporting
+ * stays here so both modes share one message path. A successful parse never
+ * enters these methods.
+ * </p>
+ * <p>
+ * Two layers, on purpose: {@link MissingDelimiterDiagnostic} may <em>relocate</em>
+ * the caret (insertion point after an unclosed {@code ) } / {@code ] } / {@code } }).
+ * Keyword / missing-receiver {@code ?[} / sole-expected-punctuation /
+ * unexpected-EOF wording only changes the sentence and keeps ANTLR's
+ * offending token, so it is a fallback-string refine rather than a second
+ * locate pass.
* </p>
*/
abstract class AbstractFriendlyErrorStrategy extends DefaultErrorStrategy {
+ private static final String SAFE_INDEX_NEEDS_RECEIVER = "'?[' requires an expression before it";
+
private final CharStream charStream;
AbstractFriendlyErrorStrategy(final CharStream charStream) {
@@ -49,24 +101,290 @@ abstract class AbstractFriendlyErrorStrategy extends DefaultErrorStrategy {
}
/**
- * Prefer a precise "Missing …" delimiter diagnostic when the token stream
- * clearly indicates an unclosed / incomplete construct; otherwise fall
- * back to the generic message.
+ * Prefer a relocated "Missing …" closer when the token stream supports it;
+ * otherwise refine the generic {@code Unexpected input: ...} fallback
+ * (reserved keyword, sole expected punctuation, unexpected EOF).
+ * Locate/refine run in a defensive try; a single listener dispatch is
+ * outside so a listener {@link IllegalArgumentException} /
+ * {@link IndexOutOfBoundsException} is not mistaken for a lookup failure
+ * and re-dispatched.
*/
private void reportFriendlyError(final Parser recognizer, final RecognitionException e, final String fallbackMessage) {
- MissingDelimiterDiagnostic.Hit hit = null;
+ Token at = e.getOffendingToken();
+ String message;
+ try {
+ // Incomplete / synthetic contexts can leave token indices out of range,
+ // and getExpectedTokens() can reject an invalid ATN state number.
+ MissingDelimiterDiagnostic.Hit hit = MissingDelimiterDiagnostic.locate(recognizer.getInputStream(), e);
+ if (hit != null) {
+ at = hit.at;
+ message = hit.message;
+ } else {
+ message = refineFallbackMessage(e, fallbackMessage);
+ }
+ } catch (IndexOutOfBoundsException | IllegalArgumentException ignored) {
+ message = fallbackMessage;
+ }
+ recognizer.notifyErrorListeners(at, message, e);
+ }
+
+ /**
+ * Improve a generic mismatch/NVAE sentence without moving the caret.
+ * Precedence: reserved/misplaced keyword, then a leading {@code ?[}
+ * without a path to attach to, then a singleton expected punctuation
+ * token, then unexpected EOF, then {@code generic}.
+ */
+ static String refineFallbackMessage(final RecognitionException e, final String generic) {
+ if (e == null) {
+ return generic;
+ }
+ Token offending = e.getOffendingToken();
+ if (offending != null) {
+ String keyword = keywordMessage(offending.getType());
+ if (keyword != null) {
+ return keyword;
+ }
+ String misplacedDefault = misplacedDefaultClause(e, offending);
+ if (misplacedDefault != null) {
+ return misplacedDefault;
+ }
+ String safeIndex = unexpectedSafeIndex(e, offending);
+ if (safeIndex != null) {
+ return safeIndex;
+ }
+ }
+ String sole = soleExpectedMessage(e);
+ if (sole != null) {
+ return sole;
+ }
+ if (offending != null && offending.getType() == Token.EOF) {
+ return "Unexpected end of input";
+ }
+ return generic;
+ }
+
+ /**
+ * javac wording for reserved keywords Groovy tokenises but does not
+ * implement, and for control-flow keywords that appear as the offending
+ * token. Like javac, the sentence names the keyword even when a related
+ * construct is nearby but incomplete ({@code if (x) else {}} is
+ * {@code 'else' without 'if'} because the then-branch is missing).
+ * {@code const} is reserved and unused in Java (JLS 3.9); the replacements
+ * are {@code val} (locals; preferred over {@code final} in Groovy 6) and
+ * {@code static final} (class constants). {@code threadsafe} is reserved
+ * and unused in Groovy. {@code default} itself is <em>not</em> mapped
+ * here: it is a valid interface-method and annotation-element keyword, so
+ * an offending {@code default} (for example {@code def m() default {1}})
+ * is not "outside of switch". {@code default:} / {@code default ->}
+ * outside a switch is recognised via {@link #misplacedDefaultClause}.
+ */
+ static String keywordMessage(final int tokenType) {
+ return switch (tokenType) {
+ case CONST -> "'const' is not supported; use 'val' or 'static final' instead";
+ case GOTO -> "'goto' is not supported";
+ case THREADSAFE -> "'threadsafe' is not supported";
+ case ELSE -> "'else' without 'if'";
+ case CATCH -> "'catch' without 'try'";
+ case FINALLY -> "'finally' without 'try'";
+ case CASE -> "'case' outside of switch";
+ default -> null;
+ };
+ }
+
+ /**
+ * {@code default: x} / {@code default -> x} at script or method scope:
+ * ANTLR's offending token is the {@code :} or {@code ->}, so
+ * {@link #keywordMessage(int)} on the offender is silent. Look one
+ * default-channel token back; skip {@code NL} so a line break between
+ * {@code default} and the clause marker still names {@code default}.
+ * Restricted to those two markers so {@code interface I { default }}
+ * (offender {@code }}) is not mislabelled.
+ */
+ static String misplacedDefaultClause(final RecognitionException e, final Token offending) {
+ if (e == null || offending == null) {
+ return null;
+ }
+ int type = offending.getType();
+ if (type != COLON && type != ARROW) {
+ return null;
+ }
+ if (!(e.getInputStream() instanceof TokenStream tokens)) {
+ return null;
+ }
+ int index = offending.getTokenIndex();
+ if (index < 1) {
+ return null;
+ }
try {
- // Incomplete / synthetic contexts can leave token indices out of range.
- hit = MissingDelimiterDiagnostic.locate(recognizer.getInputStream(), e);
+ for (int i = index - 1; i >= 0; i--) {
+ Token prev = tokens.get(i);
+ int prevType = prev.getType();
+ if (prevType == NL || prev.getChannel() != Token.DEFAULT_CHANNEL) {
+ continue;
+ }
+ return prevType == DEFAULT ? "'default' outside of switch" : null;
+ }
} catch (IndexOutOfBoundsException | IllegalArgumentException ignored) {
- // Fall through to the generic message. Catch only locate()'s known
- // defensive failures — never listener-side fatals (e.g. addFatalError).
+ return null;
+ }
+ return null;
+ }
+
+ /**
+ * {@code ?[} is a single token (Groovy 4 safe index) and only appears as
+ * {@code indexPropertyArgs}, a {@code pathElement}. It cannot start a
+ * primary. Name a missing receiver rather than dumping a generic
+ * {@code Unexpected input} span.
+ * <p>
+ * {@code indexPropertyArgs} has no leading {@code NL*}, so a visible
+ * newline before {@code ?[} is a statement break, not trivia. Hidden-channel
+ * tokens (comments; newlines inside parens) are skipped. Leave the generic
+ * sentence only when the previous token can end a path — a primary /
+ * {@code pathElement} / type-argument closer, or a {@code namePart} after
+ * a member-selection operator ({@code foo.if?[0)} still reports
+ * {@code Missing ']'}).
+ * </p>
+ */
+ static String unexpectedSafeIndex(final RecognitionException e, final Token offending) {
+ if (e == null || offending == null || offending.getType() != SAFE_INDEX) {
+ return null;
}
- if (hit != null) {
- recognizer.notifyErrorListeners(hit.at, hit.message, e);
- return;
+ if (!(e.getInputStream() instanceof TokenStream tokens)) {
+ return SAFE_INDEX_NEEDS_RECEIVER;
}
- notifyErrorListeners(recognizer, fallbackMessage, e);
+ int index = offending.getTokenIndex();
+ if (index < 1) {
+ return SAFE_INDEX_NEEDS_RECEIVER;
+ }
+ try {
+ Token prev = previousDefaultChannel(tokens, index, false);
+ if (prev == null) {
+ return SAFE_INDEX_NEEDS_RECEIVER;
+ }
+ int prevType = prev.getType();
+ if (canEndPathExpression(prevType)) {
+ return null;
+ }
+ // foo.?[ — the selector itself is not a receiver
+ if (isMemberSelection(prevType)) {
+ return SAFE_INDEX_NEEDS_RECEIVER;
+ }
+ // foo.if?[ — a namePart after a selector is a receiver
+ Token before = previousDefaultChannel(tokens, prev.getTokenIndex(), true);
+ if (before != null && isMemberSelection(before.getType())) {
+ return null;
+ }
+ return SAFE_INDEX_NEEDS_RECEIVER;
+ } catch (IndexOutOfBoundsException | IllegalArgumentException ignored) {
+ return SAFE_INDEX_NEEDS_RECEIVER;
+ }
+ }
+
+ /**
+ * Last default-channel token before {@code startExclusive}, or {@code null}.
+ * {@code skipNl} is for {@code DOT NL* namePart}; {@code indexPropertyArgs}
+ * does not skip newlines.
+ */
+ private static Token previousDefaultChannel(final TokenStream tokens, final int startExclusive,
+ final boolean skipNl) {
+ for (int i = startExclusive - 1; i >= 0; i--) {
+ Token t = tokens.get(i);
+ if (t.getChannel() != Token.DEFAULT_CHANNEL || (skipNl && t.getType() == NL)) {
+ continue;
+ }
+ return t;
+ }
+ return null;
+ }
+
+ /**
+ * Last token of a {@code primary}, {@code pathElement}, or
+ * {@code typeArguments} — the things {@code ?[} can attach to.
+ */
+ static boolean canEndPathExpression(final int type) {
+ return switch (type) {
+ case Identifier, CapitalizedIdentifier,
+ IntegerLiteral, FloatingPointLiteral, StringLiteral,
+ BooleanLiteral, NullLiteral, GStringEnd,
+ RPAREN, RBRACK, RBRACE,
+ THIS, SUPER,
+ BuiltInPrimitiveType, VOID,
+ GT -> true;
+ default -> false;
+ };
+ }
+
+ private static boolean isMemberSelection(final int type) {
+ return switch (type) {
+ case DOT, SAFE_DOT, SPREAD_DOT, SAFE_CHAIN_DOT,
+ METHOD_POINTER, METHOD_REFERENCE, AT -> true;
+ default -> false;
+ };
+ }
+
+ /**
+ * When ANTLR's expected set is a single punctuation token, name it
+ * (javac: {@code '(' expected} → {@code Missing '('}). Closers are omitted:
+ * {@link MissingDelimiterDiagnostic} already decides those with a depth check,
+ * so a balanced {@code [1,,2]} is not reported as {@code Missing ']'}.
+ */
+ static String soleExpectedMessage(final RecognitionException e) {
+ if (e == null) {
+ return null;
+ }
+ final IntervalSet expected;
+ try {
+ expected = e.getExpectedTokens();
+ } catch (IllegalArgumentException | IndexOutOfBoundsException ignored) {
+ // ATN.getExpectedTokens rejects an invalid offending state.
+ return null;
+ }
+ if (expected == null) {
+ return null;
+ }
+ int sole = soleNonNlToken(expected);
+ if (sole == Integer.MIN_VALUE) {
+ return null;
+ }
+ return soleExpectedMessage(sole);
+ }
+
+ /**
+ * The single expected token once optional newlines are ignored, or
+ * {@link Integer#MIN_VALUE} if that is not a singleton. Groovy inserts
+ * {@code NL} into many expected sets as trivia, so {@code WHILE | NL}
+ * is treated as the singleton {@code WHILE}.
+ */
+ static int soleNonNlToken(final IntervalSet expected) {
+ if (expected == null) {
+ return Integer.MIN_VALUE;
+ }
+ int found = Integer.MIN_VALUE;
+ int count = 0;
+ for (int t : expected.toArray()) {
+ if (t == NL) {
+ continue;
+ }
+ count++;
+ found = t;
+ if (count > 1) {
+ return Integer.MIN_VALUE;
+ }
+ }
+ return count == 1 ? found : Integer.MIN_VALUE;
+ }
+
+ static String soleExpectedMessage(final int tokenType) {
+ return switch (tokenType) {
+ case LPAREN -> "Missing '('";
+ case LBRACK -> "Missing '['";
+ case LBRACE -> "Missing '{'";
+ case COLON -> "Missing ':'";
+ case SEMI -> "Missing ';'";
+ case GT -> "Missing '>'";
+ case WHILE -> "Missing 'while'";
+ default -> null;
+ };
}
protected String createNoViableAlternativeErrorMessage(final Parser recognizer, final NoViableAltException e) {
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
index 1da2c21fbd..2c35a228be 100644
--- a/src/main/java/org/apache/groovy/parser/antlr4/internal/MissingDelimiterDiagnostic.java
+++ b/src/main/java/org/apache/groovy/parser/antlr4/internal/MissingDelimiterDiagnostic.java
@@ -141,35 +141,55 @@ final class MissingDelimiterDiagnostic {
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 {
+ CloserSpec closer = soleCloser(expected);
+ if (closer == null) {
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) {
+ if (closer.depth(defaultChannel) == 0) {
+ return null;
+ }
+ // The parser's sole expected closer may belong to an *outer* family
+ // (`(a?[0` expected `)` while the innermost opener is `?[`). Defer to
+ // the delimiter stack so the inner `]` is named first.
+ if (!closer.matchesInner(innermostOpenType(defaultChannel))) {
return null;
}
+ return hitAtOffending(e, defaultChannel, closer.message);
+ }
+
+ /**
+ * The single expected closer, or {@code null} if the expected set is not
+ * exactly one of {@code ) } / {@code ] } / {@code } }.
+ */
+ private static CloserSpec soleCloser(final IntervalSet expected) {
+ if (expected.contains(RPAREN)) {
+ return new CloserSpec(MSG_RPAREN, true, false, false);
+ }
+ if (expected.contains(RBRACK)) {
+ return new CloserSpec(MSG_RBRACK, false, true, false);
+ }
+ if (expected.contains(RBRACE)) {
+ return new CloserSpec(MSG_RBRACE, false, false, true);
+ }
+ return null;
+ }
+ /**
+ * Caret on the offender, or just past the last real token when the
+ * offender is EOF (whose line/column often sit on the following empty
+ * line after a trailing newline).
+ */
+ private static Hit hitAtOffending(final RecognitionException e,
+ final List<Token> defaultChannel,
+ final String message) {
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) {
@@ -179,6 +199,26 @@ final class MissingDelimiterDiagnostic {
return new Hit(message, offending);
}
+ /**
+ * One closer family: the diagnostic sentence and which delimiter depths
+ * to count. {@code ?[} is the same opener family as {@code [}.
+ */
+ private record CloserSpec(String message, boolean parens, boolean brackets, boolean braces) {
+ int depth(final List<Token> tokens) {
+ return delimiterDepth(tokens, parens, brackets, braces);
+ }
+
+ boolean matchesInner(final int inner) {
+ if (parens) {
+ return inner == LPAREN;
+ }
+ if (brackets) {
+ return isOpenBracket(inner);
+ }
+ return inner == LBRACE;
+ }
+ }
+
/**
* Net open depth for the requested delimiter family at end of stream.
* Families are counted independently so a missing {@code ')'} is not
@@ -208,6 +248,27 @@ final class MissingDelimiterDiagnostic {
return depth;
}
+ /**
+ * Type of the innermost still-open delimiter, or {@code 0} if none.
+ * Used so a sole expected {@code ')'} does not hide an inner unclosed
+ * {@code ?[} / {@code [} (Groovy 4 safe index).
+ */
+ private static int innermostOpenType(final List<Token> tokens) {
+ Deque<Integer> stack = new ArrayDeque<>();
+ for (Token t : tokens) {
+ int type = t.getType();
+ if (type == Token.EOF) {
+ break;
+ }
+ if (isOpenDelimiter(type)) {
+ stack.push(type);
+ } else if (isCloseDelimiter(type) && !stack.isEmpty() && closes(stack.peek(), type)) {
+ stack.pop();
+ }
+ }
+ return stack.isEmpty() ? 0 : stack.peek();
+ }
+
/**
* Last non-EOF, non-NL token. Skipping NL keeps the caret on the last
* meaningful line (same idea as {@link #fromDelimiterStack}) instead of
@@ -271,8 +332,9 @@ final class MissingDelimiterDiagnostic {
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) {
+ // if they remain, skip rather than false-positive. SAFE_INDEX is
+ // the Groovy 4 {@code ?[} opener, the same family as {@code [}.
+ if (nt == DOT || nt == LT || nt == LBRACK || nt == SAFE_INDEX) {
continue;
}
if (primitiveType || isHighConfidenceCastOperand(nt)) {
diff --git a/src/main/java/org/apache/groovy/parser/antlr4/internal/package-info.java b/src/main/java/org/apache/groovy/parser/antlr4/internal/package-info.java
index adfc5e5381..eefadf3498 100644
--- a/src/main/java/org/apache/groovy/parser/antlr4/internal/package-info.java
+++ b/src/main/java/org/apache/groovy/parser/antlr4/internal/package-info.java
@@ -20,6 +20,13 @@
/**
* Internal utilities for the ANTLR4 (Parrot) parser: ATN management, friendly
* error strategies ({@link DescriptiveErrorStrategy} fail-fast,
- * {@link RecoveringDescriptiveErrorStrategy} multi-error), and related diagnostics.
+ * {@link RecoveringDescriptiveErrorStrategy} multi-error), and related
+ * diagnostics ({@link MissingDelimiterDiagnostic} for relocated missing-closer
+ * carets; fallback wording lives on {@link AbstractFriendlyErrorStrategy},
+ * including reserved keywords, a leading {@code ?[} without a path to
+ * attach to, and a singleton expected-punctuation token after optional
+ * newlines are ignored). Reporting is one listener dispatch
+ * after locate/refine. Diagnostic helpers run only after recognition has
+ * already failed so they do not affect steady-state parse performance.
*/
package org.apache.groovy.parser.antlr4.internal;
diff --git a/src/spec/doc/core-operators.adoc b/src/spec/doc/core-operators.adoc
index 944a164325..2f201a04bc 100644
--- a/src/spec/doc/core-operators.adoc
+++ b/src/spec/doc/core-operators.adoc
@@ -838,6 +838,8 @@ personInfo?['name'] = 'sunlan' // quietly ignore attempt to set
assert null == personInfo?['name']
--------------------------------------
+`?[` is a single token: do not put a space between `?` and `[` (`a? [0]` is an incomplete ternary, not a safe index). An unclosed `?[` is reported as `Missing ']'`, including when the safe index sits inside parentheses (`(a?[0`). `?[` cannot start an expression: `?[0]`, `foo(?[0])`, and `x = ?[0]` are reported as `'?[' requires an expression before it`.
+
=== Membership operator
diff --git a/src/spec/doc/core-syntax.adoc b/src/spec/doc/core-syntax.adoc
index e826b6fd39..56837f5fe0 100644
--- a/src/spec/doc/core-syntax.adoc
+++ b/src/spec/doc/core-syntax.adoc
@@ -125,6 +125,12 @@ include::../test/SyntaxTest.groovy[tags=reserved_keywords,indent=0]
|===
Of these, `const`, `goto`, `strictfp`, and `threadsafe` are not currently in use.
+Using `const`, `goto`, or `threadsafe` is a compilation error: the diagnostic
+names the reserved keyword and, for `const`, points at `val` (locals; preferred
+over `final` in Groovy 6) or `static final` (class constants). A `default:` or
+`default ->` clause outside a `switch` is reported as `'default' outside of switch`.
+Unexpected Unicode spaces (for example a no-break space copied from a word
+processor) are named with a `\u` escape rather than an empty-looking glyph.
The reserved keywords can't in general be used for variable, field and method names.
diff --git a/src/main/java/org/apache/groovy/parser/antlr4/internal/package-info.java b/src/test-resources/core/Comment_01x.groovy
similarity index 74%
copy from src/main/java/org/apache/groovy/parser/antlr4/internal/package-info.java
copy to src/test-resources/core/Comment_01x.groovy
index adfc5e5381..9696eb985e 100644
--- a/src/main/java/org/apache/groovy/parser/antlr4/internal/package-info.java
+++ b/src/test-resources/core/Comment_01x.groovy
@@ -17,9 +17,18 @@
* under the License.
*/
-/**
- * Internal utilities for the ANTLR4 (Parrot) parser: ATN management, friendly
- * error strategies ({@link DescriptiveErrorStrategy} fail-fast,
- * {@link RecoveringDescriptiveErrorStrategy} multi-error), and related diagnostics.
- */
-package org.apache.groovy.parser.antlr4.internal;
+/* leading */
+def x = 1
+assert x == 1
+
+/** groovydoc-looking */
+def y = 2
+assert y == 2
+
+/* inner /* is just text */
+assert true
+
+/* empty closer follows */
+/**/
+def z = 3
+assert z == 3
diff --git a/src/test/groovy/gls/generics/GenericsUsageTest.groovy b/src/test/groovy/gls/generics/GenericsUsageTest.groovy
index 194b97bafa..c7c5117ea7 100644
--- a/src/test/groovy/gls/generics/GenericsUsageTest.groovy
+++ b/src/test/groovy/gls/generics/GenericsUsageTest.groovy
@@ -193,7 +193,7 @@ final class GenericsUsageTest {
void testCompilationWithMissingClosingBracketsInGenerics() {
shouldFailCompilationWithMessage '''
def list1 = new ArrayList<Integer()
- ''', "Unexpected input: '('"
+ ''', "Missing '>'"
shouldFailCompilationWithMessage '''
List<Integer list2 = new ArrayList<Integer>()
@@ -210,7 +210,7 @@ final class GenericsUsageTest {
shouldFailCompilationWithMessage '''
abstract class ArrayList1<E extends AbstractList<E> implements List<E> {}
- ''', "Unexpected input: 'implements'"
+ ''', "Missing '>'"
shouldFailCompilationWithMessage '''
abstract class ArrayList2<E> extends AbstractList<E implements List<E> {}
@@ -226,7 +226,7 @@ final class GenericsUsageTest {
shouldFailCompilationWithMessage '''
def List<List<Integer>> history = new ArrayList<List<Integer>()
- ''', "Unexpected input: '('"
+ ''', "Missing '>'"
}
// GROOVY-3975
diff --git a/src/test/groovy/org/apache/groovy/parser/antlr4/CommonSyntaxErrorTest.groovy b/src/test/groovy/org/apache/groovy/parser/antlr4/CommonSyntaxErrorTest.groovy
new file mode 100644
index 0000000000..285c3218c4
--- /dev/null
+++ b/src/test/groovy/org/apache/groovy/parser/antlr4/CommonSyntaxErrorTest.groovy
@@ -0,0 +1,373 @@
+/*
+ * 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
+
+import groovy.transform.AutoFinal
+import org.junit.jupiter.api.Test
+
+import static org.apache.groovy.parser.antlr4.TestUtils.expectContains
+import static org.apache.groovy.parser.antlr4.TestUtils.expectContainsOnce
+import static org.apache.groovy.parser.antlr4.TestUtils.expectParseError
+
+/**
+ * Message-quality catalog for the javac-aligned diagnostics added around
+ * unclosed literals, escaped unexpected characters (Unicode spaces, curly
+ * quotes), missing punctuation, and reserved keywords.
+ * <p>
+ * Caret-level contracts for missing {@code ) } / {@code ] } / {@code } },
+ * GString {@code $}, unexpected-character glyphs, and declaration-shape
+ * errors stay in {@link SyntaxErrorTest}. This class locks the <em>new</em>
+ * sentences that are not already dumped there.
+ * </p>
+ */
+@AutoFinal
+final class CommonSyntaxErrorTest {
+
+ @Test
+ void 'unclosed single-quoted string'() {
+ expectParseError '''\
+ |println 'Hello
+ |'''.stripMargin(), '''\
+ |Unclosed string literal @ line 1, column 9.
+ | println 'Hello
+ | ^
+ |
+ |1 error
+ |'''.stripMargin()
+ }
+
+ @Test
+ void 'unclosed double-quoted string'() {
+ expectParseError '''\
+ |println "Hello
+ |'''.stripMargin(), '''\
+ |Unclosed string literal @ line 1, column 9.
+ | println "Hello
+ | ^
+ |
+ |1 error
+ |'''.stripMargin()
+ }
+
+ @Test
+ void 'unclosed triple-quoted string'() {
+ expectContains "s = '''hello", 'Unclosed string literal'
+ }
+
+ @Test
+ void 'unclosed comment'() {
+ expectParseError '''\
+ |/* comment
+ |'''.stripMargin(), '''\
+ |Unclosed comment @ line 1, column 1.
+ | /* comment
+ | ^
+ |
+ |1 error
+ |'''.stripMargin()
+ }
+
+ @Test
+ void 'unclosed comment after a statement'() {
+ expectParseError '''\
+ |def x = 1
+ |/* still open
+ |'''.stripMargin(), '''\
+ |Unclosed comment @ line 2, column 1.
+ | /* still open
+ | ^
+ |
+ |1 error
+ |'''.stripMargin()
+ }
+
+ @Test
+ void 'zero-width space is shown as a unicode escape'() {
+ expectParseError "def \u200Bname = null\n", '''\
+ |Unexpected character: '\\u200b' @ line 1, column 5.
+ | def \u200Bname = null
+ | ^
+ |
+ |1 error
+ |'''.stripMargin()
+ }
+
+ @Test
+ void 'form feed is shown as \\f'() {
+ expectParseError "def na\u000Cme = null\n", '''\
+ |Unexpected character: '\\f' @ line 1, column 7.
+ | def na\u000Cme = null
+ | ^
+ |
+ |1 error
+ |'''.stripMargin()
+ }
+
+ @Test
+ void 'NUL is shown as a unicode escape'() {
+ expectContains 'def x\u0000 = 1', "Unexpected character: '\\u0000'"
+ }
+
+ @Test
+ void 'BOM is shown as a unicode escape'() {
+ expectContains "\uFEFFdef x = 1", "Unexpected character: '\\ufeff'"
+ }
+
+ @Test
+ void 'printable unexpected character is shown as itself'() {
+ expectContains 'def `name` = 1', "Unexpected character: '`'"
+ }
+
+ @Test
+ void 'no-break space is shown as a unicode escape'() {
+ expectContains "def \u00A0x = 1", "Unexpected character: '\\u00a0'"
+ }
+
+ @Test
+ void 'curly quote is shown as a unicode escape'() {
+ expectContains "println \u2018hello", "Unexpected character: '\\u2018'"
+ }
+
+ @Test
+ void 'em dash is shown as a unicode escape'() {
+ expectContains "def x = 1\u20142", "Unexpected character: '\\u2014'"
+ }
+
+ @Test
+ void 'if without parentheses'() {
+ expectParseError '''\
+ |if true { x = 1 }
+ |'''.stripMargin(), '''\
+ |Missing '(' @ line 1, column 4.
+ | if true { x = 1 }
+ | ^
+ |
+ |1 error
+ |'''.stripMargin()
+ }
+
+ @Test
+ void 'while without parentheses'() {
+ expectContains 'while true { x = 1 }', "Missing '('"
+ }
+
+ @Test
+ void 'for without parentheses'() {
+ expectContains 'for int i in 1..2 {}', "Missing '('"
+ }
+
+ @Test
+ void 'incomplete ternary missing colon'() {
+ expectParseError 'x ? y', '''\
+ |Missing ':' @ line 1, column 6.
+ | x ? y
+ | ^
+ |
+ |1 error
+ |'''.stripMargin()
+ }
+
+ @Test
+ void 'generic type missing closing angle'() {
+ expectContains 'def list1 = new ArrayList<Integer()', "Missing '>'"
+ }
+
+ @Test
+ void 'const is not supported'() {
+ expectParseError '''\
+ |const x = 1
+ |'''.stripMargin(), '''\
+ |'const' is not supported; use 'val' or 'static final' instead @ line 1, column 1.
+ | const x = 1
+ | ^
+ |
+ |1 error
+ |'''.stripMargin()
+ // Same sentence in class and method bodies: the token has no parse
+ // context cheap enough to pick one replacement, so both are named.
+ expectContains 'class C { const x = 1 }', "'const' is not supported; use 'val' or 'static final' instead"
+ expectContains 'def m() { const x = 1 }', "'const' is not supported; use 'val' or 'static final' instead"
+ }
+
+ @Test
+ void 'goto is not supported'() {
+ expectParseError '''\
+ |goto label
+ |'''.stripMargin(), '''\
+ |'goto' is not supported @ line 1, column 1.
+ | goto label
+ | ^
+ |
+ |1 error
+ |'''.stripMargin()
+ }
+
+ @Test
+ void 'else after if with no then-branch still names else'() {
+ expectContains 'if (x) else { }', "'else' without 'if'"
+ }
+
+ @Test
+ void 'else without if'() {
+ expectContains 'else { x = 1 }', "'else' without 'if'"
+ }
+
+ @Test
+ void 'catch without try'() {
+ expectContains 'catch (e) { }', "'catch' without 'try'"
+ }
+
+ @Test
+ void 'finally without try'() {
+ expectContains 'finally { }', "'finally' without 'try'"
+ }
+
+ @Test
+ void 'case outside of switch'() {
+ expectContains 'case 1: x = 1', "'case' outside of switch"
+ }
+
+ @Test
+ void 'default colon outside of switch'() {
+ expectContains 'default: x = 1', "'default' outside of switch"
+ }
+
+ @Test
+ void 'default arrow outside of switch'() {
+ expectContains 'default -> x', "'default' outside of switch"
+ }
+
+ @Test
+ void 'incomplete interface default method is not labelled as outside of switch'() {
+ String msg = TestUtils.compileMessage('interface I { default }')
+ assert !msg.contains("'default' outside of switch"), msg
+ }
+
+ @Test
+ void 'threadsafe is not supported'() {
+ expectContains 'threadsafe foo() {}', "'threadsafe' is not supported"
+ expectContains 'threadsafe', "'threadsafe' is not supported"
+ }
+
+ @Test
+ void 'throw with nothing after it'() {
+ expectContains 'throw', 'Unexpected end of input'
+ }
+
+ @Test
+ void 'import static with nothing after it'() {
+ expectContains 'import static', 'Unexpected end of input'
+ }
+
+ @Test
+ void 'number ending with underscore'() {
+ expectContainsOnce 'def n = 1_', 'Number ending with underscores is invalid'
+ }
+
+ @Test
+ void 'invalid octal number'() {
+ expectContainsOnce 'def n = 08', 'Invalid octal number'
+ }
+
+ @Test
+ void 'shebang not on the first line'() {
+ expectContainsOnce 'x = 1\n#!/usr/bin/env groovy', 'Shebang comment should appear at the first line'
+ }
+
+ @Test
+ void 'varargs must be the last parameter and names it'() {
+ expectContains 'def m(int... a, int b) {}', 'The var-arg parameter a must be the last parameter'
+ }
+
+ // Groovy 4 safe index: '?[' is one token and pairs with ']'
+
+ @Test
+ void 'unclosed empty safe index'() {
+ expectParseError '''\
+ |a?[
+ |'''.stripMargin(), '''\
+ |Missing ']' @ line 1, column 4.
+ | a?[
+ | ^
+ |
+ |1 error
+ |'''.stripMargin()
+ }
+
+ @Test
+ void 'safe index mismatched closer is still missing bracket'() {
+ expectContains 'a?[0)', "Missing ']'"
+ expectContains 'a?[0}', "Missing ']'"
+ }
+
+ @Test
+ void 'nested and chained unclosed safe index name the inner bracket'() {
+ expectContains 'a?[b?[0', "Missing ']'"
+ expectContains 'a?[0]?[1', "Missing ']'"
+ expectContains 'a[b?[0', "Missing ']'"
+ }
+
+ @Test
+ void 'safe index inside parens still names the missing bracket'() {
+ expectContains '(a?[0', "Missing ']'"
+ expectContains '(a[0', "Missing ']'"
+ expectContains 'foo(a?[0', "Missing ']'"
+ }
+
+ @Test
+ void 'safe index without a receiver'() {
+ expectContains '?[0]', "'?[' requires an expression before it"
+ }
+
+ @Test
+ void 'safe index after open paren is not a receiver'() {
+ expectContains 'foo(?[0])', "'?[' requires an expression before it"
+ }
+
+ @Test
+ void 'safe index after assignment is not a receiver'() {
+ expectContains 'x = ?[0]', "'?[' requires an expression before it"
+ }
+
+ @Test
+ void 'safe index after a lone dot is not a receiver'() {
+ expectContains 'foo.?[0]', "'?[' requires an expression before it"
+ }
+
+ @Test
+ void 'safe index after a newline is not a path continuation'() {
+ expectContains 'a\n?[0]', "'?[' requires an expression before it"
+ }
+
+ @Test
+ void 'question then safe index is not a receiver'() {
+ expectContains 'a??[0]', "'?[' requires an expression before it"
+ }
+
+ @Test
+ void 'keyword after a dot is still a safe-index receiver'() {
+ expectContains 'foo.if?[0)', "Missing ']'"
+ }
+
+ @Test
+ void 'space between question and bracket is ternary not safe index'() {
+ // '?[' is one token; `a? [0]` tokenises as `a` `?` `[0]` (incomplete ternary)
+ expectContains 'a? [0]', "Missing ':'"
+ }
+}
diff --git a/src/test/groovy/org/apache/groovy/parser/antlr4/GroovyParserTest.groovy b/src/test/groovy/org/apache/groovy/parser/antlr4/GroovyParserTest.groovy
index 3ca7c7b28c..707f786ae7 100644
--- a/src/test/groovy/org/apache/groovy/parser/antlr4/GroovyParserTest.groovy
+++ b/src/test/groovy/org/apache/groovy/parser/antlr4/GroovyParserTest.groovy
@@ -488,6 +488,11 @@ final class GroovyParserTest {
doRunAndTestAntlr4('core/Val_01x.groovy')
}
+ @Test
+ void 'groovy core - block comments'() {
+ doRunAndTestAntlr4('core/Comment_01x.groovy')
+ }
+
@Test
void 'groovy core - var'() {
doRunAndTestAntlr4('core/Var_01x.groovy')
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 06373e374e..66c8365e40 100644
--- a/src/test/groovy/org/apache/groovy/parser/antlr4/SyntaxErrorTest.groovy
+++ b/src/test/groovy/org/apache/groovy/parser/antlr4/SyntaxErrorTest.groovy
@@ -20,11 +20,9 @@ package org.apache.groovy.parser.antlr4
import groovy.test.NotYetImplemented
import groovy.transform.AutoFinal
-import org.codehaus.groovy.control.CompilationUnit
-import org.codehaus.groovy.control.Phases
-import org.junit.jupiter.api.Assertions
import org.junit.jupiter.api.Test
+import static org.apache.groovy.parser.antlr4.TestUtils.expectParseError
import static org.apache.groovy.parser.antlr4.util.ASTComparatorCategory.LOCATION_IGNORE_LIST
/**
@@ -83,12 +81,12 @@ final class SyntaxErrorTest {
TestUtils.doRunAndShouldFail('fail/UnexpectedCharacter_01x.groovy')
}
- @Test // TODO: Could the character be escaped in the error message?
+ @Test
void 'groovy core - UnexpectedCharacter 2'() {
expectParseError '''\
|def \u200Bname = null
|'''.stripMargin(), '''\
- |Unexpected character: '\u200B' @ line 1, column 5.
+ |Unexpected character: '\\u200b' @ line 1, column 5.
| def \u200Bname = null
| ^
|
@@ -98,7 +96,7 @@ final class SyntaxErrorTest {
expectParseError '''\
|def na\u200Bme = null
|'''.stripMargin(), '''\
- |Unexpected character: '\u200B' @ line 1, column 7.
+ |Unexpected character: '\\u200b' @ line 1, column 7.
| def na\u200Bme = null
| ^
|
@@ -108,7 +106,7 @@ final class SyntaxErrorTest {
expectParseError '''\
|def na\u000Cme = null
|'''.stripMargin(), '''\
- |Unexpected character: '\u000C' @ line 1, column 7.
+ |Unexpected character: '\\f' @ line 1, column 7.
| def na\u000Cme = null
| ^
|
@@ -123,7 +121,7 @@ final class SyntaxErrorTest {
| println 'Hello
|}
|'''.stripMargin(), '''\
- |Unexpected character: '\\'' @ line 2, column 11.
+ |Unclosed string literal @ line 2, column 11.
| println 'Hello
| ^
|
@@ -966,20 +964,6 @@ final class SyntaxErrorTest {
//--------------------------------------------------------------------------
- private static void expectParseError(String source, String expect) {
- try {
- new CompilationUnit().with {
- addSource('test.groovy', source)
- compile(Phases.CONVERSION)
- getAST()
- }
- Assertions.fail('expected parse to fail')
- } catch (e) {
- def line = (expect =~ /@ line (\d+),/)[0][1]
- Assertions.assertEquals("startup failed:\ntest.groovy: $line: $expect".toString(), e.message.replace('\r\n', '\n'))
- }
- }
-
private static unzipScriptAndShouldFail(String entryName, List ignoreClazzList, Map<String, String> replacementsMap = [:], boolean toCheckNewParserOnly = false) {
ignoreClazzList.addAll(TestUtils.COMMON_IGNORE_CLASS_LIST)
diff --git a/src/test/groovy/org/apache/groovy/parser/antlr4/TestUtils.groovy b/src/test/groovy/org/apache/groovy/parser/antlr4/TestUtils.groovy
index 8056ffb57c..8ff84f9523 100644
--- a/src/test/groovy/org/apache/groovy/parser/antlr4/TestUtils.groovy
+++ b/src/test/groovy/org/apache/groovy/parser/antlr4/TestUtils.groovy
@@ -41,11 +41,14 @@ package org.apache.groovy.parser.antlr4
import org.codehaus.groovy.ast.stmt.ReturnStatement
import org.codehaus.groovy.ast.stmt.ThrowStatement
import org.codehaus.groovy.ast.stmt.WhileStatement
+ import org.codehaus.groovy.control.CompilationUnit
import org.codehaus.groovy.control.CompilerConfiguration
import org.codehaus.groovy.control.ParserPlugin
import org.codehaus.groovy.control.ParserPluginFactory
+ import org.codehaus.groovy.control.Phases
import org.codehaus.groovy.control.SourceUnit
import org.codehaus.groovy.syntax.Token
+ import org.junit.jupiter.api.Assertions
import java.util.logging.Level
import java.util.zip.ZipEntry
@@ -109,6 +112,57 @@ final class TestUtils {
// }
}
+ /**
+ * Compile {@code source} through CONVERSION and return the diagnostic
+ * text. Fails the test if the source parses. Shared by
+ * {@code SyntaxErrorTest} and {@code CommonSyntaxErrorTest}.
+ */
+ @CompileDynamic
+ static String compileMessage(String source) {
+ try {
+ new CompilationUnit().with {
+ addSource('test.groovy', source)
+ compile(Phases.CONVERSION)
+ getAST()
+ }
+ Assertions.fail('expected parse to fail')
+ return ''
+ } catch (e) {
+ return (e.message ?: e.toString()).replace('\r\n', '\n')
+ }
+ }
+
+ /**
+ * Compile {@code source} through CONVERSION and assert the full
+ * {@code startup failed:} diagnostic matches {@code expect} (which must
+ * contain {@code @ line N,}).
+ */
+ @CompileDynamic
+ static void expectParseError(String source, String expect) {
+ def line = (expect =~ /@ line (\d+),/)[0][1]
+ Assertions.assertEquals("startup failed:\ntest.groovy: $line: $expect".toString(), compileMessage(source))
+ }
+
+ /**
+ * Compile {@code source} through CONVERSION and assert the diagnostic
+ * contains {@code fragment}.
+ */
+ static void expectContains(String source, String fragment) {
+ String msg = compileMessage(source)
+ Assertions.assertTrue(msg.contains(fragment), "expected '$fragment' in:\n$msg")
+ }
+
+ /**
+ * Like {@link #expectContains} and also require {@code @ line } to appear
+ * once (no duplicated position from {@code toAttachPositionInfo}).
+ */
+ @CompileDynamic
+ static void expectContainsOnce(String source, String fragment) {
+ String msg = compileMessage(source)
+ Assertions.assertTrue(msg.contains(fragment), "expected '$fragment' in:\n$msg")
+ Assertions.assertEquals(1, (msg =~ /@ line /).count, "position must appear once, got: $msg")
+ }
+
/*
static void unzipAndTest(String path, String entryName) {
unzipAndTest(path, entryName, ASTComparatorCategory.DEFAULT_CONFIGURATION)
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
index 88dba5f9c1..ef62c5d847 100644
--- a/src/test/groovy/org/apache/groovy/parser/antlr4/internal/MissingDelimiterDiagnosticTest.groovy
+++ b/src/test/groovy/org/apache/groovy/parser/antlr4/internal/MissingDelimiterDiagnosticTest.groovy
@@ -427,6 +427,44 @@ final class MissingDelimiterDiagnosticTest {
assertEquals "Missing ']'", hit.message
}
+ @Test
+ void 'safe-index inside parens names the inner bracket not the paren'() {
+ // strategy 1 may see a sole expected ')'; innermost opener is '?['
+ def tokens = tokenStream('(a?[0')
+ def hit = MissingDelimiterDiagnostic.locate(tokens, stubException(setOf(RPAREN), lastToken(tokens)))
+ assertNotNull hit
+ assertEquals "Missing ']'", hit.message
+ }
+
+ @Test
+ void 'regular index inside parens names the inner bracket not the paren'() {
+ def tokens = tokenStream('(a[0')
+ def hit = MissingDelimiterDiagnostic.locate(tokens, stubException(setOf(RPAREN), lastToken(tokens)))
+ assertNotNull hit
+ assertEquals "Missing ']'", hit.message
+ }
+
+ @Test
+ void 'empty unclosed safe index reports Missing bracket'() {
+ def hit = MissingDelimiterDiagnostic.locate(tokenStream('a?['), null)
+ assertNotNull hit
+ assertEquals "Missing ']'", hit.message
+ }
+
+ @Test
+ void 'safe-index mismatched paren closer reports Missing bracket'() {
+ def hit = MissingDelimiterDiagnostic.locate(tokenStream('a?[0)'), null)
+ assertNotNull hit
+ assertEquals "Missing ']'", hit.message
+ }
+
+ @Test
+ void 'cast pattern does not treat safe index as a cast operand'() {
+ def hit = MissingDelimiterDiagnostic.locate(tokenStream('(int?[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)
diff --git a/src/test/java/org/apache/groovy/parser/antlr4/AbstractLexerTest.java b/src/test/java/org/apache/groovy/parser/antlr4/AbstractLexerTest.java
new file mode 100644
index 0000000000..c917865611
--- /dev/null
+++ b/src/test/java/org/apache/groovy/parser/antlr4/AbstractLexerTest.java
@@ -0,0 +1,236 @@
+/*
+ * 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;
+
+import org.antlr.v4.runtime.CharStreams;
+import org.antlr.v4.runtime.Token;
+import org.junit.jupiter.api.Test;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Branch coverage for {@link AbstractLexer} unexpected-character diagnostics
+ * and {@link AbstractLexer#getCharErrorDisplay(int)}. End-to-end messages live
+ * in {@code CommonSyntaxErrorTest}.
+ */
+final class AbstractLexerTest {
+
+ @Test
+ void unexpectedCharacterMessageNullOrEmpty() {
+ assertEquals("Unexpected character", AbstractLexer.unexpectedCharacterMessage(null));
+ assertEquals("Unexpected character", AbstractLexer.unexpectedCharacterMessage(""));
+ }
+
+ @Test
+ void unexpectedQuoteIsUnclosedString() {
+ assertEquals("Unclosed string literal", AbstractLexer.unexpectedCharacterMessage("'"));
+ assertEquals("Unclosed string literal", AbstractLexer.unexpectedCharacterMessage("\""));
+ assertEquals("Unclosed string literal", AbstractLexer.unexpectedCharacterMessage("'''"));
+ }
+
+ @Test
+ void unexpectedPrintableStaysAsGlyph() {
+ assertEquals("Unexpected character: '`'", AbstractLexer.unexpectedCharacterMessage("`"));
+ assertEquals("Unexpected character: '#'", AbstractLexer.unexpectedCharacterMessage("#"));
+ assertEquals("Unexpected character: '😀'", AbstractLexer.unexpectedCharacterMessage("😀"));
+ }
+
+ @Test
+ void getCharErrorDisplayNamedEscapes() {
+ GroovyLangLexer lexer = displayLexer();
+ assertEquals("'\\b'", lexer.getCharErrorDisplay('\b'));
+ assertEquals("'\\t'", lexer.getCharErrorDisplay('\t'));
+ assertEquals("'\\n'", lexer.getCharErrorDisplay('\n'));
+ assertEquals("'\\f'", lexer.getCharErrorDisplay('\f'));
+ assertEquals("'\\r'", lexer.getCharErrorDisplay('\r'));
+ assertEquals("'\\''", lexer.getCharErrorDisplay('\''));
+ assertEquals("'\\\\'", lexer.getCharErrorDisplay('\\'));
+ assertEquals("'<EOF>'", lexer.getCharErrorDisplay(Token.EOF));
+ }
+
+ @Test
+ void getCharErrorDisplayUnicodeEscapesForInvisible() {
+ GroovyLangLexer lexer = displayLexer();
+ assertEquals("'\\u0000'", lexer.getCharErrorDisplay(0));
+ assertEquals("'\\u007f'", lexer.getCharErrorDisplay(0x7F));
+ assertEquals("'\\u200b'", lexer.getCharErrorDisplay(0x200B));
+ assertEquals("'\\ufeff'", lexer.getCharErrorDisplay(0xFEFF));
+ assertEquals("'\\u2028'", lexer.getCharErrorDisplay(0x2028));
+ assertEquals("'\\u2029'", lexer.getCharErrorDisplay(0x2029));
+ assertEquals("'\\ud800'", lexer.getCharErrorDisplay(0xD800));
+ assertEquals("'\\ue000'", lexer.getCharErrorDisplay(0xE000));
+ assertEquals("'\\u0378'", lexer.getCharErrorDisplay(0x0378));
+ assertEquals("'\\u00ad'", lexer.getCharErrorDisplay(0x00AD));
+ assertEquals("'\\u00a0'", lexer.getCharErrorDisplay(0x00A0));
+ assertEquals("'\\u2000'", lexer.getCharErrorDisplay(0x2000));
+ assertEquals("'\\u202f'", lexer.getCharErrorDisplay(0x202F));
+ assertEquals("'\\u2018'", lexer.getCharErrorDisplay(0x2018));
+ assertEquals("'\\u2019'", lexer.getCharErrorDisplay(0x2019));
+ assertEquals("'\\u201c'", lexer.getCharErrorDisplay(0x201C));
+ assertEquals("'\\u201d'", lexer.getCharErrorDisplay(0x201D));
+ assertEquals("'\\u2013'", lexer.getCharErrorDisplay(0x2013));
+ assertEquals("'\\u2014'", lexer.getCharErrorDisplay(0x2014));
+ assertEquals("'\\u0080'", lexer.getCharErrorDisplay(0x80));
+ }
+
+ @Test
+ void getCharErrorDisplaySupplementaryPrivateUseAsUtf16Escapes() {
+ assertEquals("'\\udb80\\udc00'", displayLexer().getCharErrorDisplay(0xF0000));
+ }
+
+ @Test
+ void getCharErrorDisplayPrintableGlyphs() {
+ GroovyLangLexer lexer = displayLexer();
+ assertEquals("'A'", lexer.getCharErrorDisplay('A'));
+ assertEquals("'`'", lexer.getCharErrorDisplay('`'));
+ assertEquals("' '", lexer.getCharErrorDisplay(' '));
+ assertEquals("'-'", lexer.getCharErrorDisplay('-'));
+ assertEquals("'😀'", lexer.getCharErrorDisplay("😀".codePointAt(0)));
+ }
+
+ @Test
+ void unexpectedCharacterMessageEscapesNbspAndCurlyQuote() {
+ assertEquals("Unexpected character: '\\u00a0'",
+ AbstractLexer.unexpectedCharacterMessage("\u00A0"));
+ assertEquals("Unexpected character: '\\u2018'",
+ AbstractLexer.unexpectedCharacterMessage("\u2018"));
+ assertEquals("Unexpected character: '\\u2014'",
+ AbstractLexer.unexpectedCharacterMessage("\u2014"));
+ }
+
+ @Test
+ void errorIgnoredUnexpectedCharacterTokenizesWithoutThrowing() {
+ GroovyLangLexer lexer = new GroovyLangLexer(CharStreams.fromString("`"));
+ lexer.setErrorIgnored(true);
+ Token t = lexer.nextToken();
+ assertTrue(t.getType() > 0);
+ assertEquals(Token.EOF, lexer.nextToken().getType());
+ }
+
+ @Test
+ void lexerThrowsGroovySyntaxErrorForUnexpectedCharacter() {
+ GroovyLangLexer lexer = new GroovyLangLexer(CharStreams.fromString("`"));
+ GroovySyntaxError err = assertThrows(GroovySyntaxError.class, lexer::nextToken);
+ assertEquals("Unexpected character: '`'", err.getMessage());
+ assertEquals(GroovySyntaxError.LEXER, err.getSource());
+ }
+
+ @Test
+ void lexerThrowsUnclosedStringForLoneQuote() {
+ GroovyLangLexer lexer = new GroovyLangLexer(CharStreams.fromString("'hello"));
+ GroovySyntaxError err = assertThrows(GroovySyntaxError.class, () -> drain(lexer));
+ assertEquals("Unclosed string literal", err.getMessage());
+ }
+
+ @Test
+ void requireUnclosedCommentPointsAtOpener() {
+ GroovyLangLexer lexer = new GroovyLangLexer(CharStreams.fromString("/* comment"));
+ GroovySyntaxError err = assertThrows(GroovySyntaxError.class, () -> drain(lexer));
+ assertEquals("Unclosed comment", err.getMessage());
+ assertEquals(1, err.getLine());
+ assertEquals(1, err.getColumn());
+ }
+
+ @Test
+ void errorIgnoredUnclosedCommentTokenizesWithoutThrowing() {
+ GroovyLangLexer lexer = new GroovyLangLexer(CharStreams.fromString("/* comment"));
+ lexer.setErrorIgnored(true);
+ List<Token> tokens = assertDoesNotThrow(() -> collect(lexer));
+ assertEquals(Token.EOF, tokens.get(tokens.size() - 1).getType());
+ assertTrue(tokens.size() >= 2, tokens.toString());
+ }
+
+ @Test
+ void requireUnclosedCommentPointsAtIndentedOpener() {
+ GroovyLangLexer lexer = new GroovyLangLexer(CharStreams.fromString(" /* comment"));
+ GroovySyntaxError err = assertThrows(GroovySyntaxError.class, () -> drain(lexer));
+ assertEquals("Unclosed comment", err.getMessage());
+ assertEquals(1, err.getLine());
+ assertEquals(5, err.getColumn());
+ }
+
+ @Test
+ void closedBlockCommentDoesNotConsumeTrailingSource() {
+ List<Token> tokens = collect("/* ok */\ndef x = 1\n");
+ assertEquals("/* ok */", tokens.get(0).getText());
+ assertTrue(texts(tokens).contains("def"), texts(tokens).toString());
+ assertTrue(texts(tokens).contains("x"), texts(tokens).toString());
+ assertEquals(Token.EOF, tokens.get(tokens.size() - 1).getType());
+ }
+
+ @Test
+ void closedBlockCommentStopsAtFirstCloser() {
+ List<Token> tokens = collect("/* a /* nested */ def x = 1\n");
+ assertEquals("/* a /* nested */", tokens.get(0).getText());
+ assertTrue(texts(tokens).contains("def"), texts(tokens).toString());
+ assertFalse(texts(tokens).get(0).contains("def"), tokens.get(0).getText());
+ }
+
+ @Test
+ void emptyAndGroovydocBlockCommentsLeaveFollowingSource() {
+ List<Token> tokens = collect("/**/\n/** groovydoc */\ndef y = 2\n");
+ assertEquals("/**/", tokens.get(0).getText());
+ assertTrue(texts(tokens).contains("def"), texts(tokens).toString());
+ assertTrue(texts(tokens).contains("y"), texts(tokens).toString());
+ }
+
+ @Test
+ void closedBlockCommentAtEofDoesNotThrow() {
+ List<Token> tokens = collect("/* ok */");
+ assertEquals("/* ok */", tokens.get(0).getText());
+ assertEquals(Token.EOF, tokens.get(tokens.size() - 1).getType());
+ }
+
+ private static GroovyLangLexer displayLexer() {
+ return new GroovyLangLexer(CharStreams.fromString("x"));
+ }
+
+ private static void drain(GroovyLangLexer lexer) {
+ collect(lexer);
+ }
+
+ private static List<Token> collect(final String src) {
+ return collect(new GroovyLangLexer(CharStreams.fromString(src)));
+ }
+
+ private static List<Token> collect(final GroovyLangLexer lexer) {
+ List<Token> tokens = new ArrayList<>();
+ Token t;
+ do {
+ t = lexer.nextToken();
+ tokens.add(t);
+ } while (t.getType() != Token.EOF);
+ return tokens;
+ }
+
+ private static List<String> texts(final List<Token> tokens) {
+ List<String> texts = new ArrayList<>(tokens.size());
+ for (Token t : tokens) {
+ texts.add(t.getText());
+ }
+ return texts;
+ }
+}
diff --git a/src/test/java/org/apache/groovy/parser/antlr4/internal/ErrorStrategyTest.java b/src/test/java/org/apache/groovy/parser/antlr4/internal/ErrorStrategyTest.java
index eb3cb94763..46269de935 100644
--- a/src/test/java/org/apache/groovy/parser/antlr4/internal/ErrorStrategyTest.java
+++ b/src/test/java/org/apache/groovy/parser/antlr4/internal/ErrorStrategyTest.java
@@ -35,11 +35,14 @@ import org.antlr.v4.runtime.atn.AbstractPredicateTransition;
import org.antlr.v4.runtime.atn.ATN;
import org.antlr.v4.runtime.atn.ATNState;
import org.antlr.v4.runtime.atn.PredictionMode;
+import org.antlr.v4.runtime.misc.IntervalSet;
import org.antlr.v4.runtime.misc.ParseCancellationException;
import org.apache.groovy.parser.antlr4.GroovyLangLexer;
import org.apache.groovy.parser.antlr4.GroovyLangParser;
import org.apache.groovy.parser.antlr4.GroovyParser;
import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
import java.util.ArrayList;
import java.util.List;
@@ -48,6 +51,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
@@ -121,8 +125,7 @@ final class ErrorStrategyTest {
() -> strategy.recover(parser, nvae));
assertInstanceOf(NoViableAltException.class, pce.getCause());
assertFalse(messages.isEmpty(), "LL recover must report before cancel");
- assertTrue(messages.stream().anyMatch(m ->
- m.contains("Unexpected input") || m.startsWith("Missing ")),
+ assertTrue(messages.stream().anyMatch(ErrorStrategyTest::isNonAntlrJargon),
"friendly diagnostic expected, got: " + messages);
}
@@ -140,8 +143,7 @@ final class ErrorStrategyTest {
() -> strategy.recover(parser, cause));
assertInstanceOf(InputMismatchException.class, pce.getCause());
assertFalse(messages.isEmpty(), "LL IME recover must report before cancel: " + messages);
- assertTrue(messages.stream().anyMatch(m ->
- m.contains("Unexpected input") || m.startsWith("Missing ")),
+ assertTrue(messages.stream().anyMatch(ErrorStrategyTest::isNonAntlrJargon),
"friendly diagnostic expected, got: " + messages);
}
@@ -323,8 +325,42 @@ final class ErrorStrategyTest {
strategy.exposeReportInputMismatch(parser, ime);
assertFalse(messages.isEmpty(), "fallback report must still notify: " + messages);
- assertTrue(messages.stream().anyMatch(m -> m.contains("Unexpected input")),
- "generic fallback expected, got: " + messages);
+ assertTrue(messages.stream().anyMatch(ErrorStrategyTest::isNonAntlrJargon),
+ "fallback report must still notify with a diagnostic, got: " + messages);
+ }
+
+ @Test
+ void reportFriendlyErrorDoesNotRedispatchWhenListenerThrows() {
+ // Single dispatch is outside the defensive catch: a listener IAE must
+ // propagate, not be treated as a locate/refine failure and retried.
+ var charStream = CharStreams.fromString("x");
+ var strategy = new StrategyProbe(charStream);
+ var messages = new ArrayList<String>();
+ var tokens = new CommonTokenStream(new GroovyLangLexer(charStream));
+ tokens.fill();
+ var parser = new GroovyLangParser(tokens);
+ parser.setErrorHandler(strategy);
+ parser.removeErrorListeners();
+ parser.addErrorListener(new BaseErrorListener() {
+ private int calls;
+
+ @Override
+ public <T extends Token> void syntaxError(Recognizer<T, ?> recognizer, T offendingSymbol, int line,
+ int charPositionInLine, String msg, RecognitionException e) {
+ calls++;
+ messages.add(msg);
+ if (calls == 1) {
+ throw new IllegalArgumentException("listener");
+ }
+ }
+ });
+
+ Token tok = tokens.get(0);
+ var nvae = new NoViableAltException(parser, tokens, tok, tok, null, parser.getContext());
+ IllegalArgumentException thrown = assertThrows(IllegalArgumentException.class,
+ () -> strategy.exposeReportNoViable(parser, nvae));
+ assertEquals("listener", thrown.getMessage());
+ assertEquals(1, messages.size(), "listener must not be invoked a second time, got: " + messages);
}
//--------------------------------------------------------------------------
@@ -362,8 +398,397 @@ final class ErrorStrategyTest {
assertTrue(parser.getInputStream().index() >= indexBefore);
}
+ /**
+ * Strategy reporting must not leak ANTLR's stock jargon onto the listener.
+ * The exact sentence is locked by {@code CommonSyntaxErrorTest} / delimiter tests.
+ */
+ private static boolean isNonAntlrJargon(final String m) {
+ return !m.contains("no viable alternative") && !m.startsWith("mismatched input");
+ }
+
+ @Test
+ void refineFallbackPrefersKeywordOverSoleExpected() {
+ Token elseTok = token(GroovyParser.ELSE, "else");
+ RecognitionException e = stubException(setOf(GroovyParser.LPAREN), elseTok);
+ assertEquals("'else' without 'if'",
+ AbstractFriendlyErrorStrategy.refineFallbackMessage(e, "Unexpected input: 'else'"));
+ }
+
+ @Test
+ void refineFallbackNamesSoleExpectedPunctuation() {
+ Token trueTok = token(GroovyParser.BooleanLiteral, "true");
+ RecognitionException e = stubException(setOf(GroovyParser.LPAREN), trueTok);
+ assertEquals("Missing '('",
+ AbstractFriendlyErrorStrategy.refineFallbackMessage(e, "Unexpected input: 'true'"));
+ assertEquals("Missing ':'", AbstractFriendlyErrorStrategy.soleExpectedMessage(GroovyParser.COLON));
+ assertEquals("Missing '>'", AbstractFriendlyErrorStrategy.soleExpectedMessage(GroovyParser.GT));
+ assertEquals("Missing 'while'", AbstractFriendlyErrorStrategy.soleExpectedMessage(GroovyParser.WHILE));
+ assertEquals("Missing '['", AbstractFriendlyErrorStrategy.soleExpectedMessage(GroovyParser.LBRACK));
+ assertEquals("Missing '{'", AbstractFriendlyErrorStrategy.soleExpectedMessage(GroovyParser.LBRACE));
+ assertEquals("Missing ';'", AbstractFriendlyErrorStrategy.soleExpectedMessage(GroovyParser.SEMI));
+ assertNull(AbstractFriendlyErrorStrategy.soleExpectedMessage(GroovyParser.RPAREN));
+ assertNull(AbstractFriendlyErrorStrategy.soleExpectedMessage(GroovyParser.Identifier));
+ }
+
+ @Test
+ void refineFallbackUnexpectedEof() {
+ Token eof = token(Token.EOF, "<EOF>");
+ RecognitionException e = stubException(new IntervalSet(), eof);
+ assertEquals("Unexpected end of input",
+ AbstractFriendlyErrorStrategy.refineFallbackMessage(e, "Unexpected input: '<EOF>'"));
+ }
+
+ @Test
+ void refineFallbackEofWithSoleColonPrefersMissingColon() {
+ Token eof = token(Token.EOF, "<EOF>");
+ RecognitionException e = stubException(setOf(GroovyParser.COLON), eof);
+ assertEquals("Missing ':'",
+ AbstractFriendlyErrorStrategy.refineFallbackMessage(e, "Unexpected input: '<EOF>'"));
+ }
+
+ @Test
+ void refineFallbackNullExceptionKeepsGeneric() {
+ assertEquals("generic", AbstractFriendlyErrorStrategy.refineFallbackMessage(null, "generic"));
+ assertNull(AbstractFriendlyErrorStrategy.soleExpectedMessage((RecognitionException) null));
+ }
+
+ @Test
+ void refineFallbackSwallowsInvalidExpectedTokensState() {
+ RecognitionException e = new RecognitionException(null, null, null) {
+ @Override
+ public IntervalSet getExpectedTokens() {
+ throw new IllegalArgumentException("Invalid state number.");
+ }
+
+ @Override
+ public Token getOffendingToken() {
+ return token(GroovyParser.Identifier, "x");
+ }
+ };
+ assertNull(AbstractFriendlyErrorStrategy.soleExpectedMessage(e));
+ assertEquals("generic", AbstractFriendlyErrorStrategy.refineFallbackMessage(e, "generic"));
+ }
+
+ @Test
+ void refineFallbackIgnoresMultiTokenExpectedSet() {
+ IntervalSet multi = setOf(GroovyParser.LPAREN, GroovyParser.COLON);
+ Token x = token(GroovyParser.Identifier, "x");
+ RecognitionException e = stubException(multi, x);
+ assertEquals("Unexpected input: 'x'",
+ AbstractFriendlyErrorStrategy.refineFallbackMessage(e, "Unexpected input: 'x'"));
+ assertNull(AbstractFriendlyErrorStrategy.soleExpectedMessage(stubException(null, x)));
+ }
+
+ @Test
+ void keywordMessages() {
+ assertEquals("'const' is not supported; use 'val' or 'static final' instead",
+ AbstractFriendlyErrorStrategy.keywordMessage(GroovyParser.CONST));
+ assertEquals("'goto' is not supported",
+ AbstractFriendlyErrorStrategy.keywordMessage(GroovyParser.GOTO));
+ assertEquals("'threadsafe' is not supported",
+ AbstractFriendlyErrorStrategy.keywordMessage(GroovyParser.THREADSAFE));
+ assertEquals("'else' without 'if'",
+ AbstractFriendlyErrorStrategy.keywordMessage(GroovyParser.ELSE));
+ assertEquals("'catch' without 'try'",
+ AbstractFriendlyErrorStrategy.keywordMessage(GroovyParser.CATCH));
+ assertEquals("'finally' without 'try'",
+ AbstractFriendlyErrorStrategy.keywordMessage(GroovyParser.FINALLY));
+ assertEquals("'case' outside of switch",
+ AbstractFriendlyErrorStrategy.keywordMessage(GroovyParser.CASE));
+ assertNull(AbstractFriendlyErrorStrategy.keywordMessage(GroovyParser.DEFAULT),
+ "default as offender is a method/annotation keyword, not 'outside of switch'");
+ assertNull(AbstractFriendlyErrorStrategy.keywordMessage(GroovyParser.Identifier));
+ }
+
+ @Test
+ void refineFallbackIgnoresNlWhenSoleExpectedRemains() {
+ Token eof = token(Token.EOF, "<EOF>");
+ RecognitionException e = stubException(setOf(GroovyParser.WHILE, GroovyParser.NL), eof);
+ assertEquals("Missing 'while'",
+ AbstractFriendlyErrorStrategy.refineFallbackMessage(e, "Unexpected input: '<EOF>'"));
+ assertEquals(GroovyParser.WHILE,
+ AbstractFriendlyErrorStrategy.soleNonNlToken(setOf(GroovyParser.WHILE, GroovyParser.NL)));
+ assertEquals(Integer.MIN_VALUE,
+ AbstractFriendlyErrorStrategy.soleNonNlToken(setOf(GroovyParser.NL)));
+ assertEquals(Integer.MIN_VALUE,
+ AbstractFriendlyErrorStrategy.soleNonNlToken(setOf(GroovyParser.WHILE, GroovyParser.LPAREN)));
+ assertEquals(GroovyParser.LPAREN,
+ AbstractFriendlyErrorStrategy.soleNonNlToken(setOf(GroovyParser.LPAREN)));
+ assertEquals(Integer.MIN_VALUE,
+ AbstractFriendlyErrorStrategy.soleNonNlToken(new IntervalSet()));
+ assertEquals(Integer.MIN_VALUE,
+ AbstractFriendlyErrorStrategy.soleNonNlToken(null));
+ }
+
+ @Test
+ void refineFallbackNullOffendingStillUsesSoleExpected() {
+ RecognitionException e = stubException(setOf(GroovyParser.LPAREN), null);
+ assertEquals("Missing '('",
+ AbstractFriendlyErrorStrategy.refineFallbackMessage(e, "generic"));
+ assertNull(AbstractFriendlyErrorStrategy.misplacedDefaultClause(e, null));
+ assertNull(AbstractFriendlyErrorStrategy.misplacedDefaultClause(null, token(GroovyParser.COLON, ":")));
+ }
+
+ @Test
+ void refineFallbackSwallowsIndexOutOfBoundsFromExpectedTokens() {
+ RecognitionException e = new RecognitionException(null, null, null) {
+ @Override
+ public IntervalSet getExpectedTokens() {
+ throw new IndexOutOfBoundsException("test");
+ }
+
+ @Override
+ public Token getOffendingToken() {
+ return token(GroovyParser.Identifier, "x");
+ }
+ };
+ assertNull(AbstractFriendlyErrorStrategy.soleExpectedMessage(e));
+ assertEquals("generic", AbstractFriendlyErrorStrategy.refineFallbackMessage(e, "generic"));
+ }
+
+ @Test
+ void refineFallbackDefaultColonLooksBack() {
+ var charStream = CharStreams.fromString("default: x = 1");
+ var tokens = new CommonTokenStream(new GroovyLangLexer(charStream));
+ tokens.fill();
+ Token colon = tokens.get(1);
+ assertEquals(GroovyParser.COLON, colon.getType());
+ RecognitionException e = stubException(new IntervalSet(), colon, tokens);
+ assertEquals("'default' outside of switch",
+ AbstractFriendlyErrorStrategy.refineFallbackMessage(e, "Unexpected input: ':'"));
+ }
+
+ @Test
+ void refineFallbackDefaultArrowLooksBack() {
+ var charStream = CharStreams.fromString("default -> x");
+ var tokens = new CommonTokenStream(new GroovyLangLexer(charStream));
+ tokens.fill();
+ Token arrow = null;
+ for (int i = 0; i < tokens.size(); i++) {
+ if (tokens.get(i).getType() == GroovyParser.ARROW) {
+ arrow = tokens.get(i);
+ break;
+ }
+ }
+ assertNotNull(arrow);
+ RecognitionException e = stubException(new IntervalSet(), arrow, tokens);
+ assertEquals("'default' outside of switch",
+ AbstractFriendlyErrorStrategy.refineFallbackMessage(e, "Unexpected input: '->'"));
+ }
+
+ @Test
+ void refineFallbackColonAfterIdentifierIsNotDefaultClause() {
+ var charStream = CharStreams.fromString("label: x = 1");
+ var tokens = new CommonTokenStream(new GroovyLangLexer(charStream));
+ tokens.fill();
+ Token colon = tokens.get(1);
+ RecognitionException e = stubException(new IntervalSet(), colon, tokens);
+ assertEquals("generic",
+ AbstractFriendlyErrorStrategy.refineFallbackMessage(e, "generic"));
+ assertNull(AbstractFriendlyErrorStrategy.misplacedDefaultClause(e, colon));
+ }
+
+ @Test
+ void misplacedDefaultClauseSkipsNewlineBetweenDefaultAndColon() {
+ var charStream = CharStreams.fromString("default\n: x = 1");
+ var tokens = new CommonTokenStream(new GroovyLangLexer(charStream));
+ tokens.fill();
+ Token colon = null;
+ for (int i = 0; i < tokens.size(); i++) {
+ if (tokens.get(i).getType() == GroovyParser.COLON) {
+ colon = tokens.get(i);
+ break;
+ }
+ }
+ assertNotNull(colon);
+ RecognitionException e = stubException(new IntervalSet(), colon, tokens);
+ assertEquals("'default' outside of switch",
+ AbstractFriendlyErrorStrategy.misplacedDefaultClause(e, colon));
+ }
+
+ @Test
+ void misplacedDefaultClauseIgnoresNonColonOffender() {
+ Token brace = token(GroovyParser.RBRACE, "}");
+ RecognitionException e = stubException(new IntervalSet(), brace);
+ assertNull(AbstractFriendlyErrorStrategy.misplacedDefaultClause(e, brace));
+ }
+
+ @Test
+ void misplacedDefaultClauseNullStream() {
+ CommonToken colon = token(GroovyParser.COLON, ":");
+ colon.setTokenIndex(1);
+ RecognitionException e = stubException(new IntervalSet(), colon);
+ assertNull(AbstractFriendlyErrorStrategy.misplacedDefaultClause(e, colon));
+ }
+
+ @Test
+ void misplacedDefaultClauseIgnoresUnindexedToken() {
+ CommonToken colon = token(GroovyParser.COLON, ":");
+ colon.setTokenIndex(0);
+ var charStream = CharStreams.fromString("default: x");
+ var tokens = new CommonTokenStream(new GroovyLangLexer(charStream));
+ tokens.fill();
+ RecognitionException e = stubException(new IntervalSet(), colon, tokens);
+ assertNull(AbstractFriendlyErrorStrategy.misplacedDefaultClause(e, colon));
+ }
+
+ @Test
+ void unexpectedSafeIndexWithoutReceiver() {
+ var charStream = CharStreams.fromString("?[0]");
+ var tokens = new CommonTokenStream(new GroovyLangLexer(charStream));
+ tokens.fill();
+ Token safe = tokens.get(0);
+ assertEquals(GroovyParser.SAFE_INDEX, safe.getType());
+ RecognitionException e = stubException(new IntervalSet(), safe, tokens);
+ assertEquals("'?[' requires an expression before it",
+ AbstractFriendlyErrorStrategy.refineFallbackMessage(e, "Unexpected input: '?['"));
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = {
+ "foo(?[0])",
+ "x = ?[0]",
+ "x+?[0]",
+ "foo.?[0]",
+ "a?.?[0]",
+ "a*.?[0]",
+ "a??.?[0]",
+ "a.&?[0]",
+ "a::?[0]",
+ "a.@?[0]",
+ "a\n?[0]",
+ "/*c*/?[0]",
+ "if?[0]",
+ "a??[0]"
+ })
+ void unexpectedSafeIndexAfterNonReceiverIsAMissingReceiver(final String src) {
+ assertSafeIndexMessage(src);
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = {
+ "a?[0,]",
+ "foo.if?[0,]",
+ "foo.\nif?[0,]",
+ "foo?.if?[0,]",
+ "(a)?[0,]",
+ "a[0]?[1,]",
+ "{1}?[0,]",
+ "a<b>?[0,]",
+ "Foo?[0,]",
+ "1?[0,]",
+ "1.0?[0,]",
+ "'s'?[0,]",
+ "true?[0,]",
+ "null?[0,]",
+ "this?[0,]",
+ "super?[0,]",
+ "int?[0,]",
+ "void?[0,]",
+ "\"$x\"?[0,]"
+ })
+ void unexpectedSafeIndexAfterPathEndingTokenIsNotAMissingReceiver(final String src) {
+ var tokens = lex(src);
+ Token safe = requireSafeIndex(tokens);
+ assertNull(AbstractFriendlyErrorStrategy.unexpectedSafeIndex(
+ stubException(new IntervalSet(), safe, tokens), safe), src);
+ }
+
+ @Test
+ void unexpectedSafeIndexPreviousTokenCanBeGt() {
+ var tokens = lex("a<b>?[0,]");
+ Token safe = requireSafeIndex(tokens);
+ // relational or type-argument closer; either way GT can end a path
+ assertEquals(GroovyParser.GT, tokens.get(safe.getTokenIndex() - 1).getType());
+ }
+
+ @Test
+ void canEndPathExpressionCoversPrimariesAndClosers() {
+ assertTrue(AbstractFriendlyErrorStrategy.canEndPathExpression(GroovyParser.Identifier));
+ assertTrue(AbstractFriendlyErrorStrategy.canEndPathExpression(GroovyParser.CapitalizedIdentifier));
+ assertTrue(AbstractFriendlyErrorStrategy.canEndPathExpression(GroovyParser.IntegerLiteral));
+ assertTrue(AbstractFriendlyErrorStrategy.canEndPathExpression(GroovyParser.FloatingPointLiteral));
+ assertTrue(AbstractFriendlyErrorStrategy.canEndPathExpression(GroovyParser.StringLiteral));
+ assertTrue(AbstractFriendlyErrorStrategy.canEndPathExpression(GroovyParser.BooleanLiteral));
+ assertTrue(AbstractFriendlyErrorStrategy.canEndPathExpression(GroovyParser.NullLiteral));
+ assertTrue(AbstractFriendlyErrorStrategy.canEndPathExpression(GroovyParser.GStringEnd));
+ assertTrue(AbstractFriendlyErrorStrategy.canEndPathExpression(GroovyParser.RPAREN));
+ assertTrue(AbstractFriendlyErrorStrategy.canEndPathExpression(GroovyParser.RBRACK));
+ assertTrue(AbstractFriendlyErrorStrategy.canEndPathExpression(GroovyParser.RBRACE));
+ assertTrue(AbstractFriendlyErrorStrategy.canEndPathExpression(GroovyParser.THIS));
+ assertTrue(AbstractFriendlyErrorStrategy.canEndPathExpression(GroovyParser.SUPER));
+ assertTrue(AbstractFriendlyErrorStrategy.canEndPathExpression(GroovyParser.BuiltInPrimitiveType));
+ assertTrue(AbstractFriendlyErrorStrategy.canEndPathExpression(GroovyParser.VOID));
+ assertTrue(AbstractFriendlyErrorStrategy.canEndPathExpression(GroovyParser.GT));
+ assertFalse(AbstractFriendlyErrorStrategy.canEndPathExpression(GroovyParser.LPAREN));
+ assertFalse(AbstractFriendlyErrorStrategy.canEndPathExpression(GroovyParser.ASSIGN));
+ assertFalse(AbstractFriendlyErrorStrategy.canEndPathExpression(GroovyParser.QUESTION));
+ assertFalse(AbstractFriendlyErrorStrategy.canEndPathExpression(GroovyParser.NL));
+ assertFalse(AbstractFriendlyErrorStrategy.canEndPathExpression(GroovyParser.DOT));
+ assertFalse(AbstractFriendlyErrorStrategy.canEndPathExpression(GroovyParser.SAFE_INDEX));
+ }
+
+ @Test
+ void unexpectedSafeIndexNulls() {
+ assertNull(AbstractFriendlyErrorStrategy.unexpectedSafeIndex(null, token(GroovyParser.SAFE_INDEX, "?[")));
+ assertNull(AbstractFriendlyErrorStrategy.unexpectedSafeIndex(stubException(new IntervalSet(), null), null));
+ Token ident = token(GroovyParser.Identifier, "a");
+ assertNull(AbstractFriendlyErrorStrategy.unexpectedSafeIndex(stubException(new IntervalSet(), ident), ident));
+ CommonToken safe = token(GroovyParser.SAFE_INDEX, "?[");
+ safe.setTokenIndex(0);
+ assertEquals("'?[' requires an expression before it",
+ AbstractFriendlyErrorStrategy.unexpectedSafeIndex(stubException(new IntervalSet(), safe), safe));
+ }
+
+ @Test
+ void unexpectedSafeIndexSwallowsStreamFailure() {
+ CommonToken safe = token(GroovyParser.SAFE_INDEX, "?[");
+ safe.setTokenIndex(1);
+ RecognitionException e = stubException(new IntervalSet(), safe, new ThrowingTokenStream());
+ assertEquals("'?[' requires an expression before it",
+ AbstractFriendlyErrorStrategy.unexpectedSafeIndex(e, safe));
+ RecognitionException e2 = stubException(new IntervalSet(), safe,
+ new ThrowingTokenStream(new IllegalArgumentException("test")));
+ assertEquals("'?[' requires an expression before it",
+ AbstractFriendlyErrorStrategy.unexpectedSafeIndex(e2, safe));
+ }
+
+ @Test
+ void misplacedDefaultClauseSwallowsStreamFailure() {
+ CommonToken colon = token(GroovyParser.COLON, ":");
+ colon.setTokenIndex(1);
+ RecognitionException e = stubException(new IntervalSet(), colon, new ThrowingTokenStream());
+ assertNull(AbstractFriendlyErrorStrategy.misplacedDefaultClause(e, colon));
+ RecognitionException e2 = stubException(new IntervalSet(), colon,
+ new ThrowingTokenStream(new IllegalArgumentException("test")));
+ assertNull(AbstractFriendlyErrorStrategy.misplacedDefaultClause(e2, colon));
+ }
+
// --- helpers ----------------------------------------------------------------
+ private static void assertSafeIndexMessage(final String src) {
+ var tokens = lex(src);
+ Token safe = requireSafeIndex(tokens);
+ RecognitionException e = stubException(new IntervalSet(), safe, tokens);
+ assertEquals("'?[' requires an expression before it",
+ AbstractFriendlyErrorStrategy.unexpectedSafeIndex(e, safe));
+ }
+
+ private static Token requireSafeIndex(final CommonTokenStream tokens) {
+ for (int i = 0; i < tokens.size(); i++) {
+ if (tokens.get(i).getType() == GroovyParser.SAFE_INDEX) {
+ return tokens.get(i);
+ }
+ }
+ throw new AssertionError("no SAFE_INDEX in: " + tokens.getText());
+ }
+
+ private static CommonTokenStream lex(final String src) {
+ var tokens = new CommonTokenStream(new GroovyLangLexer(CharStreams.fromString(src)));
+ tokens.fill();
+ return tokens;
+ }
+
private static GroovyLangParser parser(org.antlr.v4.runtime.CharStream charStream,
org.antlr.v4.runtime.ANTLRErrorStrategy strategy,
PredictionMode mode) {
@@ -436,6 +861,39 @@ final class ErrorStrategyTest {
}
}
+ private static CommonToken token(int type, String text) {
+ CommonToken t = new CommonToken(type, text);
+ t.setLine(1);
+ t.setCharPositionInLine(0);
+ return t;
+ }
+
+ private static IntervalSet setOf(int... types) {
+ IntervalSet set = new IntervalSet();
+ for (int type : types) {
+ set.add(type);
+ }
+ return set;
+ }
+
+ private static RecognitionException stubException(IntervalSet expected, Token offending) {
+ return stubException(expected, offending, null);
+ }
+
+ private static RecognitionException stubException(IntervalSet expected, Token offending, TokenStream input) {
+ return new RecognitionException(null, input, null) {
+ @Override
+ public IntervalSet getExpectedTokens() {
+ return expected;
+ }
+
+ @Override
+ public Token getOffendingToken() {
+ return offending;
+ }
+ };
+ }
+
/**
* Parser whose {@link #getInputStream()} is null so NVAE messaging hits the
* {@code <unknown input>} branch when the strategy reads the stream from the parser.