This is an automated email from the ASF dual-hosted git repository.

asf-gitbox-commits pushed a commit to branch GROOVY-12171
in repository https://gitbox.apache.org/repos/asf/groovy.git

commit 9045217216e252e6527f193a3ae8160e8395973a
Author: Daniel Sun <[email protected]>
AuthorDate: Sat Jul 18 10:58:51 2026 +0900

    GROOVY-12171: Improve GString syntax error when '$' is not followed by a 
valid interpolation
---
 .../groovy/parser/antlr4/GroovyLangLexer.java      |  91 ++++-
 .../antlr4/GroovyLangLexerGStringDollarTest.groovy | 377 +++++++++++++++++++++
 .../groovy/parser/antlr4/SyntaxErrorTest.groovy    |  65 ++++
 .../codehaus/groovy/antlr/GStringEndTest.groovy    | 106 +++++-
 4 files changed, 622 insertions(+), 17 deletions(-)

diff --git a/src/main/java/org/apache/groovy/parser/antlr4/GroovyLangLexer.java 
b/src/main/java/org/apache/groovy/parser/antlr4/GroovyLangLexer.java
index 00af5045ef..888f84507e 100644
--- a/src/main/java/org/apache/groovy/parser/antlr4/GroovyLangLexer.java
+++ b/src/main/java/org/apache/groovy/parser/antlr4/GroovyLangLexer.java
@@ -22,6 +22,7 @@ import org.antlr.v4.runtime.CharStream;
 import org.antlr.v4.runtime.CharStreams;
 import org.antlr.v4.runtime.Lexer;
 import org.antlr.v4.runtime.LexerNoViableAltException;
+import org.antlr.v4.runtime.Token;
 import org.antlr.v4.runtime.atn.ATN;
 import org.antlr.v4.runtime.atn.LexerATNSimulator;
 import org.apache.groovy.parser.antlr4.internal.atnmanager.LexerAtnManager;
@@ -43,9 +44,67 @@ public class GroovyLangLexer extends GroovyLexer {
         this.setInterpreter(new PositionAdjustingLexerATNSimulator(this, 
LexerAtnManager.INSTANCE.getATN()));
     }
 
+    /**
+     * Returns the next token, with a special case for EOF after a bare {@code 
$} in a GString.
+     * <p>
+     * After {@code GStringBegin} or {@code GStringPart} matches a trailing 
{@code $} at the
+     * end of input, Antlr marks {@code _hitEOF} and would skip
+     * {@link #GSTRING_TYPE_SELECTOR_MODE} entirely on the subsequent call.  
Detect that
+     * situation and report the dollar-body error instead of a generic 
unexpected-EOF parse
+     * failure.
+     *
+     * @return the next token from the input
+     */
     @Override
