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 deaaf9988d5..003f7393c3e 100644 --- a/src/main/java/org/apache/groovy/parser/antlr4/AstBuilder.java +++ b/src/main/java/org/apache/groovy/parser/antlr4/AstBuilder.java @@ -188,7 +188,12 @@ public AstBuilder(final SourceUnit sourceUnit, final boolean groovydocEnabled, f this.lexer = new GroovyLangLexer(charStream); this.parser = new GroovyLangParser(new CommonTokenStream(this.lexer)); - this.parser.setErrorHandler(new DescriptiveErrorStrategy(charStream)); + + // Opt-in recovery (CompilerConfiguration.ERROR_RECOVERY): resync after syntax + // errors so IDEs can collect multiple diagnostics in one pass. Default is fail-fast. + // Multi-error truth for hosts is ErrorCollector; recovery may still yield a partial tree. + this.errorRecovery = sourceUnit.getConfiguration().isErrorRecoveryEnabled(); + this.parser.setErrorHandler(DescriptiveErrorStrategy.create(charStream, this.errorRecovery)); this.groovydocManager = new GroovydocManager(groovydocEnabled, runtimeGroovydocEnabled); this.tryWithResourcesASTTransformation = new TryWithResourcesASTTransformation(this); @@ -216,7 +221,12 @@ private GroovyParserRuleContext buildCST() throws CompilationFailedException { AtnManager.READ_LOCK.lock(); try { final TokenStream tokenStream = parser.getInputStream(); - if (SLL_THRESHOLD >= 0 && tokenStream.size() > SLL_THRESHOLD) { + if (errorRecovery) { + // Recovery needs LL + listeners so every syntax error is reported. + // Skip SLL: recovering under SLL can "succeed" with a degraded tree + // and never re-run LL. + result = buildCST(PredictionMode.LL); + } else if (SLL_THRESHOLD >= 0 && tokenStream.size() > SLL_THRESHOLD) { // The more tokens to parse, the more possibility SLL will fail and the more parsing time will waste. // The option `groovy.antlr4.sll.threshold` could be tuned for better parsing performance, but it is disabled by default. // If the token count is greater than `groovy.antlr4.sll.threshold`, use LL directly. @@ -274,10 +284,32 @@ public ModuleNode buildAST() { try { return (ModuleNode) this.visit(this.buildCST()); } catch (Throwable t) { - throw convertException(t); + // convertException is a no-op for an existing CompilationFailedException and + // otherwise records (recovery) or fatally fails (default) before wrapping. + // Under recovery, return the partial module only when diagnostics were actually + // recorded — an empty collector with a CFE would otherwise look like silent success + // (belt-and-braces guard suggested in PR review). + CompilationFailedException cfe = convertException(t); + if (shouldReturnPartialModuleOnFailure()) { + return this.moduleNode; + } + throw cfe; } } + /** + * Whether recovery should return the partially built {@link #moduleNode} after a + * {@link CompilationFailedException} instead of rethrowing. + *
+ * Requires both recovery mode and at least one recorded diagnostic. An empty + * collector with recovery enabled is treated as a real failure (rethrow) so a + * programming error in an error path cannot surface as silent success. + *
+ */ + private boolean shouldReturnPartialModuleOnFailure() { + return errorRecovery && sourceUnit.getErrorCollector().hasErrors(); + } + @Override public ModuleNode visitCompilationUnit(final CompilationUnitContext ctx) { this.visit(ctx.packageDeclaration()); @@ -4874,7 +4906,6 @@ private CompilationFailedException createParsingFailedException(final Throwable if (t instanceof SyntaxException) { this.collectSyntaxError((SyntaxException) t); } else if (t instanceof GroovySyntaxError groovySyntaxError) { - this.collectSyntaxError( new SyntaxException( groovySyntaxError.getMessage(), @@ -4892,7 +4923,14 @@ private CompilationFailedException createParsingFailedException(final Throwable } private void collectSyntaxError(final SyntaxException e) { - sourceUnit.getErrorCollector().addFatalError(new SyntaxErrorMessage(e, sourceUnit)); + SyntaxErrorMessage message = new SyntaxErrorMessage(e, sourceUnit); + if (errorRecovery) { + // Accumulate diagnostics so ANTLR resync can surface further errors in one pass. + // Fail-fast still uses addFatalError so the first error stops compilation immediately. + sourceUnit.getErrorCollector().addErrorAndContinue(message); + } else { + sourceUnit.getErrorCollector().addFatalError(message); + } } private void collectException(final Exception e) { @@ -4968,6 +5006,8 @@ public List+ * 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. + *
+ */ +abstract class AbstractFriendlyErrorStrategy extends DefaultErrorStrategy { + + private final CharStream charStream; + + AbstractFriendlyErrorStrategy(final CharStream charStream) { + this.charStream = charStream; + } + + /** + * Prefer a precise "Missing …" delimiter diagnostic when the token stream + * clearly indicates an unclosed / incomplete construct; otherwise fall + * back to the generic message. + */ + private void reportFriendlyError(final Parser recognizer, final RecognitionException e, final String fallbackMessage) { + MissingDelimiterDiagnostic.Hit hit = null; + try { + // Incomplete / synthetic contexts can leave token indices out of range. + hit = MissingDelimiterDiagnostic.locate(recognizer.getInputStream(), e); + } catch (IndexOutOfBoundsException | IllegalArgumentException ignored) { + // Fall through to the generic message. Catch only locate()'s known + // defensive failures — never listener-side fatals (e.g. addFatalError). + } + if (hit != null) { + recognizer.notifyErrorListeners(hit.at, hit.message, e); + return; + } + notifyErrorListeners(recognizer, fallbackMessage, e); + } + + protected String createNoViableAlternativeErrorMessage(final Parser recognizer, final NoViableAltException e) { + TokenStream tokens = recognizer.getInputStream(); + String input; + if (tokens != null) { + if (e.getStartToken().getType() == Token.EOF) { + input = "+ * {@code recognizer} is part of the protected hook surface (parity with + * {@link #createNoViableAlternativeErrorMessage} / + * {@link #createInputMismatchErrorMessage}) so subclasses can include + * parser state when customising the message. + *
+ */ + protected String createFailedPredicateErrorMessage(final Parser recognizer, final FailedPredicateException e) { + // Non-null contract only; default message is already complete on the exception. + Objects.requireNonNull(recognizer, "recognizer"); + return e.getMessage(); + } + + @Override + protected void reportFailedPredicate(final Parser recognizer, final FailedPredicateException e) { + notifyErrorListeners(recognizer, createFailedPredicateErrorMessage(recognizer, e), e); + } +} diff --git a/src/main/java/org/apache/groovy/parser/antlr4/internal/DescriptiveErrorStrategy.java b/src/main/java/org/apache/groovy/parser/antlr4/internal/DescriptiveErrorStrategy.java index aa581e1d364..b23958546a2 100644 --- a/src/main/java/org/apache/groovy/parser/antlr4/internal/DescriptiveErrorStrategy.java +++ b/src/main/java/org/apache/groovy/parser/antlr4/internal/DescriptiveErrorStrategy.java @@ -18,7 +18,7 @@ */ package org.apache.groovy.parser.antlr4.internal; -import org.antlr.v4.runtime.BailErrorStrategy; +import org.antlr.v4.runtime.ANTLRErrorStrategy; import org.antlr.v4.runtime.CharStream; import org.antlr.v4.runtime.FailedPredicateException; import org.antlr.v4.runtime.InputMismatchException; @@ -27,113 +27,112 @@ import org.antlr.v4.runtime.ParserRuleContext; import org.antlr.v4.runtime.RecognitionException; import org.antlr.v4.runtime.Token; -import org.antlr.v4.runtime.TokenStream; import org.antlr.v4.runtime.atn.PredictionMode; -import org.antlr.v4.runtime.misc.Interval; import org.antlr.v4.runtime.misc.ParseCancellationException; /** - * Provide friendly error messages when parsing errors occurred. + * Fail-fast parser error strategy with friendly diagnostics (default Parrot mode). *- * Extends {@link BailErrorStrategy} so the successful-parse path stays - * allocation-free and ATN-light (no grammar-level error alternatives — - * those were removed after GROOVY-9588). Missing-delimiter diagnostics - * ({@code ')'}, {@code ']'}, {@code '}'}) run only after a recognition - * failure via {@link MissingDelimiterDiagnostic}. + * Cancels the parse with {@link ParseCancellationException} after reporting, + * matching {@link org.antlr.v4.runtime.BailErrorStrategy}. {@link #sync} is a + * no-op so the SLL stage of two-stage parsing stays cheap. Diagnostics are + * emitted from {@link #recover}: after a failed SLL probe the strategy may still + * be in ANTLR's internal recovery mode, which suppresses + * {@link #reportError}, while {@code recover} still runs under LL. *
+ *+ * For IDE multi-error collection use {@link #create(CharStream, boolean)} with + * {@code recover == true}, which selects + * {@link RecoveringDescriptiveErrorStrategy}. Hosts should treat + * {@link org.codehaus.groovy.control.ErrorCollector} as the multi-error source + * of truth: recovery may still produce a partial tree that fails later during + * AST building. + *
+ * + * @see RecoveringDescriptiveErrorStrategy + * @see org.codehaus.groovy.control.CompilerConfiguration#ERROR_RECOVERY */ -public class DescriptiveErrorStrategy extends BailErrorStrategy { - private final CharStream charStream; +public class DescriptiveErrorStrategy extends AbstractFriendlyErrorStrategy { + + /** + * Select fail-fast or recovering strategy. + * + * @param charStream source character stream used for snippet extraction + * @param recover {@code true} for multi-error resync; {@code false} for fail-fast + * @return an error strategy instance (never {@code null}) + */ + public static ANTLRErrorStrategy create(final CharStream charStream, final boolean recover) { + return recover + ? new RecoveringDescriptiveErrorStrategy(charStream) + : new DescriptiveErrorStrategy(charStream); + } + + /** + * Fail-fast strategy with friendly diagnostics. + * + * @param charStream source character stream used for snippet extraction + */ + public DescriptiveErrorStrategy(final CharStream charStream) { + super(charStream); + } - public DescriptiveErrorStrategy(CharStream charStream) { - this.charStream = charStream; + /** + * {@inheritDoc} + *+ * Mark incomplete contexts, emit a friendly LL diagnostic, then cancel. + *
+ */ + @Override + public void recover(final Parser recognizer, final RecognitionException e) { + throw cancel(recognizer, e); } + /** + * {@inheritDoc} + *+ * Bail-style: never return a synthetic token; report then cancel. + *
+ */ @Override - public void recover(Parser recognizer, RecognitionException e) { + public Token recoverInline(final Parser recognizer) throws RecognitionException { + throw cancel(recognizer, new InputMismatchException(recognizer)); + } + + /** + * Shared fail-fast cancel path for {@link #recover} and {@link #recoverInline}. + * Marks incomplete contexts, emits an LL diagnostic when applicable, and + * returns a {@link ParseCancellationException} for the caller to throw + * (bail-style: control never resumes in the strategy after cancel). + */ + private ParseCancellationException cancel(final Parser recognizer, final RecognitionException e) { for (ParserRuleContext context = recognizer.getContext(); context != null; context = context.getParent()) { context.exception = e; } + // Report here (not only via reportError): after a failed SLL stage the + // shared strategy may still have errorRecoveryMode=true, which makes + // DefaultErrorStrategy.reportError a no-op on the LL retry. if (PredictionMode.LL.equals(recognizer.getInterpreter().getPredictionMode())) { if (e instanceof NoViableAltException) { - this.reportNoViableAlternative(recognizer, (NoViableAltException) e); + reportNoViableAlternative(recognizer, (NoViableAltException) e); } else if (e instanceof InputMismatchException) { - this.reportInputMismatch(recognizer, (InputMismatchException) e); + reportInputMismatch(recognizer, (InputMismatchException) e); } else if (e instanceof FailedPredicateException) { - this.reportFailedPredicate(recognizer, (FailedPredicateException) e); + reportFailedPredicate(recognizer, (FailedPredicateException) e); } } - throw new ParseCancellationException(e); - } - - @Override - public Token recoverInline(Parser recognizer) - throws RecognitionException { - - this.recover(recognizer, new InputMismatchException(recognizer)); // stop parsing - return null; + return new ParseCancellationException(e); } /** - * Prefer a precise "Missing …" delimiter diagnostic when the token stream - * clearly indicates an unclosed / incomplete construct; otherwise fall - * back to the generic "Unexpected input" message. + * {@inheritDoc} + *+ * No-op (matches {@link org.antlr.v4.runtime.BailErrorStrategy}) so SLL stays cheap. + *
*/ - private void reportError(Parser recognizer, RecognitionException e, String fallbackMessage) { - MissingDelimiterDiagnostic.Hit hit = - MissingDelimiterDiagnostic.locate(recognizer.getInputStream(), e); - if (hit != null) { - recognizer.notifyErrorListeners(hit.at, hit.message, e); - return; - } - notifyErrorListeners(recognizer, fallbackMessage, e); - } - - protected String createNoViableAlternativeErrorMessage(Parser recognizer, NoViableAltException e) { - TokenStream tokens = recognizer.getInputStream(); - String input; - if (tokens != null) { - if (e.getStartToken().getType() == Token.EOF) { - input = "+ * Uses ANTLR's default resync / single-token repair so multiple recognition + * errors can be reported in one pass (IDE editing). Prefer construction via + * {@link DescriptiveErrorStrategy#create(CharStream, boolean)}. + *
+ *+ * Callers must parse with LL prediction and error listeners installed. Hosts + * should read diagnostics from + * {@link org.codehaus.groovy.control.ErrorCollector}: a partial tree may still + * fail during AST building after recovery. + *
+ * + * @see DescriptiveErrorStrategy + * @see org.codehaus.groovy.control.CompilerConfiguration#ERROR_RECOVERY + */ +public final class RecoveringDescriptiveErrorStrategy extends AbstractFriendlyErrorStrategy { + + /** + * @param charStream source character stream used for snippet extraction + */ + public RecoveringDescriptiveErrorStrategy(final CharStream charStream) { + super(charStream); + } +} 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 f1e8439d375..adfc5e53815 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 @@ -18,6 +18,8 @@ */ /** - * Internal utilities and support classes for ANTLR4 parser operation. Handles parser configuration, error handling, and internal state management. + * 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; diff --git a/src/main/java/org/codehaus/groovy/control/CompilerConfiguration.java b/src/main/java/org/codehaus/groovy/control/CompilerConfiguration.java index 95c2a98e196..71cf670b01b 100644 --- a/src/main/java/org/codehaus/groovy/control/CompilerConfiguration.java +++ b/src/main/java/org/codehaus/groovy/control/CompilerConfiguration.java @@ -64,6 +64,21 @@ public class CompilerConfiguration { /** Optimization Option for enabling parallel parsing. */ public static final String PARALLEL_PARSE = "parallelParse"; + /** + * Option for enabling ANTLR parser error recovery on the Parrot parser. + *+ * Disabled by default. When enabled (typically for IDE editing), the parser + * resynchronizes after recognition errors so multiple diagnostics can be + * collected in one pass instead of failing fast on the first error. + * Stored in {@link #getOptimizationOptions()} for consistency with other + * compiler feature flags (e.g. {@link #GROOVYDOC}). + *
+ * + * @see #isErrorRecoveryEnabled() + * @since 5.0.0 + */ + public static final String ERROR_RECOVERY = "errorRecovery"; + /** Joint Compilation Option for enabling generating stubs in memory. */ public static final String MEM_STUB = "memStub"; @@ -456,6 +471,7 @@ public void setLogClassgenStackTraceMaxDepth(int logClassgenStackTraceMaxDepth) *groovy.parallel.parsegroovy.attach.groovydocgroovy.attach.runtime.groovydocgroovy.parser.error.recovery+ * Disabled by default. Enable via {@link #ERROR_RECOVERY} or the + * {@code groovy.parser.error.recovery} system property. Intended for IDE + * editing (multiple syntax diagnostics per pass). Leave off for production + * compilation: fail-fast is cheaper and avoids building partial trees. + *
+ * + * @return {@code true} if parser error recovery is enabled + * @since 5.0.0 + */ + public boolean isErrorRecoveryEnabled() { + return Boolean.TRUE.equals(getOptimizationOptions().get(ERROR_RECOVERY)); + } } diff --git a/src/test/groovy/org/apache/groovy/parser/antlr4/Groovy9192.groovy b/src/test/groovy/org/apache/groovy/parser/antlr4/Groovy9192.groovy new file mode 100644 index 00000000000..ebb74d2a561 --- /dev/null +++ b/src/test/groovy/org/apache/groovy/parser/antlr4/Groovy9192.groovy @@ -0,0 +1,413 @@ +/* + * 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.CommonTokenStream +import org.antlr.v4.runtime.InputMismatchException +import org.antlr.v4.runtime.ParserRuleContext +import org.antlr.v4.runtime.atn.PredictionMode +import org.antlr.v4.runtime.misc.ParseCancellationException +import org.apache.groovy.parser.antlr4.internal.DescriptiveErrorStrategy +import org.apache.groovy.parser.antlr4.internal.RecoveringDescriptiveErrorStrategy +import org.codehaus.groovy.control.CompilationFailedException +import org.codehaus.groovy.control.CompilationUnit +import org.codehaus.groovy.control.CompilerConfiguration +import org.codehaus.groovy.control.ErrorCollector +import org.codehaus.groovy.control.Phases +import org.codehaus.groovy.control.SourceUnit +import org.codehaus.groovy.control.io.StringReaderSource +import org.codehaus.groovy.control.messages.Message +import org.codehaus.groovy.control.messages.SyntaxErrorMessage +import org.codehaus.groovy.syntax.SyntaxException +import org.junit.jupiter.api.Test + +import static org.codehaus.groovy.control.CompilerConfiguration.ERROR_RECOVERY +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.assertThrows +import static org.junit.jupiter.api.Assertions.assertTrue + +/** + * GROOVY-9192: optional ANTLR error recovery for the Parrot parser. + *+ * Recovery is off by default (fail-fast). Enabling {@code errorRecovery} + * resynchronizes after recognition errors so multiple diagnostics can be + * collected in one pass (IDE editing). Hosts should trust + * {@link ErrorCollector} as the multi-error source of truth. + *
+ */ +final class Groovy9192 { + + //-------------------------------------------------------------------------- + // Configuration + //-------------------------------------------------------------------------- + + @Test + void 'error recovery is disabled by default'() { + assertFalse new CompilerConfiguration().errorRecoveryEnabled + assertFalse CompilerConfiguration.DEFAULT.errorRecoveryEnabled + assertEquals 'errorRecovery', ERROR_RECOVERY + } + + @Test + void 'error recovery can be enabled via optimization option'() { + def config = new CompilerConfiguration() + config.optimizationOptions[ERROR_RECOVERY] = true + assertTrue config.errorRecoveryEnabled + + config.optimizationOptions[ERROR_RECOVERY] = false + assertFalse config.errorRecoveryEnabled + + config.optimizationOptions[ERROR_RECOVERY] = null + assertFalse config.errorRecoveryEnabled + } + + @Test + void 'copy constructor preserves error recovery option'() { + def source = new CompilerConfiguration() + source.optimizationOptions[ERROR_RECOVERY] = true + assertTrue new CompilerConfiguration(source).errorRecoveryEnabled + } + + @Test + void 'DescriptiveErrorStrategy create selects fail-fast or recovering strategy'() { + def stream = CharStreams.fromString('1') + assertInstanceOf DescriptiveErrorStrategy, DescriptiveErrorStrategy.create(stream, false) + assertInstanceOf RecoveringDescriptiveErrorStrategy, DescriptiveErrorStrategy.create(stream, true) + } + + //-------------------------------------------------------------------------- + // Fail-fast (default) — compatibility with existing contracts + //-------------------------------------------------------------------------- + + @Test + void 'default fail-fast still reports a friendly syntax error'() { + def errors = collectErrors('class C {\n def x = (\n', false) + assertEquals 1, errors.size(), "fail-fast must stop at first fatal: $errors" + assertTrue errors.any { it.contains("Missing ')'") || it.toLowerCase().contains('unexpected') }, + "unexpected diagnostics: $errors" + } + + @Test + void 'default fail-fast cancels parse via ParseCancellationException'() { + def charStream = CharStreams.fromString('class C {') + def strategy = new DescriptiveErrorStrategy(charStream) + + def lexer = new GroovyLangLexer(charStream) + def parser = new GroovyLangParser(new CommonTokenStream(lexer)) + parser.errorHandler = strategy + parser.interpreter.predictionMode = PredictionMode.LL + parser.removeErrorListeners() + + assertThrows(ParseCancellationException) { + parser.compilationUnit() + } + } + + @Test + void 'fail-fast recover marks incomplete rule contexts before cancelling'() { + // Under SLL the strategy does not report (avoids needing a live ATN state); + // it only marks contexts and cancels — the BailErrorStrategy contract. + def charStream = CharStreams.fromString('}') + def strategy = new DescriptiveErrorStrategy(charStream) + def lexer = new GroovyLangLexer(charStream) + def parser = new GroovyLangParser(new CommonTokenStream(lexer)) + parser.errorHandler = strategy + parser.interpreter.predictionMode = PredictionMode.SLL + parser.removeErrorListeners() + + def parent = new ParserRuleContext() + def child = new ParserRuleContext(parent, 0) + parser.context = child + + def e = new InputMismatchException(parser) + def pce = assertThrows(ParseCancellationException) { + strategy.recover(parser, e) + } + assertTrue pce.cause instanceof InputMismatchException + assertEquals e, child.exception + assertEquals e, parent.exception + } + + @Test + void 'fail-fast recoverInline cancels with InputMismatchException cause'() { + def charStream = CharStreams.fromString('}') + def strategy = new DescriptiveErrorStrategy(charStream) + def lexer = new GroovyLangLexer(charStream) + def parser = new GroovyLangParser(new CommonTokenStream(lexer)) + parser.errorHandler = strategy + parser.interpreter.predictionMode = PredictionMode.SLL + parser.removeErrorListeners() + parser.context = new ParserRuleContext() + + def pce = assertThrows(ParseCancellationException) { + strategy.recoverInline(parser) + } + assertTrue pce.cause instanceof InputMismatchException + } + + @Test + void 'fail-fast sync is a no-op'() { + def charStream = CharStreams.fromString('1') + def strategy = new DescriptiveErrorStrategy(charStream) + def parser = new GroovyLangParser(new CommonTokenStream(new GroovyLangLexer(charStream))) + parser.errorHandler = strategy + def indexBefore = parser.inputStream.index() + strategy.sync(parser) + assertEquals indexBefore, parser.inputStream.index() + } + + @Test + void 'fail-fast collectSyntaxError path still aborts on first error'() { + def errors = collectErrors('class C { def x = ( }', false) + assertEquals 1, errors.size(), String.valueOf(errors) + } + + //-------------------------------------------------------------------------- + // Recovery mode + //-------------------------------------------------------------------------- + + @Test + void 'recovery mode reports more diagnostics than fail-fast for multi-fault source'() { + // Two top-level broken classes so recovery can resync past the first fault. + String source = '''\ + |class A { + | def x = ( + |} + |class B { + | def y = [ + |} + |'''.stripMargin() + + def failFastErrors = collectErrors(source, false) + def recoveryErrors = collectErrors(source, true) + + assertEquals 1, failFastErrors.size(), "fail-fast: $failFastErrors" + // Product claim: multi-error collection — recovery must surface strictly more + // diagnostics than fail-fast (which stops at the first fatal recognition error). + assertTrue recoveryErrors.size() > failFastErrors.size(), + "recovery (${recoveryErrors.size()}) must report more than fail-fast (${failFastErrors.size()})\nfail-fast: $failFastErrors\nrecovery: $recoveryErrors" + assertTrue recoveryErrors.size() >= 2, "recovery should collect multiple diagnostics: $recoveryErrors" + assertTrue recoveryErrors.any { it.contains("Missing ')'") || it.contains("Missing ']'") || it.contains("Missing '}'") }, + "expected a Missing-delimiter diagnostic in: $recoveryErrors" + } + + @Test + void 'recovery mode still fails the compilation'() { + def config = recoveryConfig() + def cu = new CompilationUnit(config) + cu.addSource('Broken.groovy', 'class C {\n def x = (\n') + assertThrows(CompilationFailedException) { + cu.compile(Phases.CONVERSION) + } + assertTrue cu.errorCollector.hasErrors() + } + + @Test + void 'recovery mode keeps friendly Missing delimiter diagnostics'() { + def errors = collectErrors('class C {\n def x\n', true) + assertTrue errors.any { it.contains("Missing '}'") }, "expected Missing '}', got: $errors" + } + + @Test + void 'recovery strategy resynchronizes instead of cancelling'() { + def charStream = CharStreams.fromString('class C { def x = ( } class D {}') + def strategy = new RecoveringDescriptiveErrorStrategy(charStream) + + def lexer = new GroovyLangLexer(charStream) + def parser = new GroovyLangParser(new CommonTokenStream(lexer)) + parser.errorHandler = strategy + parser.interpreter.predictionMode = PredictionMode.LL + parser.removeErrorListeners() + + assertNotNull parser.compilationUnit() + } + + @Test + void 'AstBuilder wires recovery from CompilerConfiguration for valid source'() { + def config = recoveryConfig() + def sourceUnit = new SourceUnit( + 't.groovy', + new StringReaderSource('class C {}', config), + config, + null, + new ErrorCollector(config) + ) + def module = new AstBuilder(sourceUnit, false, false).buildAST() + assertNotNull module + assertFalse sourceUnit.errorCollector.hasErrors() + } + + @Test + void 'recovery buildAST leaves module and collector for CompilationUnit to fail'() { + // Recovery must not require SourceUnit to swallow CompilationFailedException: + // AstBuilder returns a module with diagnostics already in the ErrorCollector. + def config = recoveryConfig() + def cu = new CompilationUnit(config) + cu.addSource('Broken.groovy', 'class C {\n def x = (\n') + assertThrows(CompilationFailedException) { + cu.compile(Phases.CONVERSION) + } + def unit = cu.sources.values().iterator().next() + assertNotNull unit.AST, 'recovery should materialize a module without SourceUnit CFE catch' + assertTrue unit.errorCollector.hasErrors() + } + + @Test + void 'fail-fast AstBuilder aborts via fatal ErrorCollector path'() { + // Exercises collectSyntaxError → addFatalError (not only recovery's addErrorAndContinue). + def config = new CompilerConfiguration() + def sourceUnit = new SourceUnit( + 'broken.groovy', + new StringReaderSource('class C {\n def x = (\n', config), + config, + null, + new ErrorCollector(config) + ) + assertThrows(CompilationFailedException) { + new AstBuilder(sourceUnit, false, false).buildAST() + } + assertTrue sourceUnit.errorCollector.hasErrors() + assertEquals 1, sourceUnit.errorCollector.errorCount + } + + @Test + void 'recovery AstBuilder returns module with accumulated diagnostics for unclosed delimiters'() { + // Recovering parse finishes CST; AstBuilder returns a module and keeps + // diagnostics in the collector for CompilationUnit.failIfErrors(). + def config = recoveryConfig() + def sourceUnit = new SourceUnit( + 'partial.groovy', + new StringReaderSource('class C {\n def x = (\n def y = [\n', config), + config, + null, + new ErrorCollector(config) + ) + def module = new AstBuilder(sourceUnit, false, false).buildAST() + assertNotNull module + assertTrue sourceUnit.errorCollector.hasErrors() + assertTrue sourceUnit.errorCollector.errorCount >= 1 + } + + @Test + void 'recovery rethrows when collector has no diagnostics'() { + // Belt-and-braces guard: under recovery, a CFE with an empty + // ErrorCollector must not look like silent success. Swallow recording so + // createParsingFailedException throws CFE without populating the collector. + // + // Note: Groovy call sites unwrap CompilationFailedException to its cause + // (ScriptBytecodeAdapter.unwrap / Indy cold reflective path), so the + // observable type here is the SyntaxException cause, not the CFE wrapper. + def config = recoveryConfig() + def collector = new ErrorCollector(config) { + @Override + void addErrorAndContinue(Message message) { + // intentionally do not record + } + } + // Visit-time failure (void return on annotation method): parse succeeds, + // AST building throws CFE after collectSyntaxError. + def sourceUnit = new SourceUnit( + 'broken.groovy', + new StringReaderSource('@interface A { void m() {} }', config), + config, + null, + collector + ) + def thrown = assertThrows(SyntaxException) { + new AstBuilder(sourceUnit, false, false).buildAST() + } + assertTrue thrown.message.toLowerCase().contains('void'), thrown.message + assertFalse sourceUnit.errorCollector.hasErrors(), + 'empty collector is the precondition for the rethrow guard' + } + + @Test + void 'recovery returns partial module when visit throws after recording diagnostics'() { + // Same visit-time failure as above, but with a normal collector: diagnostics + // are recorded and buildAST returns the partial module instead of throwing. + def config = recoveryConfig() + def sourceUnit = new SourceUnit( + 'broken.groovy', + new StringReaderSource('@interface A { void m() {} }', config), + config, + null, + new ErrorCollector(config) + ) + def module = new AstBuilder(sourceUnit, false, false).buildAST() + assertNotNull module + assertTrue sourceUnit.errorCollector.hasErrors() + assertTrue sourceUnit.errorCollector.errors.any { + it instanceof SyntaxErrorMessage && + ((SyntaxErrorMessage) it).cause.message.toLowerCase().contains('void') + }, String.valueOf(sourceUnit.errorCollector.errors) + } + + @Test + void 'fail-fast still aborts when recovery is disabled'() { + // Guard is recovery-only: fail-fast must always surface failure + // (MultipleCompilationErrorsException via addFatalError). + def config = new CompilerConfiguration() + def sourceUnit = new SourceUnit( + 'broken.groovy', + new StringReaderSource('@interface A { void m() {} }', config), + config, + null, + new ErrorCollector(config) + ) + assertThrows(CompilationFailedException) { + new AstBuilder(sourceUnit, false, false).buildAST() + } + assertTrue sourceUnit.errorCollector.hasErrors() + } + + //-------------------------------------------------------------------------- + // helpers + //-------------------------------------------------------------------------- + + private static CompilerConfiguration recoveryConfig() { + def config = new CompilerConfiguration() + config.optimizationOptions[ERROR_RECOVERY] = true + config + } + + /** + * Compile {@code source} through CONVERSION and return syntax error messages + * (empty if compilation somehow succeeded). + */ + private static List+ * End-to-end multi-error collection lives in {@code Groovy9192}; this class + * locks strategy-level control flow and reporting branches that are awkward + * to assert through the full compilation pipeline alone. + *
+ */ +final class ErrorStrategyTest { + + //-------------------------------------------------------------------------- + // Fail-fast cancel contracts + //-------------------------------------------------------------------------- + + @Test + void failFastRecoverCancelsWithCause() { + var charStream = CharStreams.fromString("}"); + var strategy = new DescriptiveErrorStrategy(charStream); + var parser = parser(charStream, strategy, PredictionMode.SLL); + parser.setContext(new ParserRuleContext()); + + InputMismatchException cause = new InputMismatchException(parser); + ParseCancellationException pce = assertThrows(ParseCancellationException.class, + () -> strategy.recover(parser, cause)); + assertInstanceOf(InputMismatchException.class, pce.getCause()); + assertSame(cause, pce.getCause()); + } + + @Test + void failFastRecoverInlineCancelsWithInputMismatch() { + var charStream = CharStreams.fromString("}"); + var strategy = new DescriptiveErrorStrategy(charStream); + var parser = parser(charStream, strategy, PredictionMode.SLL); + parser.setContext(new ParserRuleContext()); + + ParseCancellationException pce = assertThrows(ParseCancellationException.class, + () -> strategy.recoverInline(parser)); + assertInstanceOf(InputMismatchException.class, pce.getCause()); + } + + @Test + void failFastRecoverInlineViaParserMatchCancels() { + var charStream = CharStreams.fromString("}"); + var strategy = new DescriptiveErrorStrategy(charStream); + var parser = parser(charStream, strategy, PredictionMode.LL); + parser.setContext(new ParserRuleContext()); + assertThrows(ParseCancellationException.class, + () -> parser.match(GroovyParser.Identifier)); + } + + @Test + void failFastRecoverUnderLlReportsNoViableAltAndCancels() { + var charStream = CharStreams.fromString("}"); + var strategy = new DescriptiveErrorStrategy(charStream); + var messages = new ArrayList