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: + *
+ * 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): + *
+ * 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
+ * One type, two views of the same analysis:
+ *
+ * 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
+ * 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
+ * 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
+ * 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
+ * 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
+ * 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:
+ *
+ *
*/
@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.
+ *
+ *
+ * {@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.
+ *
+ *
+ *
+ * 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
+ * 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) {