-    public void recover(LexerNoViableAltException e) {
-        throw e; // if some lexical error occurred, stop parsing!
+    public Token nextToken() {
+        if (_hitEOF && isGStringDollarSelectorMode()) {
+            throw new GroovySyntaxError(
+                    illegalGStringDollarMessage(Token.EOF),
+                    GroovySyntaxError.LEXER,
+                    getLine(),
+                    getCharPositionInLine() + 1);
+        }
+        return super.nextToken();
+    }
+
+    /**
+     * Report a lexical error.  When the failure is inside
+     * {@link #GSTRING_TYPE_SELECTOR_MODE} (after a {@code $} in a GString), 
emit the
+     * dedicated dollar-body diagnostic instead of Antlr's generic 
token-recognition message.
+     *
+     * @param e the recognition failure raised by the ATN simulator
+     */
+    @Override
+    public void notifyListeners(final LexerNoViableAltException e) {
+        if (isGStringDollarSelectorMode()) {
+            getErrorListenerDispatch().syntaxError(
+                    this, null, _tokenStartLine, _tokenStartCharPositionInLine,
+                    illegalGStringDollarMessage(), e);
+            return;
+        }
+        super.notifyListeners(e);
+    }
+
+    /**
+     * Abort lexing on the first lexical error.  For an invalid character 
after {@code $}
+     * in a GString, throw {@link GroovySyntaxError} so the parser layer can 
surface a
+     * source-located syntax error without retrying SLL→LL or falling back to 
a generic
+     * exception message.
+     *
+     * @param e the recognition failure raised by the ATN simulator
+     */
+    @Override
+    public void recover(final LexerNoViableAltException e) {
+        if (isGStringDollarSelectorMode()) {
+            // Column is 1-based; _tokenStartCharPositionInLine is 0-based 
(Antlr convention).
+            throw new GroovySyntaxError(
+                    illegalGStringDollarMessage(),
+                    GroovySyntaxError.LEXER,
+                    _tokenStartLine,
+                    _tokenStartCharPositionInLine + 1);
+        }
+        // if some lexical error occurred, stop parsing!
+        throw e;
     }
 
     @Override
@@ -53,6 +112,34 @@ public class GroovyLangLexer extends GroovyLexer {
         ((PositionAdjustingLexerATNSimulator) 
getInterpreter()).resetAcceptPosition(getInputStream(), _tokenStartCharIndex - 
1, _tokenStartLine, _tokenStartCharPositionInLine - 1);
     }
 
+    /**
+     * Whether the lexer is choosing between {@code ${...}} and {@code 
$identifier} after a
+     * dollar in a GString (double-quoted, triple-double-quoted, slashy or 
dollar-slashy).
+     */
+    private boolean isGStringDollarSelectorMode() {
+        return GSTRING_TYPE_SELECTOR_MODE == _mode;
+    }
+
+    /**
+     * Build the user-facing message for an illegal character (or EOF) 
immediately after
+     * {@code $} in a GString.  When a concrete character is available it is 
appended so the
+     * diagnostic names both the rule and the offending input.
+     */
+    private String illegalGStringDollarMessage() {
+        return illegalGStringDollarMessage(_input.LA(1));
+    }
+
+    /**
+     * @param c the code point at the failure site, or {@link Token#EOF}
+     */
+    private String illegalGStringDollarMessage(final int c) {
+        // Antlr leaves the input index at the unrecognised character for 
LexerNoViableAltException.
+        if (Token.EOF == c) {
+            return "Illegal string body character after dollar sign";
+        }
+        return "Illegal string body character after dollar sign: " + 
getCharErrorDisplay(c);
+    }
+
     private static class PositionAdjustingLexerATNSimulator extends 
LexerATNSimulator {
         public PositionAdjustingLexerATNSimulator(Lexer recog, ATN atn) {
             super(recog, atn);
diff --git 
a/src/test/groovy/org/apache/groovy/parser/antlr4/GroovyLangLexerGStringDollarTest.groovy
 
b/src/test/groovy/org/apache/groovy/parser/antlr4/GroovyLangLexerGStringDollarTest.groovy
new file mode 100644
index 0000000000..90e5153d9c
--- /dev/null
+++ 
b/src/test/groovy/org/apache/groovy/parser/antlr4/GroovyLangLexerGStringDollarTest.groovy
@@ -0,0 +1,377 @@
+/*
+ *  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.BaseErrorListener
+import org.antlr.v4.runtime.CharStreams
+import org.antlr.v4.runtime.LexerNoViableAltException
+import org.antlr.v4.runtime.RecognitionException
+import org.antlr.v4.runtime.Recognizer
+import org.antlr.v4.runtime.Token
+import org.junit.jupiter.api.Test
+
+import static groovy.test.GroovyAssert.shouldFail
+import static 
org.apache.groovy.parser.antlr4.GroovyLexer.GSTRING_TYPE_SELECTOR_MODE
+import static org.junit.jupiter.api.Assertions.assertEquals
+import static org.junit.jupiter.api.Assertions.assertFalse
+import static org.junit.jupiter.api.Assertions.assertSame
+import static org.junit.jupiter.api.Assertions.assertTrue
+
+/**
+ * Unit-level coverage for {@link GroovyLangLexer}'s GString dollar-body 
diagnostics
+ * (GROOVY-12171).
+ * <p>
+ * Exercises every branch of {@code nextToken}, {@code notifyListeners}, 
{@code recover}
+ * and the private dollar-message helpers: both the GString-selector mode and 
the
+ * generic non-GString fall-through, plus the EOF vs. concrete-character 
message forms.
+ */
+final class GroovyLangLexerGStringDollarTest {
+
+    private static final String ILLEGAL_AFTER_DOLLAR = 'Illegal string body 
character after dollar sign'
+
+    // 
-------------------------------------------------------------------------
+    // constructors
+    // 
-------------------------------------------------------------------------
+
+    @Test
+    void 'Reader constructor tokenizes the same as CharStream constructor'() {
+        def source = 'def x = 1'
+        def fromReader = new GroovyLangLexer(new StringReader(source))
+        def fromStream = lexer(source)
+        assertEquals fromStream.nextToken().type, fromReader.nextToken().type
+    }
+
+    // 
-------------------------------------------------------------------------
+    // nextToken — success path (super.nextToken)
+    // 
-------------------------------------------------------------------------
+
+    @Test
+    void 'nextToken returns ordinary tokens outside GString dollar mode'() {
+        def lexer = lexer('def x = 1')
+        def types = []
+        Token t
+        while ((t = lexer.nextToken()).type != Token.EOF) {
+            if (t.channel == Token.DEFAULT_CHANNEL) {
+                types << t.type
+            }
+        }
+        assertFalse types.isEmpty()
+        assertEquals Token.EOF, t.type
+    }
+
+    // 
-------------------------------------------------------------------------
+    // nextToken — _hitEOF + GSTRING_TYPE_SELECTOR_MODE (EOF after bare $)
+    // 
-------------------------------------------------------------------------
+
+    @Test
+    void 'nextToken reports dollar-body error when EOF follows bare dollar in 
GString'() {
+        // GStringBegin consumes up through `$`, sets _hitEOF because 
LA(1)==EOF,
+        // and leaves the lexer in GSTRING_TYPE_SELECTOR_MODE.  The subsequent
+        // nextToken must not silently emit EOF.
+        def lexer = lexer('"hello$')
+        Token first = lexer.nextToken()
+        assertEquals GroovyLexer.GStringBegin, first.type
+
+        def err = shouldFail(GroovySyntaxError) {
+            lexer.nextToken()
+        }
+        assertEquals ILLEGAL_AFTER_DOLLAR, err.message
+        assertEquals GroovySyntaxError.LEXER, err.source
+        assertTrue err.line >= 1
+        assertTrue err.column >= 1
+    }
+
+    @Test
+    void 'nextToken EOF dollar error for dollar-only open GString'() {
+        def lexer = lexer('"$')
+        assertEquals GroovyLexer.GStringBegin, lexer.nextToken().type
+
+        def err = shouldFail(GroovySyntaxError) {
+            lexer.nextToken()
+        }
+        assertEquals ILLEGAL_AFTER_DOLLAR, err.message
+        // EOF form has no ": '…'" character display
+        assertFalse err.message.contains(':')
+    }
+
+    // 
-------------------------------------------------------------------------
+    // nextToken / recover / notifyListeners — illegal character after $
+    // 
-------------------------------------------------------------------------
+
+    @Test
+    void 'lex of closing quote after dollar throws GroovySyntaxError with 
quoted char'() {
+        // notifyListeners (GString branch) + recover (GString branch) + char 
message
+        def err = shouldFail(GroovySyntaxError) {
+            drain(lexer('"releases$"'))
+        }
+        assertTrue err.message.startsWith(ILLEGAL_AFTER_DOLLAR + ':')
+        assertTrue err.message.contains('"') || err.message.contains('\\"')
+        assertEquals GroovySyntaxError.LEXER, err.source
+    }
+
+    @Test
+    void 'lex of space after dollar throws GroovySyntaxError naming the 
space'() {
+        def err = shouldFail(GroovySyntaxError) {
+            drain(lexer('"a$ b"'))
+        }
+        assertTrue err.message.contains(ILLEGAL_AFTER_DOLLAR)
+        assertTrue err.message.contains(' ')
+    }
+
+    @Test
+    void 'lex of newline after dollar throws GroovySyntaxError naming the 
newline'() {
+        def err = shouldFail(GroovySyntaxError) {
+            drain(lexer('"hello$\n'))
+        }
+        assertTrue err.message.contains(ILLEGAL_AFTER_DOLLAR)
+        assertTrue err.message.contains('\\n') || err.message.contains('\n')
+    }
+
+    @Test
+    void 'lex of digit after dollar is illegal'() {
+        // IdentifierInGString cannot start with a digit
+        def err = shouldFail(GroovySyntaxError) {
+            drain(lexer('"val$1"'))
+        }
+        assertTrue err.message.contains(ILLEGAL_AFTER_DOLLAR)
+        assertTrue err.message.contains('1')
+    }
+
+    @Test
+    void 'lex of punctuation after dollar is illegal'() {
+        def err = shouldFail(GroovySyntaxError) {
+            drain(lexer('"val$,rest"'))
+        }
+        assertTrue err.message.contains(ILLEGAL_AFTER_DOLLAR)
+        assertTrue err.message.contains(',')
+    }
+
+    @Test
+    void 'triple-double-quoted GString trailing dollar reports dollar-body 
error'() {
+        def err = shouldFail(GroovySyntaxError) {
+            drain(lexer('"""releases$"""'))
+        }
+        assertTrue err.message.contains(ILLEGAL_AFTER_DOLLAR)
+    }
+
+    @Test
+    void 'GStringPart mid-string trailing dollar reports dollar-body error'() {
+        // After $name the next $ pushes GSTRING_TYPE_SELECTOR_MODE again; 
closing "
+        // is then illegal
+        def err = shouldFail(GroovySyntaxError) {
+            drain(lexer('"hello $name$"'))
+        }
+        assertTrue err.message.contains(ILLEGAL_AFTER_DOLLAR)
+    }
+
+    @Test
+    void 'valid GString interpolation tokenizes without dollar-body error'() {
+        def types = []
+        def lexer = lexer('"hello $name!"')
+        Token t
+        while ((t = lexer.nextToken()).type != Token.EOF) {
+            if (t.channel == Token.DEFAULT_CHANNEL) {
+                types << t.type
+            }
+        }
+        assertTrue types.contains(GroovyLexer.GStringBegin)
+        assertTrue types.contains(GroovyLexer.Identifier)
+        assertTrue types.contains(GroovyLexer.GStringEnd)
+    }
+
+    @Test
+    void 'valid brace interpolation tokenizes without dollar-body error'() {
+        def lexer = lexer('"${1+2}"')
+        def sawLBrace = false
+        Token t
+        while ((t = lexer.nextToken()).type != Token.EOF) {
+            if (t.type == GroovyLexer.LBRACE) {
+                sawLBrace = true
+            }
+        }
+        assertTrue sawLBrace
+    }
+
+    // 
-------------------------------------------------------------------------
+    // recover — non-GString fall-through rethrows LexerNoViableAltException
+    // 
-------------------------------------------------------------------------
+
+    @Test
+    void 'recover outside GString dollar mode rethrows the recognition 
failure'() {
+        def lexer = lexer('x')
+        def e = new LexerNoViableAltException(lexer, lexer.inputStream, 0, 
null)
+        def thrown = shouldFail(LexerNoViableAltException) {
+            lexer.recover(e)
+        }
+        assertSame e, thrown
+    }
+
+    // 
-------------------------------------------------------------------------
+    // recover — GString dollar mode → GroovySyntaxError (char + EOF messages)
+    // 
-------------------------------------------------------------------------
+
+    @Test
+    void 'recover in GString dollar mode with concrete char throws 
GroovySyntaxError'() {
+        def lexer = lexer('!')
+        lexer.pushMode(GSTRING_TYPE_SELECTOR_MODE)
+        def e = new LexerNoViableAltException(lexer, lexer.inputStream, 0, 
null)
+
+        def err = shouldFail(GroovySyntaxError) {
+            lexer.recover(e)
+        }
+        assertTrue err.message.startsWith(ILLEGAL_AFTER_DOLLAR + ':')
+        assertEquals GroovySyntaxError.LEXER, err.source
+    }
+
+    @Test
+    void 'recover in GString dollar mode at EOF throws GroovySyntaxError 
without char display'() {
+        def lexer = lexer('')
+        lexer.pushMode(GSTRING_TYPE_SELECTOR_MODE)
+        def e = new LexerNoViableAltException(lexer, lexer.inputStream, 0, 
null)
+
+        def err = shouldFail(GroovySyntaxError) {
+            lexer.recover(e)
+        }
+        assertEquals ILLEGAL_AFTER_DOLLAR, err.message
+        assertFalse err.message.contains(':')
+        assertEquals GroovySyntaxError.LEXER, err.source
+    }
+
+    // 
-------------------------------------------------------------------------
+    // notifyListeners — GString dollar mode emits dedicated diagnostic
+    // 
-------------------------------------------------------------------------
+
+    @Test
+    void 'notifyListeners in GString dollar mode reports illegal dollar-body 
message'() {
+        def lexer = lexer('!')
+        lexer.pushMode(GSTRING_TYPE_SELECTOR_MODE)
+        def messages = []
+        lexer.removeErrorListeners()
+        lexer.addErrorListener(new BaseErrorListener() {
+            @Override
+            void syntaxError(Recognizer<?, ?> recognizer, Object 
offendingSymbol,
+                             int line, int charPositionInLine, String msg,
+                             RecognitionException e) {
+                messages << msg
+            }
+        })
+        def e = new LexerNoViableAltException(lexer, lexer.inputStream, 0, 
null)
+        lexer.notifyListeners(e)
+
+        assertEquals 1, messages.size()
+        assertTrue messages[0].startsWith(ILLEGAL_AFTER_DOLLAR + ':')
+    }
+
+    @Test
+    void 'notifyListeners in GString dollar mode at EOF reports message 
without char'() {
+        def lexer = lexer('')
+        lexer.pushMode(GSTRING_TYPE_SELECTOR_MODE)
+        def messages = []
+        lexer.removeErrorListeners()
+        lexer.addErrorListener(new BaseErrorListener() {
+            @Override
+            void syntaxError(Recognizer<?, ?> recognizer, Object 
offendingSymbol,
+                             int line, int charPositionInLine, String msg,
+                             RecognitionException e) {
+                messages << msg
+            }
+        })
+        def e = new LexerNoViableAltException(lexer, lexer.inputStream, 0, 
null)
+        lexer.notifyListeners(e)
+
+        assertEquals 1, messages.size()
+        assertEquals ILLEGAL_AFTER_DOLLAR, messages[0]
+    }
+
+    // 
-------------------------------------------------------------------------
+    // notifyListeners — non-GString fall-through (generic Antlr message)
+    // 
-------------------------------------------------------------------------
+
+    @Test
+    void 'notifyListeners outside GString dollar mode uses generic recognition 
message'() {
+        def lexer = lexer('!')
+        // super.notifyListeners reads [_tokenStartCharIndex, index]; seed a 
valid span
+        // (same package → protected Lexer fields are accessible).
+        lexer._tokenStartCharIndex = 0
+        def messages = []
+        lexer.removeErrorListeners()
+        lexer.addErrorListener(new BaseErrorListener() {
+            @Override
+            void syntaxError(Recognizer<?, ?> recognizer, Object 
offendingSymbol,
+                             int line, int charPositionInLine, String msg,
+                             RecognitionException e) {
+                messages << msg
+            }
+        })
+        def e = new LexerNoViableAltException(lexer, lexer.inputStream, 0, 
null)
+        lexer.notifyListeners(e)
+
+        assertEquals 1, messages.size()
+        assertTrue messages[0].startsWith('token recognition error'),
+                "expected generic Antlr message, got: ${messages[0]}"
+        assertFalse messages[0].contains(ILLEGAL_AFTER_DOLLAR)
+    }
+
+    // 
-------------------------------------------------------------------------
+    // Integration-style: forced dollar-selector mode (match path, not just
+    // the nextToken _hitEOF short-circuit after GStringBegin)
+    // 
-------------------------------------------------------------------------
+
+    @Test
+    void 'EOF while already in GString dollar mode yields dollar-body error on 
subsequent nextToken'() {
+        // Antlr's ATN returns Token.EOF without throwing when the input is 
already
+        // exhausted and nothing was consumed (failOrAccept).  That sets 
_hitEOF;
+        // the next call then hits GroovyLangLexer.nextToken's dollar-mode 
guard.
+        def lexer = lexer('')
+        lexer.pushMode(GSTRING_TYPE_SELECTOR_MODE)
+        assertEquals Token.EOF, lexer.nextToken().type
+
+        def err = shouldFail(GroovySyntaxError) {
+            lexer.nextToken()
+        }
+        assertEquals ILLEGAL_AFTER_DOLLAR, err.message
+        assertEquals GroovySyntaxError.LEXER, err.source
+    }
+
+    @Test
+    void 'match failure on illegal char in GString dollar mode yields 
dollar-body error'() {
+        def lexer = lexer(' ')
+        lexer.pushMode(GSTRING_TYPE_SELECTOR_MODE)
+        def err = shouldFail(GroovySyntaxError) {
+            lexer.nextToken()
+        }
+        assertTrue err.message.startsWith(ILLEGAL_AFTER_DOLLAR + ':')
+    }
+
+    // 
-------------------------------------------------------------------------
+    // helpers
+    // 
-------------------------------------------------------------------------
+
+    private static GroovyLangLexer lexer(String source) {
+        new GroovyLangLexer(CharStreams.fromString(source))
+    }
+
+    /** Consume tokens until EOF or an exception escapes. */
+    private static void drain(GroovyLangLexer lexer) {
+        Token t
+        while ((t = lexer.nextToken()).type != Token.EOF) {
+            // keep draining
+        }
+    }
+}
diff --git 
a/src/test/groovy/org/apache/groovy/parser/antlr4/SyntaxErrorTest.groovy 
b/src/test/groovy/org/apache/groovy/parser/antlr4/SyntaxErrorTest.groovy
index bbe0175f72..9593d78133 100644
--- a/src/test/groovy/org/apache/groovy/parser/antlr4/SyntaxErrorTest.groovy
+++ b/src/test/groovy/org/apache/groovy/parser/antlr4/SyntaxErrorTest.groovy
@@ -131,6 +131,71 @@ final class SyntaxErrorTest {
             |'''.stripMargin()
     }
 
+    @Test
+    void 'groovy core - GString illegal character after dollar'() {
+        // Trailing bare `$` in double-quoted and triple-double-quoted strings.
+        // Must name the dollar (Groovy 2 message) rather than only the 
closing quote.
+        expectParseError '''\
+            |def Target = "releases$"
+            |'''.stripMargin(), '''\
+            |Illegal string body character after dollar sign: '"' @ line 1, 
column 24.
+            |   def Target = "releases$"
+            |                          ^
+            |
+            |1 error
+            |'''.stripMargin()
+
+        expectParseError '''\
+            |def Target = """releases$"""
+            |'''.stripMargin(), '''\
+            |Illegal string body character after dollar sign: '"' @ line 1, 
column 26.
+            |   def Target = """releases$"""
+            |                            ^
+            |
+            |1 error
+            |'''.stripMargin()
+
+        expectParseError '''\
+            |def x = "$"
+            |'''.stripMargin(), '''\
+            |Illegal string body character after dollar sign: '"' @ line 1, 
column 11.
+            |   def x = "$"
+            |             ^
+            |
+            |1 error
+            |'''.stripMargin()
+
+        expectParseError '''\
+            |def x = "a$ b"
+            |'''.stripMargin(), '''\
+            |Illegal string body character after dollar sign: ' ' @ line 1, 
column 12.
+            |   def x = "a$ b"
+            |              ^
+            |
+            |1 error
+            |'''.stripMargin()
+
+        // newline immediately after `$` (unclosed GString line)
+        expectParseError '''\
+            |def x = "hello$
+            |'''.stripMargin(), '''\
+            |Illegal string body character after dollar sign: '\\n' @ line 1, 
column 16.
+            |   def x = "hello$
+            |                  ^
+            |
+            |1 error
+            |'''.stripMargin()
+
+        // true EOF immediately after `$` — no character to display in the 
message
+        expectParseError 'def x = "hello$', '''\
+            |Illegal string body character after dollar sign @ line 1, column 
16.
+            |   def x = "hello$
+            |                  ^
+            |
+            |1 error
+            |'''.stripMargin()
+    }
+
     @Test
     void 'groovy core - ParExpression'() {
         TestUtils.doRunAndShouldFail('fail/ParExpression_01x.groovy')
diff --git a/src/test/groovy/org/codehaus/groovy/antlr/GStringEndTest.groovy 
b/src/test/groovy/org/codehaus/groovy/antlr/GStringEndTest.groovy
index d9062f5293..70833a4b94 100644
--- a/src/test/groovy/org/codehaus/groovy/antlr/GStringEndTest.groovy
+++ b/src/test/groovy/org/codehaus/groovy/antlr/GStringEndTest.groovy
@@ -22,31 +22,107 @@ import 
org.codehaus.groovy.control.MultipleCompilationErrorsException
 import org.junit.jupiter.api.Test
 
 import static groovy.test.GroovyAssert.assertScript
+import static groovy.test.GroovyAssert.shouldFail
 
+/**
+ * Error diagnostics for GStrings that end with a bare {@code $}
+ * (or otherwise place an illegal character immediately after {@code $}).
+ * <p>
+ * Historical Groovy 2 reported
+ * {@code illegal string body character after dollar sign} with a precise
+ * caret.  The Antlr4 lexer must preserve that clarity rather than emitting
+ * the generic {@code token recognition error at: '"'} that points at the
+ * closing quote without mentioning the dollar.
+ */
 class GStringEndTest {
+
+    private static final String ILLEGAL_AFTER_DOLLAR = 'Illegal string body 
character after dollar sign'
+
     @Test
-    void testInvalidEndContainsLineNumber(){
-        try {
-            assertScript '''
+    void testInvalidEndContainsLineNumber() {
+        def err = shouldFail(MultipleCompilationErrorsException,
+             '''
                 def Target = "releases$"
-            '''
-        } catch (MultipleCompilationErrorsException mcee) {
-            def text = mcee.toString();
-            assert text.contains("line 2, column 40")
-        }
+            ''')
+        def text = err.toString()
+        assert text.contains('line 2, column 40')
+        assert text.contains(ILLEGAL_AFTER_DOLLAR)
+        // caret sample still points at the illegal character (the closing 
quote after $)
+        assert text.contains('def Target = "releases$"')
+        assert !text.contains('token recognition error')
+    }
+
+    @Test
+    void testInvalidEndInTripleDoubleQuotedString() {
+        def err = shouldFail(MultipleCompilationErrorsException,
+             '''
+                def Target = """releases$"""
+            ''')
+        def text = err.toString()
+        assert text.contains(ILLEGAL_AFTER_DOLLAR)
+        assert text.contains('line 2, column 42')
+        assert !text.contains('token recognition error')
+    }
+
+    @Test
+    void testDollarOnlyGString() {
+        def err = shouldFail(MultipleCompilationErrorsException,
+             'def x = "$"')
+        def text = err.toString()
+        assert text.contains(ILLEGAL_AFTER_DOLLAR)
+        assert text.contains('line 1, column 11')
+        assert !text.contains('token recognition error')
+    }
+
+    @Test
+    void testIllegalCharacterAfterDollarNotClosingQuote() {
+        // space after $ is also illegal (not an identifier / not ${)
+        def err = shouldFail(MultipleCompilationErrorsException,
+             'def x = "a$ b"')
+        def text = err.toString()
+        assert text.contains(ILLEGAL_AFTER_DOLLAR)
+        assert text.contains('line 1, column 12')
+        assert !text.contains('token recognition error')
+    }
+
+    @Test
+    void testMidStringDollarPartWithTrailingDollar() {
+        def err = shouldFail(MultipleCompilationErrorsException,
+             'def x = "hello $name$"')
+        def text = err.toString()
+        assert text.contains(ILLEGAL_AFTER_DOLLAR)
+        assert !text.contains('token recognition error')
+    }
+
+    @Test
+    void testEscapedTrailingDollarIsLegal() {
+        assertScript '''
+            def Target = "releases\\$"
+            assert Target == 'releases$'
+        '''
+    }
+
+    @Test
+    void testDollarAtEndOfFile() {
+        // GStringBegin consumes up through `$`, then EOF — no character to 
quote in the message
+        def err = shouldFail(MultipleCompilationErrorsException,
+             'def x = "hello$')
+        def text = err.toString()
+        assert text.contains(ILLEGAL_AFTER_DOLLAR)
+        assert !text.contains('token recognition error')
+        // EOF form has no trailing ": '…'" character display
+        assert !(text =~ /Illegal string body character after dollar sign: '/)
     }
 
     @Test
     void testErrorReportOnStringEndWithOutParser() {
         // GROOVY-6608: the code did throw a NPE
-        try {
-            assertScript '''
+        def err = shouldFail(MultipleCompilationErrorsException,
+             '''
             def scanFolders()
             { doThis( ~"(?i)^sometext$",
-            '''
-        } catch (MultipleCompilationErrorsException mcee) {
-            def text = mcee.toString();
-            assert text.contains("line 3, column 39")
-        }
+            ''')
+        def text = err.toString()
+        assert text.contains('line 3, column 39')
     }
 }

Reply via email to