diff --git a/COMPATIBILITY.md b/COMPATIBILITY.md index 4b7a3cae005..8f026ab81b5 100644 --- a/COMPATIBILITY.md +++ b/COMPATIBILITY.md @@ -206,6 +206,35 @@ planned for deprecation/removal in a future Groovy version. Formal `@Deprecated` may be restored before 6 GA once beta feedback confirms indy remains acceptable for those use cases. +### Groovy 6 — `instanceof` pattern variable flow scoping (GROOVY-12242) + +Groovy 6 aligns JEP 394 / JLS §6.3-style *pattern variable* scoping for +`e instanceof T t` and `e !instanceof T t` (equivalent to +`!(e instanceof T t)`) with Java for the common shapes: `if`/`else` +(including abrupt-completion survivors), short-circuit `&&` / `||`, +ternary/Elvis arms, and true-path bindings in `while` bodies. + +**Who is affected (silent behaviour change in dynamic Groovy).** Code that +previously treated a pattern variable as a local outside its JLS live range +— for example in an `else` branch of `if (o instanceof String s)`, after a +fall-through `if`, or on the RHS of `o instanceof String s || s.isEmpty()` — +now resolves that name as a dynamic property. At runtime that yields +`MissingPropertyException` (or a type-checker error under `@TypeChecked` / +`@CompileStatic`), with **no** additional compile-time warning in dynamic +mode. That is the intended Java-aligned semantics; Groovy 6 is the major +version for the change. + +**What is *not* claimed.** `while` / `do`-`while` deliberately get only +*partial* flow scoping: true-path bindings are available in a `while` body +and short-circuit rules apply in conditions, but Groovy does **not** +introduce false-path bindings after a loop when the body cannot complete +normally (JLS §6.3.2.3). Pattern names never leak past the loop. Full +after-loop introduction is a possible future enhancement, not a 6.0 gap to +back-port silently. + +Release notes for the 6.0 beta line should call this out (JIRA +`breaking` label on GROOVY-12242). + ### Groovy 6 — `CompilerConfiguration` copy constructor copies customizers (GROOVY-9585) `CompilerConfiguration(CompilerConfiguration)` now copies the source diff --git a/src/antlr/GroovyParser.g4 b/src/antlr/GroovyParser.g4 index 7384daca9ea..41b3c078bc8 100644 --- a/src/antlr/GroovyParser.g4 +++ b/src/antlr/GroovyParser.g4 @@ -352,10 +352,16 @@ referenceType : qualifiedClassName typeArguments? ; -matchingType // see: instanceof +matchingType // see: instanceof / !instanceof type patterns (JEP 394) : standardType identifier? ; +// RHS of !instanceof: Type / Type name (pattern), or parenthesised (T) / rejected (A & B) +notInstanceofType + : matchingType + | castParExpression + ; + standardType // see: returnType options { baseContext = type; } : annotationsOpt @@ -846,7 +852,8 @@ expression // boolean relational expressions (level 7) | left=expression nls op=INSTANCEOF nls matchingType #relationalExprAlt - | left=expression nls op=(AS | NOT_INSTANCEOF) nls coercionType #relationalExprAlt + | left=expression nls op=NOT_INSTANCEOF nls notInstanceofType #relationalExprAlt + | left=expression nls op=AS nls coercionType #relationalExprAlt | left=expression nls op=(LE | GE | GT | LT | IN | NOT_IN) nls right=expression #relationalExprAlt // equality/inequality (==/!=) (level 8) 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 5152b960edd..e0d05dd7fd0 100644 --- a/src/main/java/org/apache/groovy/parser/antlr4/AstBuilder.java +++ b/src/main/java/org/apache/groovy/parser/antlr4/AstBuilder.java @@ -3232,22 +3232,30 @@ public Expression visitRelationalExprAlt(final RelationalExprAltContext ctx) { ctx); case NOT_INSTANCEOF: { - CoercionTypeContext coercionCtx = ctx.coercionType(); - if (coercionCtx.castParExpression() != null - && coercionCtx.castParExpression().intersectionType().type().size() > 1) { + // GROOVY-12242: !instanceof Type name (JEP 394 pattern) via matchingType; + // parenthesised (T) / rejected intersection (A & B) via castParExpression. + NotInstanceofTypeContext nit = ctx.notInstanceofType(); + if (nit.matchingType() != null) { + nit.matchingType().putNodeMetaData(IS_INSIDE_INSTANCEOF_EXPR, Boolean.TRUE); + return configureAST( + new BinaryExpression( + (Expression) this.visit(ctx.left), + this.createGroovyToken(ctx.op), + this.visitMatchingType(nit.matchingType())), + ctx); + } + CastParExpressionContext castCtx = nit.castParExpression(); + if (castCtx.intersectionType().type().size() > 1) { throw this.createParsingFailedException("Intersection types are not supported as the right-hand side of !instanceof", ctx); } - ClassNode notInstType = this.visitCoercionType(coercionCtx); - // GROOVY-11998: keep IS_INSIDE_INSTANCEOF_EXPR on the parser context for the resolver - (coercionCtx.type() != null - ? coercionCtx.type() - : coercionCtx.castParExpression().intersectionType().type(0) - ).putNodeMetaData(IS_INSIDE_INSTANCEOF_EXPR, Boolean.TRUE); + // GROOVY-11998: mark the type context for the resolver (parameterized RHS) + castCtx.intersectionType().type(0).putNodeMetaData(IS_INSIDE_INSTANCEOF_EXPR, Boolean.TRUE); + ClassNode notInstType = this.visitCastParExpression(castCtx); return configureAST( new BinaryExpression( (Expression) this.visit(ctx.left), this.createGroovyToken(ctx.op), - configureAST(new ClassExpression(notInstType), coercionCtx)), + configureAST(new ClassExpression(notInstType), castCtx)), ctx); } diff --git a/src/main/java/org/codehaus/groovy/ast/expr/DeclarationExpression.java b/src/main/java/org/codehaus/groovy/ast/expr/DeclarationExpression.java index e4970bca8d6..a7e6ec2abae 100644 --- a/src/main/java/org/codehaus/groovy/ast/expr/DeclarationExpression.java +++ b/src/main/java/org/codehaus/groovy/ast/expr/DeclarationExpression.java @@ -43,6 +43,12 @@ * you can use the method "TupleExpression getTupleExpression()" method. * Calling either of these expression getters when the "isMultipleAssignment" condition * is not appropriate is unsafe and will result in a ClassCastException. + *

+ * JEP 394 type patterns reuse this node as the right-hand side of + * {@code instanceof}: {@code e instanceof String s} is modelled as an + * {@code instanceof} {@link BinaryExpression} whose RHS is a + * {@code DeclarationExpression} with an {@link EmptyExpression} initializer. + * The pattern variable's scope is flow-sensitive (see GROOVY-12242). */ public class DeclarationExpression extends BinaryExpression { @@ -130,6 +136,11 @@ public TupleExpression getTupleExpression() { : null; } + /** + * Returns the type of the declared variable (or of the tuple for a multiple + * assignment). For a JEP 394 pattern used as the RHS of {@code instanceof}, + * this is the pattern type {@code T} in {@code e instanceof T t}. + */ @Override public ClassNode getType() { return (isMultipleAssignmentDeclaration() ? getTupleExpression() : getVariableExpression()).getType(); diff --git a/src/main/java/org/codehaus/groovy/classgen/AsmClassGenerator.java b/src/main/java/org/codehaus/groovy/classgen/AsmClassGenerator.java index 3bf8e5e7bdb..3b83617bf5f 100644 --- a/src/main/java/org/codehaus/groovy/classgen/AsmClassGenerator.java +++ b/src/main/java/org/codehaus/groovy/classgen/AsmClassGenerator.java @@ -27,6 +27,7 @@ import org.codehaus.groovy.ast.ClassHelper; import org.codehaus.groovy.ast.ClassNode; import org.codehaus.groovy.ast.ConstructorNode; +import org.codehaus.groovy.ast.DynamicVariable; import org.codehaus.groovy.ast.FieldNode; import org.codehaus.groovy.ast.GenericsType; import org.codehaus.groovy.ast.InnerClassNode; @@ -1673,14 +1674,27 @@ public void visitVariableExpression(final VariableExpression expression) { return; } - BytecodeVariable variable = compileStack.getVariable(expression.getName(), /*throwIfMissing*/false); - if (variable != null) { - controller.getOperandStack().loadOrStoreVariable(variable, expression.isUseReferenceDirectly()); - } else { + // GROOVY-12242: VariableScopeVisitor marks out-of-scope pattern references + // as DynamicVariable. Name-based CompileStack lookup must not bypass that: + // pattern slots are allocated during condition evaluation and can still be + // present on the stack for short-circuit RHS of || (where the pattern is + // not definitely bound) or briefly on a non-live arm before hide. Treat + // DynamicVariable as property access even when a same-named slot exists. + if (expression.getAccessedVariable() instanceof DynamicVariable) { PropertyExpression pexp = thisPropX(/*implicit-this*/true, expression.getName()); pexp.getProperty().setSourcePosition(expression); pexp.copyNodeMetaData(expression); pexp.visit(this); + } else { + BytecodeVariable variable = compileStack.getVariable(expression.getName(), /*throwIfMissing*/false); + if (variable != null) { + controller.getOperandStack().loadOrStoreVariable(variable, expression.isUseReferenceDirectly()); + } else { + PropertyExpression pexp = thisPropX(/*implicit-this*/true, expression.getName()); + pexp.getProperty().setSourcePosition(expression); + pexp.copyNodeMetaData(expression); + pexp.visit(this); + } } if (!compileStack.isLHS()) { diff --git a/src/main/java/org/codehaus/groovy/classgen/VariableScopeVisitor.java b/src/main/java/org/codehaus/groovy/classgen/VariableScopeVisitor.java index b9c7126c371..330200c4c56 100644 --- a/src/main/java/org/codehaus/groovy/classgen/VariableScopeVisitor.java +++ b/src/main/java/org/codehaus/groovy/classgen/VariableScopeVisitor.java @@ -20,7 +20,9 @@ import org.codehaus.groovy.ast.ASTNode; import org.codehaus.groovy.ast.AnnotationNode; +import groovy.transform.Internal; import org.codehaus.groovy.ast.ClassCodeVisitorSupport; +import org.codehaus.groovy.ast.CodeVisitorSupport; import org.codehaus.groovy.ast.ClassHelper; import org.codehaus.groovy.ast.ClassNode; import org.codehaus.groovy.ast.DynamicVariable; @@ -35,6 +37,7 @@ import org.codehaus.groovy.ast.expr.AnnotationConstantExpression; import org.codehaus.groovy.ast.expr.ArrayExpression; import org.codehaus.groovy.ast.expr.BinaryExpression; +import org.codehaus.groovy.ast.expr.BooleanExpression; import org.codehaus.groovy.ast.expr.CastExpression; import org.codehaus.groovy.ast.expr.ClassExpression; import org.codehaus.groovy.ast.expr.ClosureExpression; @@ -42,9 +45,11 @@ import org.codehaus.groovy.ast.expr.ConstructorCallExpression; import org.codehaus.groovy.ast.expr.DeclarationExpression; import org.codehaus.groovy.ast.expr.Expression; +import org.codehaus.groovy.ast.expr.NotExpression; import org.codehaus.groovy.ast.expr.FieldExpression; import org.codehaus.groovy.ast.expr.MethodCallExpression; import org.codehaus.groovy.ast.expr.PropertyExpression; +import org.codehaus.groovy.ast.expr.TernaryExpression; import org.codehaus.groovy.ast.expr.TupleExpression; import org.codehaus.groovy.ast.expr.VariableExpression; import org.codehaus.groovy.ast.stmt.AssertStatement; @@ -62,10 +67,14 @@ import org.codehaus.groovy.control.SourceUnit; import org.codehaus.groovy.syntax.Types; +import java.util.ArrayList; +import java.util.Collections; import java.util.Deque; import java.util.HashSet; import java.util.Iterator; +import java.util.LinkedHashSet; import java.util.LinkedList; +import java.util.List; import java.util.Optional; import java.util.Set; import java.util.function.BiConsumer; @@ -76,10 +85,28 @@ import static org.apache.groovy.ast.tools.MethodNodeUtils.getPropertyName; import static org.apache.groovy.ast.tools.MethodNodeUtils.withDefaultArgumentMethods; import static org.codehaus.groovy.ast.tools.GeneralUtils.getAllProperties; +import static org.codehaus.groovy.ast.tools.GeneralUtils.maybeFallsThrough; import static org.codehaus.groovy.transform.trait.Traits.isTrait; /** * Initializes the variable scopes for an AST. + *

+ * For JEP 394 {@code instanceof} pattern variables (GROOVY-12242), this + * class is the single authoritative source of scope decisions. + * {@link InstanceofFlowBindings} answers which pattern variables are definitely + * bound on each control-flow path; this visitor: + *

+ * + * @see InstanceofFlowBindings */ public class VariableScopeVisitor extends ClassCodeVisitorSupport { @@ -581,7 +608,13 @@ public void visitCatchStatement(final CatchStatement statement) { } /** - * {@inheritDoc} + * Visits a {@code do}/{@code while} loop (GROOVY-12242). + *

+ * The body runs before the condition, so pattern variables from the + * condition are not in scope in the body (same as Java). The + * condition is visited in a nested scope so short-circuit RHS works and + * pattern names do not leak after the loop. Like {@link #visitWhileLoop}, + * there is no after-loop introduction of {@code whenFalse} bindings. */ @Override public void visitDoWhileLoop(final DoWhileStatement statement) { @@ -623,20 +656,59 @@ public void visitForLoop(final ForStatement statement) { } /** - * {@inheritDoc} + * Visits an {@code if}/{@code else} statement, establishing correct + * lexical scopes for JEP 394 {@code instanceof} pattern variables + * (GROOVY-12242) and attaching {@link InstanceofFlowBindings} metadata so + * later phases need not re-derive the flow analysis. + *

+ * Rules applied (JLS §6.3.2.2 / JEP 394): + *

+ * In dynamic Groovy, undeclared references resolve to + * {@link org.codehaus.groovy.ast.DynamicVariable} (runtime + * {@link groovy.lang.MissingPropertyException}). {@code @TypeChecked} + * enforces the same rules at compile time; both modes share this visitor. */ @Override public void visitIfElse(final IfStatement statement) { + InstanceofFlowBindings bindings = InstanceofFlowBindings.of(statement.getBooleanExpression()); + // Same analysis result: declare into scopes + enrich AST for classgen. + InstanceofFlowBindings.put(statement, bindings); + + // Condition: pattern vars are available for short-circuit RHS (e.g. &&). pushState(); visitStatement(statement); statement.getBooleanExpression().visit(this); + popState(); + + // Then-block: §6.3.2.2-200-A — only e.whenTrue() bindings. pushState(); + declarePatternVariables(bindings.whenTrue()); statement.getIfBlock().visit(this); popState(); - popState(); + + // Else-block: §6.3.2.2-200-B — only e.whenFalse() bindings. pushState(); + declarePatternVariables(bindings.whenFalse()); statement.getElseBlock().visit(this); popState(); + + // After the if-else: + // §6.3.2.2-200-C-B: if-block (S) is abrupt → e.whenFalse() survive after. + if (!maybeFallsThrough(statement.getIfBlock())) { + declarePatternVariables(bindings.whenFalse()); + } + // §6.3.2.2-200-C-A: else-block (T) is abrupt → e.whenTrue() survive after. + if (!statement.getElseBlock().isEmpty() && !maybeFallsThrough(statement.getElseBlock())) { + declarePatternVariables(bindings.whenTrue()); + } } /** @@ -660,13 +732,52 @@ public void visitSwitch(final SwitchStatement statement) { } /** - * {@inheritDoc} + * Visits a {@code while} loop with partial JEP 394 flow scoping + * for {@code instanceof} pattern variables (GROOVY-12242). + *

+ * What is supported (aligned with the if-then rule for the body): + *

+ *

+ * Intentional divergence from JLS §6.3.2.3 (while): + * Groovy does not introduce {@code e.whenFalse()} after the loop when + * the body cannot complete normally. Full after-loop introduction would need + * definite abrupt-completion analysis of every exit path (including + * {@code break}/{@code continue} of nested loops) and is left out for 6.0 — + * pattern variables never leak past the loop. Documented as a deliberate + * partial implementation, not an oversight. */ @Override public void visitWhileLoop(final WhileStatement statement) { + InstanceofFlowBindings bindings = InstanceofFlowBindings.of(statement.getBooleanExpression()); + + // Condition: short-circuit RHS only; discard pattern decls after the visit. pushState(); - super.visitWhileLoop(statement); + visitStatement(statement); + statement.getBooleanExpression().visit(this); popState(); + + // Body: § if-then analogue — only e.whenTrue() (no after-loop whenFalse). + pushState(); + declarePatternVariables(bindings.whenTrue()); + statement.getLoopBlock().visit(this); + popState(); + } + + /** + * Declares {@code instanceof} pattern variables into the current scope so + * that subsequent visits resolve them as locals. Skips names already present + * in this scope (re-declaring the same pattern variable object after a + * condition visit is a no-op). + */ + private void declarePatternVariables(final List patternVariables) { + for (VariableExpression variable : patternVariables) { + if (currentScope.getDeclaredVariable(variable.getName()) == null) { + declare(variable); + } + } } // expressions: @@ -681,17 +792,85 @@ public void visitArrayExpression(final ArrayExpression expression) { } /** - * {@inheritDoc} + * Visits binary expressions with flow-aware scoping for {@code &&} / {@code ||} + * so pattern variables follow Java short-circuit rules (GROOVY-12242 / JEP 394): + *

*/ @Override public void visitBinaryExpression(final BinaryExpression expression) { - super.visitBinaryExpression(expression); + int op = expression.getOperation().getType(); + if (op == Types.LOGICAL_AND) { + // Symmetric to ||: isolate left's declares, then expose only whenTrue on the right. + // Fixes `e !instanceof T t && t.m()` and `!(e instanceof T t) && t.m()` (whenTrue={}). + InstanceofFlowBindings leftBindings = InstanceofFlowBindings.of(expression.getLeftExpression()); + pushState(); + expression.getLeftExpression().visit(this); + popState(); + pushState(); + declarePatternVariables(leftBindings.whenTrue()); + expression.getRightExpression().visit(this); + popState(); + } else if (op == Types.LOGICAL_OR) { + // Left's true-path bindings must not leak into the right (Java rejects + // `o instanceof String s || s.isEmpty()`). False-path bindings of the + // left are in scope on the right (`o !instanceof String s || s.isEmpty()`). + InstanceofFlowBindings leftBindings = InstanceofFlowBindings.of(expression.getLeftExpression()); + pushState(); + expression.getLeftExpression().visit(this); + popState(); + pushState(); + declarePatternVariables(leftBindings.whenFalse()); + expression.getRightExpression().visit(this); + popState(); + } else if (op == Types.COMPARE_NOT_INSTANCEOF) { + // Defence in depth: pattern declare on the RHS must not stick to the + // enclosing scope (whenTrue of !instanceof is empty). Live paths + // re-introduce via declarePatternVariables / short-circuit handlers. + expression.getLeftExpression().visit(this); + pushState(); + expression.getRightExpression().visit(this); + popState(); + } else { + super.visitBinaryExpression(expression); + } - if (Types.isAssignment(expression.getOperation().getType())) { + if (Types.isAssignment(op)) { checkFinalFieldAccess(expression.getLeftExpression()); } } + /** + * Visits a ternary / Elvis expression with flow scoping for pattern variables: + * true-path bindings are in scope in the then-branch; false-path bindings in + * the else-branch (GROOVY-12242 / JEP 394). Also attaches + * {@link InstanceofFlowBindings} metadata for later phases. + */ + @Override + public void visitTernaryExpression(final TernaryExpression expression) { + InstanceofFlowBindings bindings = InstanceofFlowBindings.of(expression.getBooleanExpression()); + InstanceofFlowBindings.put(expression, bindings); + + pushState(); + expression.getBooleanExpression().visit(this); + popState(); + + pushState(); + declarePatternVariables(bindings.whenTrue()); + expression.getTrueExpression().visit(this); + popState(); + + pushState(); + declarePatternVariables(bindings.whenFalse()); + expression.getFalseExpression().visit(this); + popState(); + } + /** * {@inheritDoc} */ @@ -879,4 +1058,273 @@ public void visitVariableExpression(final VariableExpression expression) { checkVariableContextAccess(variable, expression); } } + + // ========================================================================= + // Nested class: InstanceofFlowBindings + // ========================================================================= + + /** + * Flow-sensitive result for JEP 394 {@code instanceof} pattern + * bindings (GROOVY-12242) — the Groovy equivalent of the JLS §6.3.1 + * “introduced by” sets. + *

+ * One type, two views of the same analysis: + *

+ * {@link VariableScopeVisitor} runs the analysis once via {@link #of}, + * declares from the variable lists, and attaches this instance as AST + * metadata ({@link #put}/{@link #get}). Later phases must use + * {@link #get} only — never re-call {@link #of} on the condition. + *

+ * Covered shapes: {@code e instanceof T t}, {@code e !instanceof T t} + * (native form and {@code !(e instanceof T t)}), {@code !expr}, + * {@code a && b}, {@code a || b}. All other shapes yield {@link #EMPTY} + * (conservative: no definite bindings). + * + * @see VariableScopeVisitor + * @since 6.0.0 + */ + @Internal + public static final class InstanceofFlowBindings { + + /** Metadata key for {@link ASTNode#putNodeMetaData}/{@code getNodeMetaData}. */ + public static final Object KEY = InstanceofFlowBindings.class; + + /** Singleton for "no pattern variables on either path". */ + public static final InstanceofFlowBindings EMPTY = + new InstanceofFlowBindings(List.of(), List.of()); + + private final List whenTrue; + private final List whenFalse; + /** Cached name view of {@link #whenTrue}; never reallocated. */ + private final Set whenTrueNames; + /** Cached name view of {@link #whenFalse}; never reallocated. */ + private final Set whenFalseNames; + + private InstanceofFlowBindings(final List whenTrue, + final List whenFalse) { + this.whenTrue = whenTrue; + this.whenFalse = whenFalse; + this.whenTrueNames = namesOf(whenTrue); + this.whenFalseNames = namesOf(whenFalse); + } + + /** + * Pattern variables definitely assigned when the condition is {@code true}. + * Used by {@link VariableScopeVisitor} to declare into scopes. + */ + public List whenTrue() { return whenTrue; } + + /** + * Pattern variables definitely assigned when the condition is {@code false}. + * Used by {@link VariableScopeVisitor} to declare into scopes. + */ + public List whenFalse() { return whenFalse; } + + /** + * Names of pattern variables bound when the condition is {@code true}. + * Used by classgen for CompileStack path-hide; same set as {@link #whenTrue()}. + */ + public Set whenTrueNames() { return whenTrueNames; } + + /** + * Names of pattern variables bound when the condition is {@code false}. + * Used by classgen for CompileStack path-hide; same set as {@link #whenFalse()}. + */ + public Set whenFalseNames() { return whenFalseNames; } + + /** Whether any pattern variable is bound on either path. */ + public boolean isEmpty() { + return whenTrue.isEmpty() && whenFalse.isEmpty(); + } + + /** + * All pattern-variable names in either path (stable encounter order). + */ + public Set allNames() { + if (isEmpty()) return Collections.emptySet(); + if (whenFalseNames.isEmpty()) return whenTrueNames; + if (whenTrueNames.isEmpty()) return whenFalseNames; + Set names = new LinkedHashSet<>(whenTrueNames.size() + whenFalseNames.size()); + names.addAll(whenTrueNames); + names.addAll(whenFalseNames); + return Collections.unmodifiableSet(names); + } + + // ----------------------------------------------------------------- + // AST metadata (enrichment for later phases) + // ----------------------------------------------------------------- + + /** + * Attaches this analysis result to {@code node} for later phases. + * No-op when {@code bindings} is null or {@link #EMPTY}. + */ + public static void put(final ASTNode node, final InstanceofFlowBindings bindings) { + if (node == null || bindings == null || bindings.isEmpty()) return; + node.putNodeMetaData(KEY, bindings); + } + + /** + * Returns the analysis result previously attached to {@code node}, or + * {@link #EMPTY} if none (no path-live pattern variables). + */ + public static InstanceofFlowBindings get(final ASTNode node) { + if (node == null) return EMPTY; + InstanceofFlowBindings bindings = node.getNodeMetaData(KEY); + return bindings != null ? bindings : EMPTY; + } + + // ----------------------------------------------------------------- + // Analysis entry points + // ----------------------------------------------------------------- + + /** + * Analyses {@code expression} for definite {@code instanceof} pattern + * bindings. Call only from {@link VariableScopeVisitor} (or tests); + * classgen must use {@link #get(ASTNode)}. + * + * @param expression a boolean condition (may be a + * {@link BooleanExpression} wrapper); {@code null} + * yields {@link #EMPTY} + * @return the true/false binding sets; never {@code null} + */ + public static InstanceofFlowBindings of(final Expression expression) { + return expression == null ? EMPTY : analyse(expression); + } + + /** + * Returns {@code true} if {@code expression} contains any JEP 394 + * type-pattern node at any depth. Full subtree walk (unlike {@link #of}). + * Used by tests and diagnostics; classgen has its own structural check + * for expression-statement isolation. + */ + public static boolean containsPattern(final Expression expression) { + if (expression == null) return false; + boolean[] found = {false}; + expression.visit(new CodeVisitorSupport() { + @Override + public void visitBinaryExpression(final BinaryExpression be) { + if (found[0]) return; + int op = be.getOperation().getType(); + if ((op == Types.KEYWORD_INSTANCEOF || op == Types.COMPARE_NOT_INSTANCEOF) + && isTypePattern(be.getRightExpression())) { + found[0] = true; + return; + } + super.visitBinaryExpression(be); + } + }); + return found[0]; + } + + /** + * Names of all pattern variables in {@code expression}, regardless + * of definite-assignment path. Full tree walk; for tests/diagnostics. + * Classgen tracks allocated slots via CompileStack instead. + */ + public static Set allPatternNames(final Expression expression) { + if (expression == null) return Collections.emptySet(); + Set names = new LinkedHashSet<>(); + expression.visit(new CodeVisitorSupport() { + @Override + public void visitBinaryExpression(final BinaryExpression be) { + int op = be.getOperation().getType(); + if ((op == Types.KEYWORD_INSTANCEOF || op == Types.COMPARE_NOT_INSTANCEOF) + && isTypePattern(be.getRightExpression())) { + names.add(((DeclarationExpression) be.getRightExpression()) + .getVariableExpression().getName()); + return; + } + super.visitBinaryExpression(be); + } + }); + return Collections.unmodifiableSet(names); + } + + // ----------------------------------------------------------------- + // Internal recursive descent + // ----------------------------------------------------------------- + + /** + * Recursive descent over the boolean algebra of the condition. + * Only operators that propagate definite-assignment are followed + * ({@code instanceof}, {@code !instanceof}, {@code !}, {@code &&}, + * {@code ||}). Other shapes return {@link #EMPTY} conservatively. + */ + private static InstanceofFlowBindings analyse(final Expression expression) { + Expression expr = expression; + while (expr instanceof BooleanExpression && !(expr instanceof NotExpression)) { + expr = ((BooleanExpression) expr).getExpression(); + } + if (expr instanceof NotExpression not) { + return analyse(not.getExpression()).negated(); + } + if (expr instanceof BinaryExpression binary) { + int op = binary.getOperation().getType(); + if (op == Types.KEYWORD_INSTANCEOF) { + return ofInstanceof(binary); + } + if (op == Types.COMPARE_NOT_INSTANCEOF) { + return ofInstanceof(binary).negated(); + } + if (op == Types.LOGICAL_AND) { + InstanceofFlowBindings left = analyse(binary.getLeftExpression()); + InstanceofFlowBindings right = analyse(binary.getRightExpression()); + // True only when both sides are true → union true-sets. + return new InstanceofFlowBindings(union(left.whenTrue, right.whenTrue), List.of()); + } + if (op == Types.LOGICAL_OR) { + InstanceofFlowBindings left = analyse(binary.getLeftExpression()); + InstanceofFlowBindings right = analyse(binary.getRightExpression()); + // False only when both sides are false → union false-sets. + return new InstanceofFlowBindings(List.of(), union(left.whenFalse, right.whenFalse)); + } + } + return EMPTY; + } + + private static InstanceofFlowBindings ofInstanceof(final BinaryExpression binary) { + Expression right = binary.getRightExpression(); + if (isTypePattern(right)) { + VariableExpression patternVar = + ((DeclarationExpression) right).getVariableExpression(); + return new InstanceofFlowBindings(List.of(patternVar), List.of()); + } + return EMPTY; + } + + private InstanceofFlowBindings negated() { + return isEmpty() ? this : new InstanceofFlowBindings(whenFalse, whenTrue); + } + + private static List union(final List a, + final List b) { + if (a.isEmpty()) return b; + if (b.isEmpty()) return a; + List result = new ArrayList<>(a.size() + b.size()); + Set seen = new LinkedHashSet<>(); + for (VariableExpression ve : a) if (seen.add(ve.getName())) result.add(ve); + for (VariableExpression ve : b) if (seen.add(ve.getName())) result.add(ve); + return List.copyOf(result); + } + + private static Set namesOf(final List vars) { + if (vars.isEmpty()) return Collections.emptySet(); + if (vars.size() == 1) return Set.of(vars.get(0).getName()); + Set result = new LinkedHashSet<>(vars.size()); + for (VariableExpression ve : vars) result.add(ve.getName()); + return Collections.unmodifiableSet(result); + } + + private static boolean isTypePattern(final Expression right) { + return right instanceof DeclarationExpression decl + && !decl.isMultipleAssignmentDeclaration() + && decl.getVariableExpression() != null; + } + } } diff --git a/src/main/java/org/codehaus/groovy/classgen/asm/BinaryExpressionHelper.java b/src/main/java/org/codehaus/groovy/classgen/asm/BinaryExpressionHelper.java index 01c3e3dac3e..bf3bf573687 100644 --- a/src/main/java/org/codehaus/groovy/classgen/asm/BinaryExpressionHelper.java +++ b/src/main/java/org/codehaus/groovy/classgen/asm/BinaryExpressionHelper.java @@ -47,12 +47,17 @@ import org.codehaus.groovy.ast.tools.WideningCategories; import org.codehaus.groovy.classgen.AsmClassGenerator; import org.codehaus.groovy.classgen.BytecodeExpression; +import org.codehaus.groovy.classgen.VariableScopeVisitor.InstanceofFlowBindings; import org.codehaus.groovy.runtime.MultipleAssignmentSupport; import org.codehaus.groovy.runtime.ScriptBytecodeAdapter; import org.codehaus.groovy.syntax.Token; import org.objectweb.asm.Label; import org.objectweb.asm.MethodVisitor; +import java.util.Collections; +import java.util.Map; +import java.util.Set; + import static org.apache.groovy.ast.tools.ExpressionUtils.isNullConstant; import static org.codehaus.groovy.ast.tools.GeneralUtils.args; import static org.codehaus.groovy.ast.tools.GeneralUtils.binX; @@ -565,47 +570,44 @@ public void evaluateEqual(final BinaryExpression expression, final boolean defin } // evaluate RHS and store its value - if (lhsType.isArray() && rightExpression instanceof ListExpression) { // array = [ ... ] - Expression array = new ArrayExpression(lhsType.getComponentType(), ((ListExpression) rightExpression).getExpressions()); - array.setSourcePosition(rightExpression); - array.setType(lhsType); - array.visit(acg); - } else if (rightExpression instanceof EmptyExpression) { // define field - CompileStack.pushInitValue(lhsType, mv); - operandStack.push(lhsType); - } else { - rightExpression.visit(acg); - } - - ClassNode rhsType = operandStack.getTopOperand(); - + // GROOVY-12242: for a single-variable declaration, evaluate the RHS in a nested + // CompileStack state so instanceof pattern variables do not leak past the + // declaration while the LHS local is defined in the outer state. if (directAssignment) { - VariableExpression var = (VariableExpression) leftExpression; - if (var.isClosureSharedVariable() && ClassHelper.isPrimitiveType(rhsType)) { - // GROOVY-5570: if a closure shared variable is a primitive type, it must be boxed - rhsType = ClassHelper.getWrapper(rhsType); - operandStack.box(); - } + compileStack.pushState(); + try { + evaluateRightHandSide(lhsType, rightExpression, acg, mv, operandStack); + ClassNode rhsType = operandStack.getTopOperand(); + VariableExpression var = (VariableExpression) leftExpression; + if (var.isClosureSharedVariable() && ClassHelper.isPrimitiveType(rhsType)) { + // GROOVY-5570: if a closure shared variable is a primitive type, it must be boxed + rhsType = ClassHelper.getWrapper(rhsType); + operandStack.box(); + } - // ensure we try to unbox null to cause a runtime NPE in case we assign - // null to a primitive typed variable, even if it is used only in boxed - // form as it is closure shared - if (var.isClosureSharedVariable() && ClassHelper.isPrimitiveType(var.getOriginType()) && isNullConstant(rightExpression)) { - operandStack.doGroovyCast(var.getOriginType()); - // these two are never reached in bytecode and only there - // to avoid verify errors and compiler infrastructure hazzle - operandStack.box(); - operandStack.doGroovyCast(lhsType); - } - // normal type transformation - if (!ClassHelper.isPrimitiveType(lhsType) && isNullConstant(rightExpression)) { - operandStack.replace(lhsType); - } else { - operandStack.doGroovyCast(lhsType); + // ensure we try to unbox null to cause a runtime NPE in case we assign + // null to a primitive typed variable, even if it is used only in boxed + // form as it is closure shared + if (var.isClosureSharedVariable() && ClassHelper.isPrimitiveType(var.getOriginType()) && isNullConstant(rightExpression)) { + operandStack.doGroovyCast(var.getOriginType()); + // these two are never reached in bytecode and only there + // to avoid verify errors and compiler infrastructure hazzle + operandStack.box(); + operandStack.doGroovyCast(lhsType); + } + // normal type transformation + if (!ClassHelper.isPrimitiveType(lhsType) && isNullConstant(rightExpression)) { + operandStack.replace(lhsType); + } else { + operandStack.doGroovyCast(lhsType); + } + } finally { + // Drop RHS pattern locals before defining the LHS in the outer state + compileStack.pop(); } // store value - BytecodeVariable v = compileStack.defineVariable(var, lhsType, true); + BytecodeVariable v = compileStack.defineVariable((Variable) leftExpression, lhsType, true); operandStack.remove(1); if (returnRightValue) { new VariableSlotLoader(lhsType, v.getIndex(), operandStack).visit(acg); @@ -613,6 +615,9 @@ public void evaluateEqual(final BinaryExpression expression, final boolean defin return; } + evaluateRightHandSide(lhsType, rightExpression, acg, mv, operandStack); + ClassNode rhsType = operandStack.getTopOperand(); + // GROOVY-10918: direct store to local variable or parameter (no temp) if (!defineVariable && leftExpression instanceof VariableExpression) { BytecodeVariable v = compileStack.getVariable(leftExpression.getText(), false); @@ -1110,6 +1115,37 @@ protected void evaluateCompoundAssign(final String assignName, final String base controller.getCompileStack().popLHS(); } + /** + * Evaluates the right-hand side of an assignment onto the operand stack. + */ + private void evaluateRightHandSide(final ClassNode lhsType, final Expression rightExpression, + final AsmClassGenerator acg, final MethodVisitor mv, + final OperandStack operandStack) { + if (lhsType.isArray() && rightExpression instanceof ListExpression) { // array = [ ... ] + Expression array = new ArrayExpression(lhsType.getComponentType(), ((ListExpression) rightExpression).getExpressions()); + array.setSourcePosition(rightExpression); + array.setType(lhsType); + array.visit(acg); + } else if (rightExpression instanceof EmptyExpression) { // define field + CompileStack.pushInitValue(lhsType, mv); + operandStack.push(lhsType); + } else { + rightExpression.visit(acg); + } + } + + /** + * Emits bytecode for {@code e instanceof T} and, when the right-hand side is + * a JEP 394 type pattern ({@code e instanceof T t}), conditionally stores + * the checked value into the pattern variable {@code t}. + *

+ * The slot is allocated and {@linkplain CompileStack#recordPatternVariable + * recorded} immediately so a short-circuit {@code &&} RHS can reference + * {@code t}. Path-level name visibility after the condition is administered + * by CompileStack hide/push/pop (see {@link StatementWriter#writeIfElse}). + * + * @param expression an {@code instanceof} binary expression + */ private void evaluateInstanceof(final BinaryExpression expression) { CompileStack compileStack = controller.getCompileStack(); OperandStack operandStack = controller.getOperandStack(); @@ -1117,25 +1153,25 @@ private void evaluateInstanceof(final BinaryExpression expression) { expression.getLeftExpression().visit(controller.getAcg()); operandStack.box(); // TODO: support instanceof primitives - //ClassNode sourceType = operandStack.getTopOperand(); ClassNode targetType = expression.getRightExpression().getType(); - - var jep394 = !(expression.getRightExpression() instanceof ClassExpression); - if (jep394) { - operandStack.dup(); // stash value for use by JEP 394 pattern variable + // JEP 394: RHS is DeclarationExpression (Type name) rather than ClassExpression + boolean patternMatch = !(expression.getRightExpression() instanceof ClassExpression); + if (patternMatch) { + operandStack.dup(); // stash value for the pattern variable store } String typeName = BytecodeHelper.getClassInternalName(targetType); controller.getMethodVisitor().visitTypeInsn(INSTANCEOF, typeName); operandStack.replace(ClassHelper.boolean_TYPE); - if (jep394) { + if (patternMatch) { var variable = (Variable) ((BinaryExpression) expression.getRightExpression()).getLeftExpression(); BytecodeVariable v = compileStack.defineVariable(variable, targetType, false); + compileStack.recordPatternVariable(v); MethodVisitor mv = controller.getMethodVisitor(); mv.visitInsn(DUP_X1); // stack: ..., check, value, check - Label l0 = operandStack.jump(IFEQ); // skip store if not instanceof + Label notInstance = operandStack.jump(IFEQ); // skip store if not instanceof mv.visitTypeInsn(CHECKCAST, typeName); if (!v.isHolder()) { @@ -1146,16 +1182,29 @@ private void evaluateInstanceof(final BinaryExpression expression) { mv.visitInsn(SWAP); mv.visitMethodInsn(INVOKEVIRTUAL, "groovy/lang/Reference", "set", "(Ljava/lang/Object;)V", false); } - Label l1 = operandStack.jump(GOTO); + Label done = operandStack.jump(GOTO); - mv.visitLabel(l0); // stack: ..., check, value + mv.visitLabel(notInstance); // stack: ..., check, value mv.visitInsn(POP); - mv.visitLabel(l1); // stack: ..., check + mv.visitLabel(done); // stack: ..., check operandStack.push(ClassHelper.boolean_TYPE); } } + /** + * Emits bytecode for {@code e !instanceof T} and for the JEP 394 + * type pattern form {@code e !instanceof T t}. + *

+ * Implemented as {@code !(e instanceof T [t])} so pattern store logic in + * {@link #evaluateInstanceof} is shared: the pattern local is assigned when + * the value is an instance of {@code T} (i.e. when the overall + * {@code !instanceof} result is false). Path visibility follows + * {@code InstanceofFlowBindings} for {@code COMPARE_NOT_INSTANCEOF} + * (whenTrue/whenFalse swapped relative to plain {@code instanceof}). + * + * @param expression a {@code !instanceof} binary expression + */ private void evaluateNotInstanceof(final BinaryExpression expression) { unaryExpressionHelper.writeNotExpression( notX( @@ -1441,23 +1490,36 @@ private void evaluateTernaryExpression(final TernaryExpression expression) { ClassNode commonType = WideningCategories.lowestUpperBound(truePartType, falsePartType); // write "x?y:z" as "x?T(y):T(z)" where T is common type of y and z + CompileStack compileStack = controller.getCompileStack(); OperandStack operandStack = controller.getOperandStack(); MethodVisitor mv = controller.getMethodVisitor(); - // load x + // load x; path-hide pattern locals via CompileStack push/hide/pop (GROOVY-12242) + InstanceofFlowBindings bindings = InstanceofFlowBindings.get(expression); + Map beforePatterns = compileStack.snapshotPatternVariables(); boolPart.visit(controller.getAcg()); + Set introduced = compileStack.patternVariablesIntroducedSince(beforePatterns); Label l0 = operandStack.jump(IFEQ); - // true path: load y and cast to T + // true path: only whenTrue names visible among those this condition introduced + compileStack.pushState(); + compileStack.hidePatternVariablesExcept(introduced, bindings.whenTrueNames()); truePart.visit(controller.getAcg()); operandStack.doGroovyCast(commonType); + compileStack.pop(); Label l1 = new Label(); mv.visitJumpInsn(GOTO, l1); - // false path: load z and cast to T + // false path: only whenFalse names visible mv.visitLabel(l0); + compileStack.pushState(); + compileStack.hidePatternVariablesExcept(introduced, bindings.whenFalseNames()); falsePart.visit(controller.getAcg()); operandStack.doGroovyCast(commonType); + compileStack.pop(); + + // After ternary, names introduced by this condition leave scope. + compileStack.hidePatternVariablesExcept(introduced, Collections.emptySet()); // finish up mv.visitLabel(l1); diff --git a/src/main/java/org/codehaus/groovy/classgen/asm/CompileStack.java b/src/main/java/org/codehaus/groovy/classgen/asm/CompileStack.java index 796617f32f4..c708a287c0b 100644 --- a/src/main/java/org/codehaus/groovy/classgen/asm/CompileStack.java +++ b/src/main/java/org/codehaus/groovy/classgen/asm/CompileStack.java @@ -36,9 +36,11 @@ import java.util.Collections; import java.util.Deque; import java.util.HashMap; +import java.util.HashSet; import java.util.LinkedList; import java.util.List; import java.util.Map; +import java.util.Set; import static org.objectweb.asm.Opcodes.ACONST_NULL; import static org.objectweb.asm.Opcodes.ASTORE; @@ -101,6 +103,14 @@ public class CompileStack { private final Deque temporaryVariables = new LinkedList<>(); /** overall used variables for a method/constructor */ private final Deque usedVariables = new LinkedList<>(); + /** + * Pattern variables defined by {@code instanceof} type patterns in this method. + * Keys are names; values are the slots allocated by {@code evaluateInstanceof}. + * The name→slot mapping in {@link #stackVariables} may be hidden/restored via + * {@link #hideVariable(String)} and {@link #pushState()}/{@link #pop()}, but + * the slot indices recorded here remain valid for the whole method. + */ + private final Map patternVariables = new HashMap<>(); /** map containing named labels of parenting blocks */ private Map superBlockNamedLabels = new HashMap<>(); /** map containing named labels of current block */ @@ -476,6 +486,7 @@ public void clear() { untypedExceptions.clear(); stackVariables.clear(); usedVariables.clear(); + patternVariables.clear(); finallyBlocks.clear(); resetVariableIndex(false); superBlockNamedLabels.clear(); @@ -908,6 +919,108 @@ public boolean containsVariable(final String name) { return stackVariables.containsKey(name); } + /** + * Records that {@code variable} was allocated as an {@code instanceof} pattern + * binding. The slot is already present in {@link #stackVariables}; this registry + * lets later control-flow boundaries hide names while keeping the slot indices. + *

+ * GROOVY-12242 / JEP 394: pattern slots are defined during condition evaluation + * (needed for short-circuit {@code &&} RHS). After the condition, names that are + * not live on a path must be hidden so (1) a same-named local can be declared + * on that path and (2) name lookup does not load a slot that scoping forbids. + *

+ * Re-recording the same name replaces the previous registry entry (a later + * condition may re-bind the name to a new slot). + * + * @param variable the pattern local just defined; ignored if {@code null} + * @see #hideVariable(String) + * @see #hidePatternVariablesExcept(Collection, Collection) + */ + public void recordPatternVariable(final BytecodeVariable variable) { + if (variable != null) { + patternVariables.put(variable.getName(), variable); + } + } + + /** + * Hides a named variable from name resolution while leaving its slot index + * allocated. The name becomes free in the current state frame only. + *

+ * Call this inside a {@link #pushState()}/{@link #pop()} region so that + * {@code pop} restores the previous name map (and thus re-exposes the variable + * if it was visible in the outer frame). Using hide without a matching push + * permanently removes the name from the current frame — the intended form for + * “after this construct the name is no longer in scope”. + *

+ * This is the CompileStack-native mechanism for flow-scoped pattern variables + * (GROOVY-12242). It deliberately does not free the register: the value may + * still be needed on another path that re-exposes the same slot via pop or a + * later path that still lists the name as live. + * + * @param name the variable name to hide; no-op if absent + */ + public void hideVariable(final String name) { + stackVariables.remove(name); + } + + /** + * Snapshot of the pattern-variable registry (name → slot). Used to compute + * which names a condition evaluation introduced or re-bound by + * comparing {@link BytecodeVariable} identity before and after the visit. + * A name-only set is insufficient when a later condition reuses a pattern name. + * + * @return an immutable copy of the registry; empty if none recorded + */ + public Map snapshotPatternVariables() { + if (patternVariables.isEmpty()) return Collections.emptyMap(); + return Map.copyOf(patternVariables); + } + + /** + * Names whose registry entry is new or whose {@link BytecodeVariable} identity + * differs from {@code before} (re-bind of the same name). + * + * @param before snapshot from {@link #snapshotPatternVariables()} taken before + * evaluating the condition; may be empty + * @return names introduced or re-bound by the condition that just ran + */ + public Set patternVariablesIntroducedSince(final Map before) { + if (patternVariables.isEmpty()) return Collections.emptySet(); + if (before == null || before.isEmpty()) { + return Set.copyOf(patternVariables.keySet()); + } + Set introduced = new HashSet<>(); + for (Map.Entry e : patternVariables.entrySet()) { + if (before.get(e.getKey()) != e.getValue()) { + introduced.add(e.getKey()); + } + } + return introduced; + } + + /** + * Among {@code candidates}, hides every name that is not in {@code liveNames}. + * Only names that are both candidates and recorded pattern variables are affected; + * outer pattern variables not listed in {@code candidates} stay untouched. + *

+ * Must be used with {@link #pushState()}/{@link #pop()} (or as a permanent hide + * on the current frame) as described by {@link #hideVariable(String)}. + * + * @param candidates names introduced by the current condition; may be empty + * @param liveNames names that must remain visible; may be empty or {@code null} + * (both mean hide every candidate) + */ + public void hidePatternVariablesExcept(final Collection candidates, + final Collection liveNames) { + if (candidates == null || candidates.isEmpty()) return; + for (String name : candidates) { + if (!patternVariables.containsKey(name)) continue; + if (liveNames == null || !liveNames.contains(name)) { + hideVariable(name); + } + } + } + /** * Calculates the index of the next free register stores it * and sets the current variable index to the old value diff --git a/src/main/java/org/codehaus/groovy/classgen/asm/StatementWriter.java b/src/main/java/org/codehaus/groovy/classgen/asm/StatementWriter.java index 2154f9ef009..a412bba7b23 100644 --- a/src/main/java/org/codehaus/groovy/classgen/asm/StatementWriter.java +++ b/src/main/java/org/codehaus/groovy/classgen/asm/StatementWriter.java @@ -23,6 +23,7 @@ import org.codehaus.groovy.ast.VariableScope; import org.codehaus.groovy.ast.expr.BinaryExpression; import org.codehaus.groovy.ast.expr.ClosureListExpression; +import org.codehaus.groovy.ast.expr.DeclarationExpression; import org.codehaus.groovy.ast.expr.EmptyExpression; import org.codehaus.groovy.ast.expr.Expression; import org.codehaus.groovy.ast.expr.MethodCall; @@ -44,14 +45,20 @@ import org.codehaus.groovy.ast.stmt.ThrowStatement; import org.codehaus.groovy.ast.stmt.TryCatchStatement; import org.codehaus.groovy.ast.stmt.WhileStatement; +import org.codehaus.groovy.ast.CodeVisitorSupport; import org.codehaus.groovy.classgen.AsmClassGenerator; +import org.codehaus.groovy.classgen.VariableScopeVisitor.InstanceofFlowBindings; import org.codehaus.groovy.classgen.asm.CompileStack.BlockRecorder; +import org.codehaus.groovy.syntax.Types; import org.objectweb.asm.Label; import org.objectweb.asm.MethodVisitor; +import java.util.HashSet; import java.util.Iterator; import java.util.List; +import java.util.Map; import java.util.Optional; +import java.util.Set; import java.util.function.Consumer; import static org.apache.groovy.ast.tools.ExpressionUtils.isNullConstant; @@ -470,6 +477,21 @@ public void writeDoWhileLoop(final DoWhileStatement statement) { /** * Generates bytecode for an if/else statement. + *

+ * GROOVY-7463: a labeled {@code if} registers a breakable frame around the + * then arm only (same region as before GROOVY-12242). The else arm + * runs after that frame is popped so labeled-break scoping for the then + * arm is unchanged. Named break labels remain registered for the method + * (CompileStack does not un-register them on pop). + *

+ * GROOVY-12242 / JEP 394: pattern slots are allocated on the outer frame + * during the condition (needed for short-circuit {@code &&} RHS). Each arm + * then uses {@link CompileStack#pushState}/{@link CompileStack#hidePatternVariablesExcept}/ + * {@link CompileStack#pop} so only path-live names are visible. Path-live + * names come from {@link InstanceofFlowBindings} metadata attached by + * {@link org.codehaus.groovy.classgen.VariableScopeVisitor} — classgen + * does not re-run the flow analysis. After both arms, non-survivors are + * permanently hidden on the outer frame (JLS §6.3.2.2-200-C). * * @param statement the if statement to compile */ @@ -477,22 +499,55 @@ public void writeIfElse(final IfStatement statement) { controller.getAcg().onLineNumber(statement, "visitIfElse"); writeStatementLabel(statement); - Label exitPath = controller.getCompileStack().pushBreakable(statement.getStatementLabels()); // GROOVY-7463 + CompileStack compileStack = controller.getCompileStack(); + // Name view of the visitor's analysis; never re-analyse the condition. + InstanceofFlowBindings bindings = InstanceofFlowBindings.get(statement); + + // Pattern slots on the outer frame so survivors need no put-back after pop. + Map beforePatterns = compileStack.snapshotPatternVariables(); statement.getBooleanExpression().visit(controller.getAcg()); + Set introduced = compileStack.patternVariablesIntroducedSince(beforePatterns); + + // GROOVY-7463: breakable wraps then only (historical region). + Label exitPath = compileStack.pushBreakable(statement.getStatementLabels()); + + boolean ifFallsThrough = maybeFallsThrough(statement.getIfBlock()); + boolean elseEmpty = statement.getElseBlock().isEmpty(); + boolean elseFallsThrough = elseEmpty || maybeFallsThrough(statement.getElseBlock()); + + // Then path: only whenTrue names among those this condition introduced. Label elsePath = controller.getOperandStack().jump(IFEQ); + compileStack.pushState(); + compileStack.hidePatternVariablesExcept(introduced, bindings.whenTrueNames()); statement.getIfBlock().visit(controller.getAcg()); - controller.getCompileStack().pop(); + compileStack.pop(); + compileStack.pop(); // ends breakable (before else — same as pre-GROOVY-12242) MethodVisitor mv = controller.getMethodVisitor(); - if (statement.getElseBlock().isEmpty()) { + if (elseEmpty) { mv.visitLabel(elsePath); } else { - if (maybeFallsThrough(statement.getIfBlock())) { + if (ifFallsThrough) { mv.visitJumpInsn(GOTO, exitPath); } mv.visitLabel(elsePath); + // Else path (outside breakable): only whenFalse names. + compileStack.pushState(); + compileStack.hidePatternVariablesExcept(introduced, bindings.whenFalseNames()); statement.getElseBlock().visit(controller.getAcg()); + compileStack.pop(); + } + + // Survivors per JLS §6.3.2.2-200-C on the outer frame. + Set survivors = new HashSet<>(); + if (!ifFallsThrough) { + survivors.addAll(bindings.whenFalseNames()); } + if (!elseEmpty && !elseFallsThrough) { + survivors.addAll(bindings.whenTrueNames()); + } + survivors.retainAll(introduced); + compileStack.hidePatternVariablesExcept(introduced, survivors); mv.visitLabel(exitPath); } @@ -883,6 +938,10 @@ public void writeReturn(final ReturnStatement statement) { * Evaluates the expression and discards any value left on the operand stack. * Marks method-call and binary expressions so that unused return values * are elided rather than boxed. + *

+ * GROOVY-12242: non-declaration statements that contain an {@code instanceof} + * type pattern run in a nested CompileStack state ({@code pushState}/{@code pop}) + * so pattern locals defined during evaluation cannot leak past the statement. * * @param statement the expression statement to compile */ @@ -895,9 +954,48 @@ public void writeExpressionStatement(final ExpressionStatement statement) { if (expression instanceof MethodCall || expression instanceof BinaryExpression) expression.putNodeMetaData(AsmClassGenerator.ELIDE_EXPRESSION_VALUE, Boolean.TRUE); - var operandStack = controller.getOperandStack(); - int mark = operandStack.getStackLength(); - expression.visit(controller.getAcg()); - operandStack.popDownTo(mark); + CompileStack compileStack = controller.getCompileStack(); + // Declaration LHS isolation is in evaluateEqual; multi-assign must not be wrapped. + boolean isolatesPatternVars = !(expression instanceof DeclarationExpression) + && containsTypePattern(expression); + if (isolatesPatternVars) { + compileStack.pushState(); + } + try { + var operandStack = controller.getOperandStack(); + int mark = operandStack.getStackLength(); + expression.visit(controller.getAcg()); + operandStack.popDownTo(mark); + } finally { + if (isolatesPatternVars) { + compileStack.pop(); + } + } + } + + /** + * Structural check: does {@code expression} contain any JEP 394 type + * pattern ({@code e instanceof T t})? Not a flow analysis — used only to + * decide whether an expression statement needs CompileStack isolation. + */ + private static boolean containsTypePattern(final Expression expression) { + if (expression == null) return false; + boolean[] found = {false}; + expression.visit(new CodeVisitorSupport() { + @Override + public void visitBinaryExpression(final BinaryExpression be) { + if (found[0]) return; + int op = be.getOperation().getType(); + if ((op == Types.KEYWORD_INSTANCEOF || op == Types.COMPARE_NOT_INSTANCEOF) + && be.getRightExpression() instanceof DeclarationExpression decl + && !decl.isMultipleAssignmentDeclaration() + && decl.getVariableExpression() != null) { + found[0] = true; + return; + } + super.visitBinaryExpression(be); + } + }); + return found[0]; } } diff --git a/src/spec/doc/core-semantics.adoc b/src/spec/doc/core-semantics.adoc index 1f4319c9819..3efc02e1610 100644 --- a/src/spec/doc/core-semantics.adoc +++ b/src/spec/doc/core-semantics.adoc @@ -485,6 +485,52 @@ It is possible to use labels in the `break` instruction as a target for jump, as include::../test/semantics/LabelsTest.groovy[tags=break_label,indent=0] ---- +=== `instanceof` / `!instanceof` pattern variables (JEP 394) + +Since Groovy 6, a type pattern on `instanceof` or `!instanceof` introduces a +*pattern variable* whose scope follows Java's flow rules (JLS §6.3 / JEP 394) +for the common shapes used in application code: + +[source,groovy] +---- +Object o = 'hi' +if (o instanceof String s) { + assert s.toUpperCase() == 'HI' // s is a local in the then-block +} else { + // s is *not* in scope here (dynamic: MissingPropertyException; static: error) +} +// s is not in scope after a fall-through if either + +if (o !instanceof Integer i) { + // i is *not* in scope here (condition true means o is not an Integer) +} else { + assert i instanceof Integer // i is bound when !instanceof is false +} +---- + +The form `e !instanceof T t` is equivalent to `!(e instanceof T t)` for both +binding and scoping. Short-circuit boolean operators and ternary arms respect +the same “definitely bound on this path” rules (`&&` exposes true-path bindings +on the right-hand side; `||` does not). Under `@TypeChecked` / `@CompileStatic` +the type checker rejects out-of-scope uses at compile time; in dynamic Groovy +the same uses become dynamic property lookups and typically throw +`groovy.lang.MissingPropertyException` at runtime. + +[NOTE] +==== +*While loops (intentional partial support).* A `while` condition's true-path +pattern variables are in scope in the loop body, and short-circuit rules apply +inside the condition. Groovy does *not* introduce false-path bindings *after* +the loop when the body cannot complete normally (unlike the full JLS while +rule). Pattern names never leak past the loop. Full after-loop introduction may +be considered later; it is not part of the 6.0 contract. +==== + +This is a **breaking change** from earlier Groovy releases that left pattern +locals visible more widely in dynamic mode. See +link:https://issues.apache.org/jira/browse/GROOVY-12242[GROOVY-12242] and +`COMPATIBILITY.md`. + == Expressions Expressions are the building blocks of Groovy programs that are used to reference diff --git a/src/test/groovy/groovy/BreakContinueLabelTest.groovy b/src/test/groovy/groovy/BreakContinueLabelTest.groovy index 43723263bb8..03a08ec31d2 100644 --- a/src/test/groovy/groovy/BreakContinueLabelTest.groovy +++ b/src/test/groovy/groovy/BreakContinueLabelTest.groovy @@ -80,6 +80,50 @@ final class BreakContinueLabelTest { ''' } + /** + * GROOVY-7463 + GROOVY-12242: breakable frame for a labeled {@code if} + * still ends before the else arm (historical region). A break from the + * then-arm of a nested labeled if remains supported; a break from the + * else-arm to the outer if's label is still resolved via the method-level + * named-break map (CompileStack does not un-register names on pop). + */ + @Test + void testBreakLabelFromElseOfLabeledIf() { + assertScript ''' + def log = [] + label: + if (false) { + log << 'then' + assert false + } else { + log << 'else' + break label + log << 'after-break' + } + log << 'after-if' + assert log == ['else', 'after-if'] + ''' + } + + // GROOVY-12242: labeled if + instanceof pattern in then still breaks correctly + @Test + void testBreakLabelInIfWithInstanceofPattern() { + assertScript ''' + def log = [] + Object o = 'x' + label: + if (o instanceof String s) { + log << s + break label + log << 'dead' + } else { + log << 'else' + } + log << 'done' + assert log == ['x', 'done'] + ''' + } + @Test void testBreakLabelInSimpleForLoop() { assertScript ''' diff --git a/src/test/groovy/groovy/InstanceofFlowBindingsTest.groovy b/src/test/groovy/groovy/InstanceofFlowBindingsTest.groovy new file mode 100644 index 00000000000..b6ba66a08a2 --- /dev/null +++ b/src/test/groovy/groovy/InstanceofFlowBindingsTest.groovy @@ -0,0 +1,513 @@ +/* + * 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 groovy + +import org.codehaus.groovy.ast.DynamicVariable +import org.codehaus.groovy.ast.expr.Expression +import org.codehaus.groovy.ast.expr.VariableExpression +import org.codehaus.groovy.ast.stmt.IfStatement +import org.codehaus.groovy.classgen.VariableScopeVisitor.InstanceofFlowBindings +import org.codehaus.groovy.control.CompilationUnit +import org.codehaus.groovy.control.Phases +import org.codehaus.groovy.ast.CodeVisitorSupport +import org.junit.jupiter.api.Test + +final class InstanceofFlowBindingsTest { + + @Test + void testBindingsOfPositiveInstanceof() { + def b = InstanceofFlowBindings.of(parseCondition('o instanceof String s')) + assert b.whenTrue()*.name == ['s'] + assert b.whenFalse().isEmpty() + assert b.allNames() as List == ['s'] + } + + @Test + void testBindingsOfNegatedInstanceof() { + def b = InstanceofFlowBindings.of(parseCondition('!(o instanceof String s)')) + assert b.whenTrue().isEmpty() + assert b.whenFalse()*.name == ['s'] + assert b.allNames() as List == ['s'] + } + + @Test + void testBindingsOfNativeNotInstanceofPattern() { + def b = InstanceofFlowBindings.of(parseCondition('o !instanceof String s')) + assert b.whenTrue().isEmpty() + assert b.whenFalse()*.name == ['s'] + assert b.allNames() as List == ['s'] + // equivalent to !(o instanceof String s) + def negated = InstanceofFlowBindings.of(parseCondition('!(o instanceof String s)')) + assert b.whenTrue()*.name == negated.whenTrue()*.name + assert b.whenFalse()*.name == negated.whenFalse()*.name + } + + @Test + void testContainsPatternNativeNotInstanceof() { + assert InstanceofFlowBindings.containsPattern(parseCondition('o !instanceof String s')) + assert !InstanceofFlowBindings.containsPattern(parseCondition('o !instanceof String')) + } + + @Test + void testAllPatternNamesNativeNotInstanceof() { + def expr = parseCondition('o !instanceof String s') + assert InstanceofFlowBindings.allPatternNames(expr) == ['s'] as Set + } + + @Test + void testBindingsOfAnd() { + def b = InstanceofFlowBindings.of(parseCondition('o instanceof String s && s.length() > 0')) + assert b.whenTrue()*.name == ['s'] + assert b.whenFalse().isEmpty() + } + + @Test + void testBindingsOfOr() { + def b = InstanceofFlowBindings.of(parseCondition('o instanceof String s || s.length() > 0')) + assert b.whenTrue().isEmpty() + assert b.whenFalse().isEmpty() + assert b.isEmpty() + } + + @Test + void testBindingsOfNegatedOrFalsePath() { + def b = InstanceofFlowBindings.of(parseCondition('!(o instanceof String s) || s.isEmpty()')) + // left false-path binds s; OR false bindings = left.whenFalse ∪ right.whenFalse = {s} + assert b.whenFalse()*.name == ['s'] + assert b.whenTrue().isEmpty() + } + + @Test + void testBindingsOfPlainInstanceofAreEmpty() { + def b = InstanceofFlowBindings.of(parseCondition('o instanceof String')) + assert b.isEmpty() + } + + @Test + void testOfNullIsEmpty() { + assert InstanceofFlowBindings.of(null).isEmpty() + } + + @Test + void testContainsPattern() { + assert InstanceofFlowBindings.containsPattern(parseCondition('o instanceof String s')) + assert InstanceofFlowBindings.containsPattern(parseCondition('!(o instanceof String s)')) + assert InstanceofFlowBindings.containsPattern(parseCondition('o instanceof String s && s')) + assert !InstanceofFlowBindings.containsPattern(parseCondition('o instanceof String')) + assert !InstanceofFlowBindings.containsPattern(null) + } + + @Test + void testContainsPatternNestedInCall() { + def expr = parseMethodArg('m(o instanceof String s)') + assert InstanceofFlowBindings.containsPattern(expr) + } + + // allPatternNames() returns ALL pattern variable names in the expression tree, + // regardless of which flow path (whenTrue / whenFalse) they appear on. + // This differs from allNames() which only covers names in the binding sets. + @Test + void testAllPatternNames_positiveInstanceof() { + // simple instanceof: appears in both allNames() and allPatternNames() + def b = InstanceofFlowBindings.of(parseCondition('o instanceof String s')) + def expr = parseCondition('o instanceof String s') + assert InstanceofFlowBindings.allPatternNames(expr) == ['s'] as Set + } + + @Test + void testAllPatternNames_orCondition_returnsNameEvenWhenBindingsEmpty() { + // o instanceof String s || true: bindings EMPTY (no whenTrue, no whenFalse), + // so allNames() returns {}. But allPatternNames() still returns {s} because + // evaluateInstanceof allocates the slot regardless. + def expr = parseCondition('o instanceof String s || true') + def b = InstanceofFlowBindings.of(expr) + assert b.isEmpty() : 'bindings are empty for || shape' + assert b.allNames().isEmpty() : 'allNames() returns {} for || shape' + assert InstanceofFlowBindings.allPatternNames(expr) == ['s'] as Set : 'allPatternNames() returns {s}' + } + + @Test + void testAllPatternNames_andCondition_sameAsAllNames() { + // o instanceof String s && cond: whenTrue={s}, allNames()={s}, allPatternNames()={s} + def expr = parseCondition('o instanceof String s && s.length() > 0') + def b = InstanceofFlowBindings.of(expr) + assert InstanceofFlowBindings.allPatternNames(expr) == b.allNames() + } + + @Test + void testAllPatternNames_nullReturnsEmpty() { + assert InstanceofFlowBindings.allPatternNames(null).isEmpty() + } + + @Test + void testAllPatternNames_noPattern_returnsEmpty() { + def expr = parseCondition('o instanceof String') // no pattern variable + assert InstanceofFlowBindings.allPatternNames(expr).isEmpty() + } + + @Test + void testAllPatternNames_twoPatternsTwoNames() { + def expr = parseCondition2('o instanceof String s && p instanceof Integer i') + assert InstanceofFlowBindings.allPatternNames(expr) == ['s', 'i'] as Set + } + + @Test + void testRightOfOrIsDynamicVariable() { + def src = ''' + class C { + def m(Object o) { + if (o instanceof String s || s.length() > 0) { + return 1 + } + return 0 + } + } + ''' + def cu = new CompilationUnit() + cu.addSource('C.groovy', src) + cu.compile(Phases.SEMANTIC_ANALYSIS) + def accesses = [] + cu.ast.classes[0].getMethods('m')[0].code.visit(new CodeVisitorSupport() { + @Override + void visitVariableExpression(VariableExpression ve) { + if (ve.name == 's') { + accesses << ve.accessedVariable + } + super.visitVariableExpression(ve) + } + }) + assert accesses.any { it instanceof DynamicVariable } : + "expected DynamicVariable for RHS of ||, got: ${accesses*.class*.simpleName}" + } + + @Test + void testNegatedIfBranchIsDynamicVariable() { + def src = ''' + class C { + def m(Object o) { + if (!(o instanceof String s)) { + return s + } + return null + } + } + ''' + def cu = new CompilationUnit() + cu.addSource('C.groovy', src) + cu.compile(Phases.SEMANTIC_ANALYSIS) + def accesses = [] + cu.ast.classes[0].getMethods('m')[0].code.visit(new CodeVisitorSupport() { + @Override + void visitVariableExpression(VariableExpression ve) { + if (ve.name == 's') { + accesses << ve.accessedVariable + } + super.visitVariableExpression(ve) + } + }) + // declaration + use in if-branch (must be DynamicVariable) + assert accesses.count { it instanceof DynamicVariable } >= 1 + } + + // ------------------------------------------------------------------------- + // GROOVY-12242: systematic binding-analysis unit tests aligned with JLS §6.3.1 + // + // JLS §6.3.1 defines flow scoping for pattern variables in expressions. + // The following tests map directly to each sub-section: + // + // §6.3.1.5 (instanceof): e instanceof T t → whenTrue: {t}, whenFalse: {} (no rule) + // §6.3.1.3 (!): !a → whenTrue = a.whenFalse, whenFalse = a.whenTrue + // §6.3.1.1 (&&): a && b → whenTrue = a.whenTrue ∪ b.whenTrue, whenFalse = {} (no rule) + // §6.3.1.2 (||): a || b → whenFalse = a.whenFalse ∪ b.whenFalse, whenTrue = {} (no rule) + // §6.3.1.7 (parens): (a) → same as a (transparent) + // §6.3.1.4 (?:): a ? b : c → no whenTrue / whenFalse bindings (conservative) + // §6.3.1.1-200-A error: same name in a.whenTrue and b.whenTrue of && → compile-time error + // ------------------------------------------------------------------------- + + // JLS §6.3.1.1 Rule B: a&&b when-true = a.whenTrue ∪ b.whenTrue = {s} ∪ {} = {s} + // JLS §6.3.1.1 (note): no rule for when-false of &&. + @Test + void testAndWithPattern_trueBinds_falseEmpty() { + def b = InstanceofFlowBindings.of(parseCondition('o instanceof String s && true')) + assert b.whenTrue()*.name == ['s'] + assert b.whenFalse().isEmpty() + } + + // JLS §6.3.1.2 (note): no rule for when-true of ||. + // JLS §6.3.1.2 Rule B: a||b when-false = a.whenFalse ∪ b.whenFalse. + // For (o instanceof String s): whenFalse = {} (§6.3.1.5: no when-false for instanceof). + // For true: whenFalse = {}. So a||b when-false = {} ∪ {} = {}. Both paths empty. + @Test + void testOrWithPattern_bothEmpty() { + def b = InstanceofFlowBindings.of(parseCondition('o instanceof String s || true')) + assert b.isEmpty() + } + + // JLS §6.3.1.3: !(o instanceof String s) when-true = a.whenFalse = {} (§6.3.1.5 no when-false) + // JLS §6.3.1.1 Rule B: (left.whenTrue={}) && (right.whenTrue={}) → whenTrue = {} + // JLS §6.3.1.1 (note): no rule for when-false of &&. → whenFalse = {} + @Test + void testNegatedAndCond_bothEmpty() { + def b = InstanceofFlowBindings.of(parseCondition('!(o instanceof String s) && true')) + assert b.isEmpty() + } + + // JLS §6.3.1.3: !(o instanceof String s) when-false = a.whenTrue = {s} + // JLS §6.3.1.2 Rule B: a||b when-false = a.whenFalse ∪ b.whenFalse = {s} ∪ {} = {s} + // JLS §6.3.1.2 (note): no rule for when-true of ||. → whenTrue = {} + @Test + void testNegatedOrCond_falseBinds() { + def b = InstanceofFlowBindings.of(parseCondition('!(o instanceof String s) || true')) + assert b.whenFalse()*.name == ['s'] + assert b.whenTrue().isEmpty() + } + + // JLS §6.3.1.1 Rule B: (a&&b) when-true = {s}; negated → when-false = {s}, when-true = {} + // (§6.3.1.3: !expr when-true = expr.whenFalse; !expr when-false = expr.whenTrue) + @Test + void testNegatedAndPattern_falseBindsAfterNegation() { + def b = InstanceofFlowBindings.of(parseCondition('!(o instanceof String s && s.length() > 0)')) + assert b.whenFalse()*.name == ['s'] + assert b.whenTrue().isEmpty() + } + + // JLS §6.3.1.3 applied twice (double negation identity): + // o instanceof String s: whenTrue={s}, whenFalse={} + // !(…): whenTrue={}, whenFalse={s} + // !!(…): whenTrue={s}, whenFalse={} — same as the original (§6.3.1.3 is its own inverse) + @Test + void testDoubleNegation_sameAsPositive() { + def b = InstanceofFlowBindings.of(parseCondition('!!(o instanceof String s)')) + assert b.whenTrue()*.name == ['s'] + assert b.whenFalse().isEmpty() + } + + // JLS §6.3.1.5: a instanceof T t introduces t when true; NO binding when false. + // This is the base axiom — everything else is derived from it. + @Test + void testJLS_6_3_1_5_noWhenFalseForInstanceof() { + // Positive instanceof: whenTrue binds, whenFalse is explicitly empty + def pos = InstanceofFlowBindings.of(parseCondition('o instanceof String s')) + assert pos.whenTrue()*.name == ['s'] : 'JLS §6.3.1.5-100-A: s introduced when true' + assert pos.whenFalse().isEmpty() : 'JLS §6.3.1.5 (note): no rule for when false' + + // Plain instanceof without pattern variable: contributes nothing + def plain = InstanceofFlowBindings.of(parseCondition('o instanceof String')) + assert plain.isEmpty() : 'no type pattern means no binding' + } + + // JLS §6.3.1.7: parenthesized expressions are transparent. + // (a instanceof T t) has exactly the same bindings as a instanceof T t. + @Test + void testJLS_6_3_1_7_parenthesizedExpression_transparent() { + // The Groovy AST wraps the condition in a BooleanExpression; unwrapping happens in analyse(). + // A user-written (o instanceof String s) adds no additional wrapper beyond what the + // if-condition already imposes, so the result must equal the unwrapped case. + def wrapped = InstanceofFlowBindings.of(parseCondition('(o instanceof String s)')) + assert wrapped.whenTrue()*.name == ['s'] : 'JLS §6.3.1.7-100-A: parens transparent for whenTrue' + assert wrapped.whenFalse().isEmpty() : 'JLS §6.3.1.7-100-B: parens transparent for whenFalse' + } + + // JLS §6.3.1.4: conditional operator a ? b : c — no whenTrue/whenFalse bindings. + // "It cannot be determined at compile time whether a will evaluate to true." + // InstanceofFlowBindings.analyse() returns EMPTY conservatively for this shape + // (it is not a boolean-algebra operator that propagates definite-assignment). + @Test + void testJLS_6_3_1_4_ternaryConditional_noBindings() { + // The condition `o instanceof String s ? true : false` cannot propagate + // the binding of s beyond the ternary — no scope rule exists for ?: + // (§6.3.1.4 note). analyse() sees a non-recognised expression shape → EMPTY. + def b = InstanceofFlowBindings.of(parseCondition('o instanceof String s ? true : false')) + assert b.isEmpty() : 'JLS §6.3.1.4 (note): no whenTrue/false rule for ?:' + } + + // (4) o instanceof String s && p instanceof Integer i (JLS §6.3.1.1) + // Rule B: a&&b when-true = a.whenTrue ∪ b.whenTrue = {s} ∪ {i} = {s, i} + // Rule A: s (introduced by a when true) is definitely matched at b — legal, + // different names, no §6.3.1.1-200-A error. + // No rule for when-false (§6.3.1.1 note: cannot determine which side failed). + @Test + void testAndWithTwoPatterns_bothInTrue() { + def b = InstanceofFlowBindings.of( + parseCondition2('o instanceof String s && p instanceof Integer i')) + assert b.whenTrue()*.name as Set == ['s', 'i'] as Set + assert b.whenFalse().isEmpty() + } + + // (5) o instanceof String s || p instanceof Integer i (JLS §6.3.1.2) + // Rule B: a||b when-false = a.whenFalse ∪ b.whenFalse + // a.whenFalse for (o instanceof String s) = {} (§6.3.1.5: no when-false rule for instanceof) + // b.whenFalse for (p instanceof Integer i) = {} + // a||b when-false = {} ∪ {} = {} + // No rule for when-true (§6.3.1.2 note: cannot determine which side was true). + // Different names s/i → no §6.3.1.2-200-A or -200-B error. + @Test + void testOrWithTwoPatterns_bothEmpty() { + def b = InstanceofFlowBindings.of( + parseCondition2('o instanceof String s || p instanceof Integer i')) + assert b.isEmpty() + } + + // !(a instanceof String s) || !(b instanceof Integer i) (JLS §6.3.1.2 + §6.3.1.3) + // !(o instanceof String s): when-false = {s} (§6.3.1.3: !a when-false = a.whenTrue) + // !(p instanceof Integer i): when-false = {i} + // Rule A: s (introduced by left when false) is in scope at right (§6.3.1.2-100-A). + // Rule B: a||b when-false = {s} ∪ {i} = {s, i} + // Different names s/i → no §6.3.1.2-200-B error. + @Test + void testOrWithTwoNegatedPatterns_falseHasBoth() { + def b = InstanceofFlowBindings.of( + parseCondition2('!(o instanceof String s) || !(p instanceof Integer i)')) + assert b.whenFalse()*.name as Set == ['s', 'i'] as Set + assert b.whenTrue().isEmpty() + } + + // isEmpty() is false when only whenFalse is non-empty + @Test + void testIsEmpty_falseWhenOnlyFalseSide() { + def b = InstanceofFlowBindings.of(parseCondition('!(o instanceof String s)')) + assert !b.isEmpty() + assert b.whenTrue().isEmpty() + assert !b.whenFalse().isEmpty() + } + + // allNames() returns names from both sides (stable order) + @Test + void testAllNames_mergesBothSides() { + // construct a condition that has s on true and t on false: + // (o instanceof String s && true) || !(o instanceof Integer t) + // → true: {} (|| doesn't guarantee), false: {t} (from right) + // Use allNames on something that has both sides: + // !(o instanceof String s) alone: whenFalse={s}, whenTrue={}; allNames={s} + def b1 = InstanceofFlowBindings.of(parseCondition('!(o instanceof String s)')) + assert b1.allNames() == ['s'] as Set + + // positive: whenTrue={s}, whenFalse={} + def b2 = InstanceofFlowBindings.of(parseCondition('o instanceof String s')) + assert b2.allNames() == ['s'] as Set + } + + // whenTrueNames() / whenFalseNames() return Set (not List) + // Uses a legal condition; tests that the return type is a Set. + @Test + void testNameSets_returnSets() { + // o instanceof String s && true → whenTrue introduces {s}, whenFalse is {} + def b = InstanceofFlowBindings.of(parseCondition('o instanceof String s && true')) + assert b.whenTrueNames() instanceof Set + assert b.whenFalseNames() instanceof Set + assert b.whenTrueNames() == ['s'] as Set + assert b.whenFalseNames().isEmpty() + } + + // JLS §6.3.1.1-200-A: it is a compile-time error when the same pattern variable name + // is introduced by BOTH operands of &&. InstanceofFlowBindings is a pre-error analysis + // (it runs before the error is reported) and will conservatively union the sets; the + // resulting program is still rejected at compile time by VariableScopeVisitor. + // This test documents that behavior without asserting on specific names (the analysis + // result for rejected input is unspecified / implementation-defined). + @Test + void testJLS_6_3_1_1_DuplicateNameIsCompileError_analysisStillRuns() { + // 'o instanceof String s && o instanceof String s' — same name s on both sides, + // §6.3.1.1-200-A error. The analysis sees both, union deduplicates to size 1. + // We only check it doesn't throw; the compiler will still reject the source. + def b = InstanceofFlowBindings.of(parseCondition('o instanceof String s && o instanceof String s')) + // whenTrue may contain s (deduped) — at minimum the result is not null / EMPTY + assert b != null + // The union of {s} and {s} should deduplicate to one entry + assert b.whenTrueNames().size() == 1 : 'union should deduplicate same-named vars' + assert b.whenFalseNames().isEmpty() : 'no false-path bindings for &&' + } + + private static Expression parseCondition(String condition) { + def src = """ + class C { + def m(Object o) { + if ($condition) return 1 + return 0 + } + } + """ + def cu = new CompilationUnit() + cu.addSource('C.groovy', src) + cu.compile(Phases.CONVERSION) + IfStatement ifStmt = null + cu.ast.classes[0].getMethods('m')[0].code.visit(new CodeVisitorSupport() { + @Override + void visitIfElse(IfStatement statement) { + ifStmt = statement + } + }) + ifStmt.booleanExpression.expression + } + + private static Expression parseMethodArg(String statement) { + def src = """ + class C { + def m(Object o) { + $statement + } + } + """ + def cu = new CompilationUnit() + cu.addSource('C.groovy', src) + cu.compile(Phases.CONVERSION) + Expression found = null + cu.ast.classes[0].getMethods('m')[0].code.visit(new CodeVisitorSupport() { + @Override + void visitMethodCallExpression(org.codehaus.groovy.ast.expr.MethodCallExpression call) { + if (call.methodAsString == 'm' || call.objectExpression.text == 'this') { + found = call + } + super.visitMethodCallExpression(call) + } + @Override + void visitExpressionStatement(org.codehaus.groovy.ast.stmt.ExpressionStatement stmt) { + found = stmt.expression + super.visitExpressionStatement(stmt) + } + }) + found + } + + /** + * Variant of {@link #parseCondition} for conditions that reference two variables + * ({@code o} and {@code p}), e.g. multi-pattern {@code &&} / {@code ||} combinations. + */ + private static Expression parseCondition2(String condition) { + def src = """ + class C { + def m(Object o, Object p) { + if ($condition) return 1 + return 0 + } + } + """ + def cu = new CompilationUnit() + cu.addSource('C.groovy', src) + cu.compile(Phases.CONVERSION) + IfStatement ifStmt = null + cu.ast.classes[0].getMethods('m')[0].code.visit(new CodeVisitorSupport() { + @Override + void visitIfElse(IfStatement statement) { + ifStmt = statement + } + }) + ifStmt.booleanExpression.expression + } +} diff --git a/src/test/groovy/groovy/InstanceofScopeTest.groovy b/src/test/groovy/groovy/InstanceofScopeTest.groovy new file mode 100644 index 00000000000..85f7567552f --- /dev/null +++ b/src/test/groovy/groovy/InstanceofScopeTest.groovy @@ -0,0 +1,917 @@ +/* + * 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 groovy + +import org.codehaus.groovy.ast.CodeVisitorSupport +import org.codehaus.groovy.ast.DynamicVariable +import org.codehaus.groovy.ast.Variable +import org.codehaus.groovy.ast.expr.VariableExpression +import org.codehaus.groovy.control.CompilationUnit +import org.codehaus.groovy.control.Phases +import org.junit.jupiter.api.Test + +/** + * AST-level scope tests for JEP 394 {@code instanceof} pattern variables + * (GROOVY-12242), verifying that {@link org.codehaus.groovy.classgen.VariableScopeVisitor} + * correctly scopes each pattern variable for every condition shape in the + * visibility matrix. + *

+ * Each test compiles to {@link Phases#SEMANTIC_ANALYSIS} (which runs + * {@code VariableScopeVisitor}) and then inspects + * {@link VariableExpression#getAccessedVariable()} to verify that: + *

+ * + *

Visibility matrix (JLS §6.3.2.2 / JEP 394)

+ *
+ *  #  | Condition shape                       | if-block | else-block | after if-else
+ *  ---|--------------------------------------|----------|------------|---------------
+ *  1  | o instanceof String s                 | local    | dynamic    | dynamic (*)
+ *  1b | o instanceof String s, else abrupt    | local    | (abrupt)   | local
+ *  2  | !(o instanceof String s)              | dynamic  | local      | —
+ *  2n | o !instanceof String s  (native)      | dynamic  | local      | dynamic  (≡ 2)
+ *  3  | !(o instanceof String s), if abrupt   | (abrupt) | local      | local
+ *  4  | o instanceof String s && cond         | local    | dynamic    | dynamic
+ *  5  | o instanceof String s || cond         | dynamic  | dynamic    | dynamic
+ *  6  | !(o instanceof String s) && cond      | dynamic  | —          | dynamic
+ *  7a | !(o instanceof s) && cond, else abrupt| dynamic  | (abrupt)   | dynamic
+ *  7b | !(o instanceof s), abrupt else only   | dynamic  | (abrupt)   | dynamic (**)
+ *  8  | !(o instanceof String s) || cond      | —        | local      | —
+ * 
+ * (*) s NOT visible after when both branches fall through. + * (**) missing case: abrupt else alone is not enough; if-block must + * also be abrupt for §6.3.2.2-200-C-B to apply. + * (2n) native {@code !instanceof} is equivalent to {@code !(instanceof)}. + */ +final class InstanceofScopeTest { + + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + /** + * Compiles {@code src} through {@link Phases#SEMANTIC_ANALYSIS} and + * collects all {@code accessedVariable} values for {@link VariableExpression}s + * that reference a variable named {@code varName} inside method {@code m} of + * class {@code C}, excluding the declaration site itself. + */ + private static List collectAccesses(String src, String varName = 's') { + def cu = new CompilationUnit() + cu.addSource('C.groovy', src) + cu.compile(Phases.SEMANTIC_ANALYSIS) + List accesses = [] + cu.ast.classes.find { it.name == 'C' } + .getMethods('m')[0].code + .visit(new CodeVisitorSupport() { + @Override + void visitVariableExpression(VariableExpression ve) { + // Include only references, not the declaration site. + // The declaration site has accessedVariable == ve (set to itself by declare()). + if (ve.name == varName && ve.accessedVariable !== ve) { + accesses << ve.accessedVariable + } + super.visitVariableExpression(ve) + } + }) + accesses + } + + /** True if the accessed variable is a local (not a DynamicVariable). */ + private static boolean isLocal(Variable v) { + !(v instanceof DynamicVariable) + } + + // ------------------------------------------------------------------------- + // Native !instanceof Type name (equivalent to !(instanceof), JEP 394) + // ------------------------------------------------------------------------- + + /** + * {@code o !instanceof String s} ≡ {@code !(o instanceof String s)}: + * whenTrue={}, whenFalse={s} → then dynamic, else local, after dynamic. + */ + @Test + void testNativeNotInstanceof_ifBlockDynamic_elseLocal_afterDynamic() { + def src = ''' + class C { + def m(Object o) { + if (o !instanceof String s) { + s // then → dynamic + } else { + s.length() // else → local + } + s // after → dynamic + } + } + ''' + def accesses = collectAccesses(src) + assert accesses.size() == 3 + assert !isLocal(accesses[0]) : "then of !instanceof: s must be dynamic" + assert isLocal(accesses[1]) : "else of !instanceof: s must be local" + assert !isLocal(accesses[2]) : "after !instanceof if: s must be dynamic" + } + + /** + * Early return in then of {@code !instanceof}: whenFalse={s} survives after + * (same as {@code !(instanceof)} with abrupt then). + */ + @Test + void testNativeNotInstanceof_abruptThen_afterLocal() { + def src = ''' + class C { + def m(Object o) { + if (o !instanceof String s) return + s.length() // after → local (survivor) + } + } + ''' + def accesses = collectAccesses(src) + assert accesses.size() == 1 + assert isLocal(accesses[0]) : "after abrupt then of !instanceof: s must be local" + } + + /** + * {@code &&} RHS of native {@code !instanceof} and of {@code !(instanceof)} + * must both be dynamic (whenTrue empty) — short-circuit isolation is symmetric. + */ + @Test + void testNotInstanceof_andRhsDynamic_nativeAndNegatedForms() { + for (String cond : [ + 'o !instanceof String s && s.isEmpty()', + '!(o instanceof String s) && s.isEmpty()', + ]) { + def src = """ + class C { + def m(Object o) { + ${cond} + } + } + """ + def accesses = collectAccesses(src) + assert accesses.size() == 1 : "expected one use of s in: $cond" + assert !isLocal(accesses[0]) : "&& RHS must be dynamic for: $cond" + } + } + + /** + * Redeclare in then of {@code !instanceof} is allowed (whenTrue empty). + */ + @Test + void testNativeNotInstanceof_redeclareInThen_isLocal() { + def src = ''' + class C { + def m(Object o) { + if (o !instanceof String s) { + def s = 'x' + s + } else { + s.length() + } + } + } + ''' + def accesses = collectAccesses(src) + assert accesses.size() == 2 + assert isLocal(accesses[0]) : "then redeclare: local" + assert isLocal(accesses[1]) : "else pattern: local" + assert !accesses[0].is(accesses[1]) + } + + // ------------------------------------------------------------------------- + // Case 1: o instanceof String s + // ------------------------------------------------------------------------- + + /** + * JLS §6.3.2.2-200-A: e.whenTrue is in scope in the then-block (S). + * e.whenFalse ({}) is in scope in the else-block (T) — so s is dynamic there. + * No abrupt completion → s is NOT introduced after the if-else. + */ + @Test + void testCase1_simpleInstanceof_ifBlockLocal_elseBlockDynamic_afterDynamic() { + def src = ''' + class C { + def m(Object o) { + if (o instanceof String s) { + s.length() // use in if-block → must be local + } else { + s // use in else-block → must be dynamic + } + s // use after if-else → must be dynamic + } + } + ''' + def accesses = collectAccesses(src) + assert accesses.size() == 3 + assert isLocal(accesses[0]) : "if-block: s must be local (JLS §6.3.2.2-200-A)" + assert !isLocal(accesses[1]) : "else-block: s must be dynamic (e.whenFalse={})" + assert !isLocal(accesses[2]) : "after if: s must be dynamic (both branches fall through)" + } + + // ------------------------------------------------------------------------- + // Case 1b: o instanceof String s, else cannot complete normally + // ------------------------------------------------------------------------- + + /** + * JLS §6.3.2.2-200-C-A: introduced by e.whenTrue={s}, S can complete normally, + * T cannot complete normally → s IS introduced after the if-else. + */ + @Test + void testCase1b_simpleInstanceof_abruptElse_afterLocal() { + def src = ''' + class C { + def m(Object o) { + if (o instanceof String s) { + // S falls through + } else { + throw new IllegalArgumentException() + } + s // after: T is abrupt, S falls through → s visible (C-A) + } + } + ''' + def accesses = collectAccesses(src) + assert accesses.size() == 1 + assert isLocal(accesses[0]) : "after if (abrupt else): s must be local (JLS §6.3.2.2-200-C-A)" + } + + // ------------------------------------------------------------------------- + // Case 2: !(o instanceof String s) + // ------------------------------------------------------------------------- + + /** + * JLS §6.3.2.2-200-B: e.whenFalse={s} is in scope in the else-block (T). + * JLS §6.3.2.2-200-A: e.whenTrue={} → if-block does NOT see s. + */ + @Test + void testCase2_negatedInstanceof_ifBlockDynamic_elseBlockLocal() { + def src = ''' + class C { + def m(Object o) { + if (!(o instanceof String s)) { + s // use in if-block → must be dynamic (e.whenTrue={}) + } else { + s.length() // use in else-block → must be local (e.whenFalse={s}) + } + } + } + ''' + def accesses = collectAccesses(src) + assert accesses.size() == 2 + assert !isLocal(accesses[0]) : "if-block: s must be dynamic (JLS §6.3.2.2-200-A, whenTrue={})" + assert isLocal(accesses[1]) : "else-block: s must be local (JLS §6.3.2.2-200-B, whenFalse={s})" + } + + // ------------------------------------------------------------------------- + // Case 3: !(o instanceof String s), abrupt if-block (early return/throw) + // ------------------------------------------------------------------------- + + /** + * JLS §6.3.2.2-200-C-B: introduced by e.whenFalse={s}, S cannot complete + * normally (early return), T can → s IS introduced after the if statement. + */ + @Test + void testCase3_negatedInstanceof_abruptIf_afterLocal() { + def src = ''' + class C { + def m(Object o) { + if (!(o instanceof String s)) { + return null // S cannot complete normally + } + s.length() // after: S abrupt, no else → s visible (C-B) + } + } + ''' + def accesses = collectAccesses(src) + assert accesses.size() == 1 + assert isLocal(accesses[0]) : "after if (abrupt if-block): s must be local (JLS §6.3.2.2-200-C-B)" + } + + // ------------------------------------------------------------------------- + // Case 4: o instanceof String s && cond + // ------------------------------------------------------------------------- + + /** + * JLS §6.3.1.1: a&&b when-true = {s} → if-block sees s. + * JLS §6.3.1.1 (note): no when-false rule → else-block does NOT see s. + * No abrupt completion → after if-else does NOT see s. + */ + @Test + void testCase4_andChain_ifBlockLocal_elseBlockDynamic_afterDynamic() { + def src = ''' + class C { + def m(Object o) { + if (o instanceof String s && s.length() > 0) { + s.toUpperCase() // if-block → local + } else { + s // else-block → dynamic + } + s // after → dynamic + } + } + ''' + def accesses = collectAccesses(src) + // s appears in: condition (RHS of &&, already in scope), if-block, else-block, after + // The && RHS visit also resolves s; let's filter just the block uses: + // accesses are in AST visit order: condition-RHS s, then if-block s, else-block s, after s + assert accesses.size() >= 3 + // The if-block use and after-if uses are tracked; find the else and after ones + // All local ones should be the if-block one, all dynamic ones the else and after + def locals = accesses.findAll { isLocal(it) } + def dynamics = accesses.findAll { !isLocal(it) } + assert !locals.isEmpty() : "if-block (and && RHS) references to s must be local" + assert !dynamics.isEmpty() : "else-block and after-if references to s must be dynamic" + } + + /** + * §6.3.1.1 Rule A: s (introduced by left when true) is definitely matched + * at right — so the RHS of && also sees s as a local. + */ + @Test + void testCase4_andChain_rhs_seesPatternVarAsLocal() { + def src = ''' + class C { + def m(Object o) { + if (o instanceof String s && s.length() > 0) { + return 1 + } + return 0 + } + } + ''' + def accesses = collectAccesses(src) + // The only s reference is in the && RHS: must be local + assert accesses.size() == 1 + assert isLocal(accesses[0]) : "RHS of && must see s as local (JLS §6.3.1.1 Rule A)" + } + + // ------------------------------------------------------------------------- + // Case 5: o instanceof String s || cond + // ------------------------------------------------------------------------- + + /** + * JLS §6.3.1.2 (note): no when-true rule for || → if-block does NOT see s. + * VariableScopeVisitor declares nothing for the if-block → DynamicVariable. + */ + @Test + void testCase5_orChain_ifBlockDynamic() { + def src = ''' + class C { + def m(Object o) { + if (o instanceof String s || true) { + s // if-block → must be dynamic + } + } + } + ''' + def accesses = collectAccesses(src) + assert accesses.size() == 1 + assert !isLocal(accesses[0]) : "if-block with || condition: s must be dynamic (JLS §6.3.1.2 note)" + } + + /** + * JLS §6.3.1.2 Rule A: s introduced by left.whenFalse is in scope in the + * right of ||. But the right of || is NOT where s is declared on a false path — + * that is about the || sub-expression, not the if-block. + * Specifically: `o instanceof String s || s.isEmpty()` — the s in `s.isEmpty()` + * is the RIGHT of ||, which is inside the if's condition, NOT the if-block. + * After || evaluates to true, VariableScopeVisitor gives the if-block e.whenTrue={} + * → still dynamic in the if-block body. + */ + @Test + void testCase5_orChain_rhs_seesPatternFalseVar() { + // Right side of ||: s.isEmpty() — s was introduced by left.whenFalse={s} + // So this s reference should be local (JLS §6.3.1.2-100-A) + def src = ''' + class C { + def m(Object o) { + if (!(o instanceof String s) || s.isEmpty()) { + return 1 + } + return 0 + } + } + ''' + def accesses = collectAccesses(src) + // s in s.isEmpty() is the RHS of ||; it's introduced by left.whenFalse={s} + assert accesses.size() == 1 + assert isLocal(accesses[0]) : "RHS of || (after !(instanceof s)): s must be local (JLS §6.3.1.2-100-A)" + } + + // ------------------------------------------------------------------------- + // Case 6: !(o instanceof String s) && cond + // ------------------------------------------------------------------------- + + /** + * JLS §6.3.1.3 + §6.3.1.1: !(instanceof s).whenTrue = {} → + * (!(instanceof s) && cond).whenTrue = {} → if-block does NOT see s. + */ + @Test + void testCase6_negatedAndCond_ifBlockDynamic() { + def src = ''' + class C { + def m(Object o) { + if (!(o instanceof String s) && true) { + s // if-block → must be dynamic + } + } + } + ''' + def accesses = collectAccesses(src) + assert accesses.size() == 1 + assert !isLocal(accesses[0]) : "if-block: !(instanceof s) && cond → s must be dynamic" + } + + /** + * After if, with abrupt else: JLS §6.3.2.2-200-C-A: + * e.whenTrue={}, T abrupt → C-A doesn't apply (e.whenTrue={}). + * C-B: e.whenFalse={s}, S cannot complete normally? S = !(s) && cond if-block + * can complete normally → C-B doesn't apply either. So s NOT visible after. + */ + @Test + void testCase7a_negatedAndCond_abruptElse_afterDynamic() { + def src = ''' + class C { + def m(Object o) { + if (!(o instanceof String s) && true) { + // S falls through + } else { + throw new RuntimeException() + } + s // after → must be dynamic (C-B doesn't apply) + } + } + ''' + def accesses = collectAccesses(src) + assert accesses.size() == 1 + assert !isLocal(accesses[0]) : "after if (!(instanceof s) && cond, abrupt else): s must be dynamic" + } + + // ------------------------------------------------------------------------- + // Case 7b: missing case — !(o instanceof String s), abrupt else only + // ------------------------------------------------------------------------- + + /** + * explicit missing case: + *
+     *   if (!(o instanceof String s)) {
+     *     println "not String"   // S can complete normally
+     *   } else {
+     *     println "String"
+     *     return                 // T cannot complete normally
+     *   }
+     *   println s  // s NOT visible: C-B requires S to be abrupt, but S falls through
+     * 
+ * + * JLS §6.3.2.2-200-C analysis: + * e = !(o instanceof String s): whenFalse={s} + * C-B: e.whenFalse={s} AND S cannot complete normally AND T can → required but S CAN complete → C-B does NOT apply + * C-A: e.whenTrue={} → nothing + * → s NOT introduced after the if-else statement. + */ + @Test + void testCase7b_negatedInstanceof_abruptElseOnly_afterDynamic() { + def src = ''' + class C { + def m(Object o) { + if (!(o instanceof String s)) { + println "not String" // S can complete normally + } else { + println "String" + return s // T cannot complete normally + } + s // after → must be dynamic (C-B does NOT apply) + } + } + ''' + def accesses = collectAccesses(src) + // s appears in: else-block (return s) and after the if-else + // else-block: e.whenFalse={s} → local + // after: C-B does not apply → dynamic + assert accesses.size() == 2 + assert isLocal(accesses[0]) : "else-block: s must be local (JLS §6.3.2.2-200-B, e.whenFalse={s})" + assert !isLocal(accesses[1]) : "after if (abrupt-else-only): s must be dynamic (C-B does NOT apply)" + } + + // ------------------------------------------------------------------------- + // Case 8: !(o instanceof String s) || cond + // ------------------------------------------------------------------------- + + /** + * JLS §6.3.1.2 + §6.3.1.3: + * !(instanceof s) → whenFalse={s}. + * a || b: b is in scope for left.whenFalse={s} (Rule A). + * a || b: whenFalse = left.whenFalse ∪ right.whenFalse = {s} ∪ {} = {s}. + * → else-block sees s (e.whenFalse={s}, JLS §6.3.2.2-200-B). + * → if-block does NOT see s (JLS §6.3.1.2 note: no whenTrue rule for ||). + */ + @Test + void testCase8_negatedOrCond_elseBlockLocal_ifBlockDynamic() { + def src = ''' + class C { + def m(Object o) { + if (!(o instanceof String s) || s.isEmpty()) { + s // if-block → dynamic (|| has no whenTrue rule) + } else { + s.length() // else-block → local (e.whenFalse={s}) + } + } + } + ''' + def accesses = collectAccesses(src) + // s appears in: condition RHS (s.isEmpty()), if-block, else-block + // condition RHS: s introduced by left.whenFalse={s} via || Rule A → local + // if-block: e.whenTrue={} → dynamic + // else-block: e.whenFalse={s} → local + assert accesses.size() == 3 + assert isLocal(accesses[0]) : "|| RHS (s.isEmpty): s must be local (JLS §6.3.1.2-100-A)" + assert !isLocal(accesses[1]) : "if-block: s must be dynamic (JLS §6.3.1.2 note, no whenTrue rule)" + assert isLocal(accesses[2]) : "else-block: s must be local (JLS §6.3.2.2-200-B, whenFalse={s})" + } + + // ------------------------------------------------------------------------- + // Double negation / De Morgan identities + // ------------------------------------------------------------------------- + + /** + * JLS §6.3.1.3 applied twice: !!(instanceof s) when-true = {s} — same as plain instanceof. + */ + @Test + void testDoubleNegation_ifBlockLocal() { + def src = ''' + class C { + def m(Object o) { + if (!!(o instanceof String s)) { + s.length() // if-block → local + } + } + } + ''' + def accesses = collectAccesses(src) + assert accesses.size() == 1 + assert isLocal(accesses[0]) : "if-block with !! condition: s must be local (§6.3.1.3 applied twice)" + } + + /** + * De Morgan: !(a && b) ≡ !a || !b. No definite binding on either path. + * But the && RHS (b = s.length() > 0) is in scope for the pattern-true + * arm (JLS §6.3.1.1 Rule A), so the condition itself contains a local ref. + */ + @Test + void testDeMorgan_notAndNegation_bothDynamic() { + def src = ''' + class C { + def m(Object o) { + if (!(o instanceof String s && s.length() > 0)) { + s // if-block → dynamic (whenTrue of !(&&) = {}) + } else { + s // else-block → local (whenFalse of !(&&) = {s}) + } + } + } + ''' + def accesses = collectAccesses(src) + // Visit order: condition (s.length() > 0 inside &&), if-block s, else-block s. + // !(a && b): whenTrue = (a&&b).whenFalse = {}; whenFalse = (a&&b).whenTrue = {s} + // condition (&&) RHS: s.length() > 0 — left's whenTrue={s} is in scope (§6.3.1.1 Rule A) → local + // if-block: e.whenTrue={} → dynamic + // else-block: e.whenFalse={s} → local + assert accesses.size() == 3 + assert isLocal(accesses[0]) : "condition (&&) RHS: s must be local (JLS §6.3.1.1 Rule A)" + assert !isLocal(accesses[1]) : "if-block: !(&&) whenTrue={} → s must be dynamic" + assert isLocal(accesses[2]) : "else-block: !(&&) whenFalse={s} → s must be local" + } + + // ------------------------------------------------------------------------- + // Ternary expression (JLS §6.3.1.4) + // ------------------------------------------------------------------------- + + /** + * JLS §6.3.1.4: a ? b : c introduces no bindings for when-true or when-false. + * The condition's whenTrue={s} is visible in b, and whenFalse={} in c. + */ + @Test + void testTernaryExpression_trueExprLocal_falseExprDynamic() { + def src = ''' + class C { + def m(Object o) { + def r = (o instanceof String s) ? s.length() : s // s in false-expr → dynamic + r + } + } + ''' + def accesses = collectAccesses(src) + // s in true-expr (s.length()) → local (condition.whenTrue={s}) + // s in false-expr (s) → dynamic (condition.whenFalse={}) + assert accesses.size() == 2 + assert isLocal(accesses[0]) : "ternary true-expr: s must be local (condition.whenTrue={s})" + assert !isLocal(accesses[1]) : "ternary false-expr: s must be dynamic (condition.whenFalse={})" + } + + // ------------------------------------------------------------------------- + // while / do-while (intentional partial flow scoping) + // ------------------------------------------------------------------------- + + /** + * while body sees e.whenTrue only. Positive instanceof → s local in body. + */ + @Test + void testWhile_positiveInstanceof_bodyLocal_afterDynamic() { + def src = ''' + class C { + def m(Object o) { + while (o instanceof String s) { + s.length() // body → local + break + } + s // after → dynamic (no after-loop introduction) + } + } + ''' + def accesses = collectAccesses(src) + assert accesses.size() == 2 + assert isLocal(accesses[0]) : "while body: s must be local (whenTrue)" + assert !isLocal(accesses[1]) : "after while: s must be dynamic (no JLS after-loop intro)" + } + + /** + * while (!(o instanceof String s)): whenTrue={}, so body must *not* see s + * as a local. After the loop still dynamic (partial support). + */ + @Test + void testWhile_negatedInstanceof_bodyDynamic_afterDynamic() { + def src = ''' + class C { + def m(Object o) { + while (!(o instanceof String s)) { + s // body → dynamic (whenTrue={}) + break + } + s // after → dynamic + } + } + ''' + def accesses = collectAccesses(src) + assert accesses.size() == 2 + assert !isLocal(accesses[0]) : "while body of negated: s must be dynamic" + assert !isLocal(accesses[1]) : "after while: s must be dynamic" + } + + /** + * Even when the while body cannot complete normally, Groovy does not + * introduce whenFalse after the loop (documented divergence from JLS). + */ + @Test + void testWhile_abruptBody_stillNoAfterLoopIntroduction() { + def src = ''' + class C { + def m(Object o) { + while (!(o instanceof String s)) { + return 'in' + } + s // still dynamic — no after-loop whenFalse + } + } + ''' + def accesses = collectAccesses(src) + assert accesses.size() == 1 + assert !isLocal(accesses[0]) : "after while with abrupt body: no whenFalse introduction" + } + + /** + * {@code while (o instanceof String s || cond)}: whenTrue is empty, so the + * body must not see s as a local (|| has no whenTrue rule). + */ + @Test + void testWhile_orCondition_bodyDynamic() { + def src = ''' + class C { + def m(Object o) { + while (o instanceof String s || false) { + s // body → dynamic + break + } + } + } + ''' + def accesses = collectAccesses(src) + assert accesses.size() == 1 + assert !isLocal(accesses[0]) : "while body of || condition: s must be dynamic" + } + + /** + * do-while: body runs before condition — s not in body; condition RHS of && + * may be local. After loop still dynamic. + */ + @Test + void testDoWhile_bodyDynamic_conditionRhsLocal_afterDynamic() { + def src = ''' + class C { + def m(Object o) { + do { + s // body → dynamic + } while (o instanceof String s && s.length() > 0) + s // after → dynamic + } + } + ''' + def accesses = collectAccesses(src) + // body s, condition s.length(), after s + assert accesses.size() == 3 + assert !isLocal(accesses[0]) : "do-while body: s must be dynamic" + assert isLocal(accesses[1]) : "do-while condition && RHS: s must be local" + assert !isLocal(accesses[2]) : "after do-while: s must be dynamic" + } + + // ------------------------------------------------------------------------- + // Redeclaration where pattern variable is NOT visible → new local is OK + // ------------------------------------------------------------------------- + + /** + * Else-block of {@code o instanceof String s}: s is not in scope, so + * {@code def s = ...} declares a fresh local. Uses of s in the else-block + * must resolve to that local (not the pattern binding, not DynamicVariable). + */ + @Test + void testRedeclareInElseBlock_wherePatternNotVisible_isLocal() { + def src = ''' + class C { + def m(Object o) { + if (o instanceof String s) { + s.length() + } else { + def s = 'local' + s + } + } + } + ''' + def accesses = collectAccesses(src) + // if-block s (pattern local), else-block s (new local declaration's use) + assert accesses.size() == 2 + assert isLocal(accesses[0]) : "if-block: pattern s must be local" + assert isLocal(accesses[1]) : "else-block: redeclared s must be a local" + // The two locals must be distinct Variable objects. + assert accesses[0].is(accesses[0]) // sanity + assert !accesses[0].is(accesses[1]) : "else-block s must not be the pattern variable" + } + + /** + * After if-else where both branches fall through, pattern s is not in scope; + * {@code def s = ...} is a fresh local. + */ + @Test + void testRedeclareAfterIf_wherePatternNotVisible_isLocal() { + def src = ''' + class C { + def m(Object o) { + if (o instanceof String s) { + s.length() + } + def s = 42 + s + } + } + ''' + def accesses = collectAccesses(src) + // if-block s (pattern), after-if s (new local) + assert accesses.size() == 2 + assert isLocal(accesses[0]) : "if-block: pattern s must be local" + assert isLocal(accesses[1]) : "after if: redeclared s must be a local" + assert !accesses[0].is(accesses[1]) : "after-if s must not be the pattern variable" + } + + /** + * True-branch of {@code !(o instanceof String s)}: whenTrue is empty, so + * redeclaring s is allowed and yields a fresh local. + */ + @Test + void testRedeclareInNegatedIfBlock_wherePatternNotVisible_isLocal() { + def src = ''' + class C { + def m(Object o) { + if (!(o instanceof String s)) { + def s = 'x' + s + } else { + s.length() + } + } + } + ''' + def accesses = collectAccesses(src) + // if-block s (new local), else-block s (pattern local) + assert accesses.size() == 2 + assert isLocal(accesses[0]) : "if-block: redeclared s must be a local" + assert isLocal(accesses[1]) : "else-block: pattern s must be local" + assert !accesses[0].is(accesses[1]) : "if-block s must not be the pattern variable" + } + + // ------------------------------------------------------------------------- + // shouldNotCompile: cannot redeclare where pattern variable IS visible + // ------------------------------------------------------------------------- + + /** + * Then-block of {@code o instanceof String s}: s is in scope → redeclare fails. + */ + @Test + void testShouldNotCompile_redeclareInIfBlock_wherePatternVisible() { + def err = shouldNotCompile ''' + class C { + def m(Object o) { + if (o instanceof String s) { + def s = 'nope' + } + } + } + ''' + assert err =~ /already contains a variable of the name s/ + } + + /** + * After abrupt else of {@code o instanceof String s}: s survives → redeclare fails. + */ + @Test + void testShouldNotCompile_redeclareAfterAbruptElse_wherePatternVisible() { + def err = shouldNotCompile ''' + class C { + def m(Object o) { + if (o instanceof String s) { + // fall through + } else { + return + } + def s = 'nope' + } + } + ''' + assert err =~ /already contains a variable of the name s/ + } + + /** + * After {@code if (!(o instanceof String s)) return}: s is introduced → redeclare fails. + */ + @Test + void testShouldNotCompile_redeclareAfterEarlyReturn_wherePatternVisible() { + def err = shouldNotCompile ''' + class C { + def m(Object o) { + if (!(o instanceof String s)) return + def s = 'nope' + } + } + ''' + assert err =~ /already contains a variable of the name s/ + } + + /** + * Else-block of {@code !(o instanceof String s)}: whenFalse={s} → redeclare fails. + */ + @Test + void testShouldNotCompile_redeclareInElseOfNegated_wherePatternVisible() { + def err = shouldNotCompile ''' + class C { + def m(Object o) { + if (!(o instanceof String s)) { + // not string + } else { + def s = 'nope' + } + } + } + ''' + assert err =~ /already contains a variable of the name s/ + } + + private static String shouldNotCompile(String src) { + try { + def cu = new CompilationUnit() + cu.addSource('C.groovy', src) + cu.compile(Phases.SEMANTIC_ANALYSIS) + throw new AssertionError("Expected compilation to fail:\n$src") + } catch (Exception e) { + return e.message ?: e.toString() + } + } +} diff --git a/src/test/groovy/groovy/InstanceofTest.groovy b/src/test/groovy/groovy/InstanceofTest.groovy index 7896c65ac9b..9257ad8bda7 100644 --- a/src/test/groovy/groovy/InstanceofTest.groovy +++ b/src/test/groovy/groovy/InstanceofTest.groovy @@ -20,6 +20,8 @@ package groovy import org.junit.jupiter.api.Test +import org.codehaus.groovy.control.MultipleCompilationErrorsException + import static groovy.test.GroovyAssert.shouldFail final class InstanceofTest { @@ -94,6 +96,95 @@ final class InstanceofTest { assert (n instanceof Integer i && i.intValue() == 12345) } + // GROOVY-12242: native !instanceof type pattern (JEP 394) + @Test + void testNotInstanceofPatternVariable() { + def n = (Number) 12345 + if (n !instanceof String s) { + assert n.intValue() == 12345 + } else { + assert false : 'expected non-String' + } + if (n !instanceof Integer i) { + assert false : 'expected Integer (condition false)' + } else { + assert i.intValue() == 12345 + } + } + + // GROOVY-12242: !instanceof pattern + short-circuit / early return survivors + @Test + void testNotInstanceofPatternScope() { + def f = { Object o -> + if (o !instanceof String s) { + return 'not-string' + } else { + return s.toUpperCase() + } + } + assert f('hi') == 'HI' + assert f(1) == 'not-string' + + def g = { Object o -> + if (o !instanceof String s) return 'early' + return s.toUpperCase() + } + assert g('ab') == 'AB' + assert g(9) == 'early' + } + + // GROOVY-12242: !instanceof pattern must not leak into then-block + @Test + void testNotInstanceofPatternThenBlockDynamic() { + def err = shouldFail MissingPropertyException, ''' + def m(Object o) { + if (o !instanceof String s) { + return s + } + return 'ok' + } + m(1) + ''' + assert err.message =~ /No such property: s/ + } + + // GROOVY-12242: ternary with !instanceof pattern + @Test + void testNotInstanceofPatternTernary() { + def f = { Object o -> + (o !instanceof String s) ? 'not' : s.toUpperCase() + } + assert f(1) == 'not' + assert f('xy') == 'XY' + } + + // GROOVY-12242: && / || with !instanceof pattern + @Test + void testNotInstanceofPatternBooleanOps() { + // true path of !instanceof is empty — RHS of && must not see s as bound from left + def err = shouldFail MissingPropertyException, ''' + def m(Object o) { + return (o !instanceof String s && s.isEmpty()) + } + m(1) + ''' + assert err.message =~ /No such property: s/ + + // Same for parenthesised negation form (&& isolation is flow-based) + err = shouldFail MissingPropertyException, ''' + def m(Object o) { + return (!(o instanceof String s) && s.isEmpty()) + } + m(1) + ''' + assert err.message =~ /No such property: s/ + + // false path of !instanceof binds s — RHS of || can use s when left is false + assert (({ Object o -> (o !instanceof String s || s.isEmpty()) }('')) == true) + assert (({ Object o -> (o !instanceof String s || s.length() > 0) }('ab')) == true) + assert (({ Object o -> (o !instanceof String s || s.isEmpty()) }(1)) == true) + } + // GROOVY-11229 @Test void testVariable2() { @@ -223,4 +314,796 @@ final class InstanceofTest { } assert y == 'foobar' } + + // GROOVY-12242: Java-aligned flow scoping for negated instanceof (JEP 394) + @Test + void testVariableScopeNegatedElse() { + def f = { Object o -> + if (!(o instanceof String s)) { + return 'not' + } else { + return s.toUpperCase() + } + } + assert f('hi') == 'HI' + assert f(1) == 'not' + } + + // GROOVY-12242: pattern variable remains in scope after abrupt then-branch + @Test + void testVariableScopeEarlyReturn() { + def f = { Object o -> + if (!(o instanceof String s)) return 'early' + return s.toUpperCase() + } + assert f('hi') == 'HI' + assert f(42) == 'early' + } + + // GROOVY-12242: pattern variable remains after else that cannot complete normally + @Test + void testVariableScopeAfterAbruptElse() { + def f = { Object o -> + if (o instanceof String s) { + // matched + } else { + return 'no' + } + return s.toUpperCase() + } + assert f('ab') == 'AB' + assert f(9) == 'no' + } + + // GROOVY-12242: pattern variable must not leak after a declaration statement + @Test + void testVariableNoLeakAfterDeclaration() { + def err = shouldFail MissingPropertyException, ''' + class C { + Object m(Object o) { + boolean b = (o instanceof String s) + return s + } + } + new C().m('hi') + ''' + assert err.message =~ /No such property: s/ + } + + // GROOVY-12242: pattern variable must not leak after an expression statement + @Test + void testVariableNoLeakAfterExpressionStatement() { + def err = shouldFail MissingPropertyException, ''' + class C { + Object m(Object o) { + o instanceof String s && s.length() > 0 + return s + } + } + new C().m('hi') + ''' + assert err.message =~ /No such property: s/ + } + + // GROOVY-12242: true branch of negated instanceof must not see the pattern local + // (CompileStack polarity must match VariableScope — no silent null ALOAD) + @Test + void testVariableNegatedIfBranchNotInScope() { + def err = shouldFail MissingPropertyException, ''' + class C { + Object m(Object o) { + if (!(o instanceof String s)) { + return s + } + return 'matched' + } + } + new C().m(1) + ''' + assert err.message =~ /No such property: s/ + } + + // GROOVY-12242: true-path binding of left of || is not in scope on the right (Java) + @Test + void testVariableOrRightHandSideNotInScope() { + def shell = GroovyShell.withConfig { + ast groovy.transform.TypeChecked + } + def err = shouldFail shell, ''' + @groovy.transform.TypeChecked + class C { + static void m(Object o) { + if (o instanceof String s || s.length() > 0) { + } + } + } + ''' + assert err.message =~ /The variable .s. is undeclared|Apparent variable .s./ + } + + // GROOVY-12242: false-path binding is in scope on the right of || (Java) + @Test + void testVariableOrRightHandSideFalsePathInScope() { + def f = { Object o -> + // when o is String, left is false, right sees s + return (!(o instanceof String s) || s.isEmpty()) + } + assert f('') == true + assert f('x') == false + assert f(1) == true // left true → short-circuit, s not needed + } + + // GROOVY-12242: ternary false branch must not see true-path pattern variable + @Test + void testVariableTernaryFalseBranchNotInScope() { + def shell = GroovyShell.withConfig { + ast groovy.transform.TypeChecked + } + def err = shouldFail shell, ''' + @groovy.transform.TypeChecked + class C { + static Object m(Object o) { + return o instanceof String s ? 'yes' : s + } + } + ''' + assert err.message =~ /The variable .s. is undeclared|Apparent variable .s./ + } + + // GROOVY-12242: dynamic ternary false branch must not load a pattern local + @Test + void testVariableTernaryFalseBranchNotInScopeDynamic() { + def err = shouldFail MissingPropertyException, ''' + class C { + Object m(Object o) { + return o instanceof String s ? 'yes' : s + } + } + new C().m(1) + ''' + assert err.message =~ /No such property: s/ + } + + // GROOVY-12242: ternary true branch sees pattern variable + @Test + void testVariableTernaryTrueBranch() { + def f = { Object o -> o instanceof String s ? s.toUpperCase() : 'no' } + assert f('ab') == 'AB' + assert f(1) == 'no' + } + + // GROOVY-12242: reassignment of pattern variable (not implicitly final, JEP 394) + @Test + void testVariableReassignment() { + Object o = 'hi' + if (o instanceof String s) { + s = s + '!' + assert s == 'hi!' + } else { + assert false + } + } + + // GROOVY-12242: pattern variable shadows a field only where in scope + @Test + void testVariableFieldShadowing() { + def obj = new Object() { + String s = 'field' + def test(Object o) { + if (o instanceof String s) { + return "pv=$s" + } + return "field=$s" + } + } + assert obj.test('x') == 'pv=x' + assert obj.test(1) == 'field=field' + } + + // GROOVY-12242: && chain uses pattern variable on subsequent operands + @Test + void testVariableAndChain() { + Object o = 'hello' + assert (o instanceof String s && s.length() > 3 && s.startsWith('h')) + assert !(o instanceof String s && s.length() > 99) + } + + // GROOVY-12242: while body can use true-path pattern variable + @Test + void testVariableWhileBody() { + Object o = 'ab' + def n = 0 + while (o instanceof String s && s.length() > 0) { + n += 1 + o = s.substring(1) + } + assert n == 2 + assert o == '' + } + + // GROOVY-12242: negated while condition — s not in body; not after (partial JLS) + @Test + void testWhileNegated_bodyAndAfterNotVisible() { + def err = shouldFail MissingPropertyException, ''' + def m(Object o) { + while (!(o instanceof String s)) { + return s + } + return 'out' + } + m(1) + ''' + assert err.message =~ /No such property: s/ + + err = shouldFail MissingPropertyException, ''' + def m(Object o) { + while (!(o instanceof String s)) { + return 'in' + } + return s + } + m('hi') + ''' + assert err.message =~ /No such property: s/ + } + + // GROOVY-12242: reuse the same pattern variable name in successive statements + @Test + void testVariableNameReuse() { + Object a = 'x', b = 1 + def r = [] + if (a instanceof String s) r << s + if (b instanceof Integer s) r << s + assert r == ['x', 1] + } + + // GROOVY-12242: type-checked flow scoping for early return + @Test + void testVariableScopeEarlyReturnTypeChecked() { + def shell = GroovyShell.withConfig { + ast groovy.transform.TypeChecked + } + assert shell.evaluate(''' + @groovy.transform.TypeChecked + class C { + static String m(Object o) { + if (!(o instanceof String s)) return 'early' + return s.toUpperCase() + } + } + assert C.m('hi') == 'HI' + assert C.m(1) == 'early' + true + ''') + } + + // GROOVY-12242: type-checked — positive instanceof still not in else + @Test + void testVariableScopePositiveNotInElseTypeChecked() { + def shell = GroovyShell.withConfig { + ast groovy.transform.TypeChecked + } + def err = shouldFail shell, ''' + Number n = 12345 + if (n instanceof Integer i) { + } else { + i.toString() + } + ''' + assert err.message =~ /The variable .i. is undeclared/ + } + + // GROOVY-12242: type-checked — negated instanceof is in else + @Test + void testVariableScopeNegatedInElseTypeChecked() { + def shell = GroovyShell.withConfig { + ast groovy.transform.TypeChecked + } + assert shell.evaluate(''' + @groovy.transform.TypeChecked + class C { + static String m(Object o) { + if (!(o instanceof String s)) { + return 'not' + } else { + return s.toUpperCase() + } + } + } + assert C.m('hi') == 'HI' + assert C.m(1) == 'not' + true + ''') + } + + // ------------------------------------------------------------------------- + // GROOVY-12242: systematic visibility matrix + // + // For each condition shape the test verifies: + // - if-block visibility + // - else-block visibility + // - after-if-else visibility (with and without abrupt completion) + // ------------------------------------------------------------------------- + + // --- (1) simple: o instanceof String s --- + + // pattern var in if-block (already covered by testVariable above); + // here we also test: NOT in else-block, NOT after if-else (no abrupt branch) + @Test + void testSimpleInstanceof_notInElse_notAfterIf() { + def err = shouldFail MissingPropertyException, ''' + class C { + def m(Object o) { + if (o instanceof String s) { /* ok */ } + else { return s } + } + } + new C().m(1) + ''' + assert err.message =~ /No such property: s/ + + err = shouldFail MissingPropertyException, ''' + class C { + def m(Object o) { + if (o instanceof String s) { /* ok */ } + return s + } + } + new C().m('x') + ''' + assert err.message =~ /No such property: s/ + } + + // pattern var IS visible after if when else cannot complete normally + @Test + void testSimpleInstanceof_visibleAfterIf_whenElseAbrupt() { + // else throws → s is visible after + def f = { Object o -> + if (o instanceof String s) { + // matched + } else { + throw new IllegalArgumentException('not a string') + } + s.toUpperCase() + } + assert f('hello') == 'HELLO' + try { f(1); assert false } catch (IllegalArgumentException ignored) {} + } + + // --- (2) simple: !(o instanceof String s) --- + + // true-branch (the !instanceof branch) must NOT see s; + // false-branch (else) MUST see s + @Test + void testNegatedInstanceof_truePathHides_falsePathBinds() { + // else sees s (already covered by testVariableScopeNegatedElse) + // here: if-branch does NOT see s + def err = shouldFail MissingPropertyException, ''' + class C { + def m(Object o) { + if (!(o instanceof String s)) { + return s + } + return 'ok' + } + } + new C().m(1) + ''' + assert err.message =~ /No such property: s/ + } + + // --- (3) !instanceof s, plus return in else block (→ s after if) --- + // (already covered by testVariableScopeEarlyReturn / testVariableScopeNegatedElse; + // repeat here as an explicit cell in the matrix) + @Test + void testNegatedInstanceof_earlyReturnInTrue_visibleAfter() { + def f = { Object o -> + if (!(o instanceof String s)) return 'nope' + s.toUpperCase() + } + assert f('hi') == 'HI' + assert f(42) == 'nope' + } + + // --- (4) o instanceof String s && cond --- + + // if-block: s visible; else-block: s NOT visible; after: NOT visible + @Test + void testAndChain_ifBlockVisible_elseNotVisible_afterNotVisible() { + // if-block is visible (tested by testVariableAndChain and testVariableScope) + // else-block: NOT visible + def err = shouldFail MissingPropertyException, ''' + class C { + def m(Object o) { + if (o instanceof String s && s.length() > 0) { + /* ok */ + } else { + return s + } + } + } + new C().m(1) + ''' + assert err.message =~ /No such property: s/ + + // after if: NOT visible (even if both branches complete normally) + err = shouldFail MissingPropertyException, ''' + class C { + def m(Object o) { + if (o instanceof String s && s.length() > 0) { /* ok */ } + return s + } + } + new C().m('x') + ''' + assert err.message =~ /No such property: s/ + } + + // --- (5) o instanceof String s || cond --- + + // JLS §6.3.1.2 (note): no rule for when-true of || — so VariableScopeVisitor does + // NOT declare s in the if-block scope. Dynamic Groovy resolves the undeclared s as a + // DynamicVariable, which yields MissingPropertyException at runtime (same as TypeChecked). + // There is no need to use @TypeChecked here — the scope decision is made entirely by + // VariableScopeVisitor (JLS §6.3.2.2-200-A: e.whenTrue is {} for this shape). + @Test + void testOrChain_noVisibilityInIfBlock() { + // s must not be visible in the if-block when condition is instanceof s || ... + def err = shouldFail MissingPropertyException, ''' + class C { + def m(Object o) { + if (o instanceof String s || true) { + return s + } + return 'ok' + } + } + new C().m('hello') + ''' + assert err.message =~ /No such property: s/ + } + + // --- (6) !(o instanceof String s) && cond --- + // De Morgan: ≡ (!instanceof s) && cond + // JLS §6.3.1.3 + §6.3.1.1: !(o instanceof String s).whenTrue = {} (no when-false for instanceof), + // && propagates no false-bindings, so the if-block (true path) does NOT see s. + // VariableScopeVisitor declares nothing in the if-block → DynamicVariable → MissingPropertyException. + @Test + void testNegatedAndCond_noVisibilityInIfBlock() { + def err = shouldFail MissingPropertyException, ''' + class C { + def m(Object o) { + if (!(o instanceof String s) && true) { + return s + } + return 'out' + } + } + new C().m(1) + ''' + assert err.message =~ /No such property: s/ + } + + // --- (7) !(o instanceof String s) && cond, plus abrupt else-block --- + // After the if: JLS §6.3.2.2-200-C-B: var introduced when true=?, S cannot complete normally, T can. + // Here if-block true-path has no s binding (whenTrue={}), so even with abrupt else, s is NOT after. + @Test + void testNegatedAndCond_withElseReturn_noVisibilityAfter() { + def err = shouldFail MissingPropertyException, ''' + class C { + def m(Object o) { + if (!(o instanceof String s) && true) { + // true path: s not definitely bound + } else { + return 'out' + } + return s + } + } + new C().m(1) + ''' + assert err.message =~ /No such property: s/ + } + + // --- (NEW) !(o instanceof String s) with abrupt else-block only --- + // missing case: + // if (!(o instanceof String s)) { println "not String" } else { println "String"; return } + // println s // <-- must be INVALID + // + // JLS §6.3.2.2-200-C analysis: + // e = !(o instanceof String s): whenTrue={}, whenFalse={s} + // S = if-block (println "not String"): can complete normally + // T = else-block (println "String"; return): cannot complete normally + // C-A: e.whenTrue={} -> nothing even if T abrupt + // C-B: e.whenFalse={s}, S cannot complete normally? NO (S falls through) -> C-B does NOT apply + // -> s is NOT introduced after the if-else statement + @Test + void testNegatedInstanceof_abruptElseOnly_noVisibilityAfter() { + def err = shouldFail MissingPropertyException, ''' + class C { + def m(Object o) { + if (!(o instanceof String s)) { + println "not String" + } else { + println "String" + return s + } + return s // s NOT in scope: if-block falls through, C-B does not apply + } + } + new C().m(1) + ''' + assert err.message =~ /No such property: s/ + } + + // --- (8) !(o instanceof String s) || cond --- + // Equivalent to !s || cond. False path of || = both sides false → s is bound when + // left is false (i.e. o instanceof String s) AND right is false. + // So else-block sees s; after-if with abrupt if-block sees s. + @Test + void testNegatedOr_elseBlockSees_afterAbruptIfBlockSees() { + // else-block: !(!(s)) || cond is false → !(o instanceof s) is false → s bound + def f = { Object o -> + if (!(o instanceof String s) || s.isEmpty()) { + return 'branch-true' + } else { + // here s is definitely bound (the !instanceof was false, so instanceof matched) + return 'has-s:' + s + } + } + assert f('hello') == 'has-s:hello' // !instanceof false, so else + assert f('') == 'branch-true' // !instanceof false but s.isEmpty true → if + assert f(42) == 'branch-true' // !instanceof true → if + + // after if with abrupt else (throw): s visible after + def g = { Object o -> + if (!(o instanceof String s) || s.isEmpty()) { + /* fell through */ + } else { + throw new IllegalStateException('non-empty string') + } + // s NOT visible here (if-block can complete normally without binding s) + } + g('') // no exception + g(42) // no exception + } + + // --- (9) !(o instanceof String s) || cond, plus return in else block --- + // true-path completes normally → s NOT visible after if-else when else abruptly returns + // (because the if-path does not guarantee s is bound) + @Test + void testNegatedOr_elseReturn_noVisibilityAfter() { + def err = shouldFail MissingPropertyException, ''' + class C { + def m(Object o) { + if (!(o instanceof String s) || s.isEmpty()) { + /* true branch: no guarantee s is bound */ + } else { + return 'else' + } + return s + } + } + new C().m('') + ''' + assert err.message =~ /No such property: s/ + } + + // ------------------------------------------------------------------------- + // Additional De Morgan / compound cases + // ------------------------------------------------------------------------- + + // De Morgan: !(a && b) ≡ !a || !b + // !(o instanceof String s && cond) — no binding anywhere (conservative) + @Test + void testDeMorgan_notAndNegation_noBinding() { + def err = shouldFail MissingPropertyException, ''' + class C { + def m(Object o) { + if (!(o instanceof String s && s.length() > 0)) { + return s + } + return 'ok' + } + } + new C().m(1) + ''' + assert err.message =~ /No such property: s/ + } + + // Double negation: !!(o instanceof String s) ≡ o instanceof String s + @Test + void testDoubleNegation_positiveBinding() { + def f = { Object o -> + if (!!(o instanceof String s)) { + return s.toUpperCase() + } + return 'no' + } + assert f('ab') == 'AB' + assert f(1) == 'no' + } + + // ------------------------------------------------------------------------- + // GROOVY-12242: redeclaration where pattern variable is / is not visible + // ------------------------------------------------------------------------- + + /** + * Else-block of positive instanceof: s not in scope → fresh local is allowed + * and correctly used at runtime (class generation must free the name). + */ + @Test + void testRedeclareInElse_wherePatternNotVisible_runtime() { + def f = { Object o -> + if (o instanceof String s) { + return 'pat:' + s + } else { + def s = 'local' + return s + } + } + assert f('hi') == 'pat:hi' + assert f(1) == 'local' + } + + /** + * After if with both branches falling through: s not in scope → fresh local OK. + */ + @Test + void testRedeclareAfterIf_wherePatternNotVisible_runtime() { + def f = { Object o -> + if (o instanceof String s) { + // matched; s does not escape + } + def s = 99 + return s + } + assert f('x') == 99 + assert f(1) == 99 + } + + /** + * True-branch of negated instanceof: s not in scope → fresh local OK. + */ + @Test + void testRedeclareInNegatedIf_wherePatternNotVisible_runtime() { + def f = { Object o -> + if (!(o instanceof String s)) { + def s = 'shadow' + return s + } else { + return 'pat:' + s + } + } + assert f(1) == 'shadow' + assert f('hi') == 'pat:hi' + } + + /** + * Nested: outer pattern s remains usable after an inner if that introduces i. + */ + @Test + void testNestedInstanceof_outerPatternStillVisible() { + def f = { Object o, Object p -> + if (o instanceof String s) { + if (p instanceof Integer i) { + return s + ':' + i + } + return s + ':no-i' + } + return 'no-s' + } + assert f('ab', 3) == 'ab:3' + assert f('ab', 'x') == 'ab:no-i' + assert f(1, 3) == 'no-s' + } + + // --- shouldNotCompile: cannot redeclare where pattern s is visible --- + + @Test + void testShouldNotCompile_redeclareInIfBlock_wherePatternVisible() { + def err = shouldFail MultipleCompilationErrorsException, ''' + def m(Object o) { + if (o instanceof String s) { + def s = 'nope' + } + } + ''' + assert err.message =~ /already contains a variable of the name s/ + } + + @Test + void testShouldNotCompile_redeclareAfterEarlyReturn_wherePatternVisible() { + def err = shouldFail MultipleCompilationErrorsException, ''' + def m(Object o) { + if (!(o instanceof String s)) return + def s = 'nope' + } + ''' + assert err.message =~ /already contains a variable of the name s/ + } + + @Test + void testShouldNotCompile_redeclareAfterAbruptElse_wherePatternVisible() { + def err = shouldFail MultipleCompilationErrorsException, ''' + def m(Object o) { + if (o instanceof String s) { + } else { + return + } + def s = 'nope' + } + ''' + assert err.message =~ /already contains a variable of the name s/ + } + + @Test + void testShouldNotCompile_redeclareInElseOfNegated_wherePatternVisible() { + def err = shouldFail MultipleCompilationErrorsException, ''' + def m(Object o) { + if (!(o instanceof String s)) { + } else { + def s = 'nope' + } + } + ''' + assert err.message =~ /already contains a variable of the name s/ + } + + /** + * Successive ifs reusing pattern name {@code s}: second condition must + * re-bind the slot so else-path hide still frees the name for redeclaration. + */ + @Test + void testSuccessiveIfs_reusePatternName_redeclareInSecondElse() { + def f = { Object a, Object b -> + if (a instanceof String s) { + // first binding + } + if (b instanceof Integer s) { + return 'int:' + s + } else { + def s = 'local' + return s + } + } + assert f('x', 7) == 'int:7' + assert f('x', 'y') == 'local' + assert f(1, 7) == 'int:7' + assert f(1, 'y') == 'local' + } + + /** + * Expression-statement pattern then a later if reusing the name — hide must + * still apply (identity-based "introduced", not name-set diff). + */ + @Test + void testIsolatedPatternExpr_thenIfReuseName_redeclareInElse() { + def f = { Object o -> + (o instanceof String s) // isolated; must not leak + if (o instanceof Integer s) { + return 'int:' + s + } else { + def s = 'ok' + return s + } + } + assert f(5) == 'int:5' + assert f('hi') == 'ok' + } + + /** + * After an early-return negated instanceof, survivor {@code s} remains usable + * even when a prior isolated pattern used the same name. + */ + @Test + void testNameReuse_thenSurvivorAfterEarlyReturn() { + def f = { Object o -> + (o instanceof Number n) // different name; isolation + if (!(o instanceof String s)) return 'early' + return s.toUpperCase() + } + assert f('ab') == 'AB' + assert f(1) == 'early' + } } diff --git a/src/test/groovy/org/apache/groovy/parser/antlr4/IntersectionCastParserTest.groovy b/src/test/groovy/org/apache/groovy/parser/antlr4/IntersectionCastParserTest.groovy index ae278f787dd..f6517f43df8 100644 --- a/src/test/groovy/org/apache/groovy/parser/antlr4/IntersectionCastParserTest.groovy +++ b/src/test/groovy/org/apache/groovy/parser/antlr4/IntersectionCastParserTest.groovy @@ -115,6 +115,14 @@ final class IntersectionCastParserTest { assertTrue(!ast.context.errorCollector.hasErrors()) } + @Test + void 'type pattern accepted for !instanceof'() { + ModuleNode ast = buildAST('def b = x !instanceof String s') + assertNotNull(ast) + assertTrue(!ast.context.errorCollector.hasErrors(), + "Parse should accept !instanceof type pattern; got: ${ast.context.errorCollector.errors}") + } + //-------------------------------------------------------------------------- private static ClassNode singleCastTargetType(String src) {