diff --git a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/validation/controlflow/DataflowAnomalyAnalysis.java b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/validation/controlflow/DataflowAnomalyAnalysis.java index 0234df8a1..7d834bff8 100644 --- a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/validation/controlflow/DataflowAnomalyAnalysis.java +++ b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/validation/controlflow/DataflowAnomalyAnalysis.java @@ -71,11 +71,26 @@ public boolean uninitialized(NameDef v) { } public VarStates addRead(LocalVarDef v, Element r) { + return withRead(v, r, null); + } + + /** + * Records a read that cannot observe writes made inside {@code excluded}. + * + *
A for-range start expression is evaluated once before the loop is entered, but the CFG
+ * back edge revisits the loop statement, so a plain read would also mark writes made in the
+ * loop body - suppressing a genuine never-read warning for them.
+ */
+ public VarStates addReadOutside(LocalVarDef v, Element r, Element excluded) {
+ return withRead(v, r, excluded);
+ }
+
+ private VarStates withRead(LocalVarDef v, Element r, @Nullable Element excluded) {
VState s = getVarState(v);
if (s == null) {
s = VState.initialDefined;
}
- s = s.addRead(r);
+ s = s.addRead(r, excluded);
Builder The CFG has a back edge to the loop statement, so the fixpoint evaluates this again on
+ * later iterations. Reads are therefore recorded only against writes outside the loop: a write
+ * in the loop body happens after the header has already been evaluated and cannot be observed
+ * by it, so counting it as read would hide a genuine dead assignment.
+ */
+ private VarStates handleLoopHeaderExpr(VarStates incoming, Expr headerExpr, WStatement loop) {
+ checkIfVarsInitialized(headerExpr, incoming);
+ for (NameDef v : headerExpr.attrReadVariables()) {
+ if (isLocalVarDef(v)) {
+ incoming = incoming.addReadOutside((LocalVarDef) v, headerExpr, loop);
+ }
+ }
+ return incoming;
+ }
+
private VarStates handleExprInCompound(VarStates incoming, Expr expr) {
checkIfVarsInitialized(expr, incoming);
for (NameDef v : expr.attrReadVariables()) {
diff --git a/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/BugTests.java b/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/BugTests.java
index d20248032..f81f572c3 100644
--- a/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/BugTests.java
+++ b/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/BugTests.java
@@ -966,6 +966,110 @@ public void forRangeStartReadsParameter() {
);
}
+ /** A local read by the loop variable's start expression is read, whichever way the loop counts. */
+ @Test
+ public void forRangeStartReadsLocal() {
+ for (String direction : new String[]{"downto 0", "to 20"}) {
+ CompilationResult result = test()
+ .setStopOnFirstError(false)
+ .executeProg(false)
+ .lines(
+ "package test",
+ "native testSuccess()",
+ "init",
+ " let a = 10",
+ " for i = a " + direction,
+ " skip",
+ "endpackage"
+ );
+
+ Assert.assertTrue(
+ result.getGui().getWarningList().stream()
+ .noneMatch(w -> w.getMessage().contains("assignment to local variable a is never read")),
+ "Unexpected never-read warning for 'a' with 'for i = a " + direction + "': "
+ + result.getGui().getWarningList()
+ );
+ }
+ }
+
+ /**
+ * The start expression runs once before the loop, so a write in the body is not read by it.
+ * The CFG back edge revisits the loop statement, which would otherwise mark the body write as
+ * read and hide this warning.
+ */
+ @Test
+ public void forRangeStartDoesNotCountBodyReassignmentAsRead() {
+ CompilationResult result = test()
+ .setStopOnFirstError(false)
+ .executeProg(false)
+ .lines(
+ "package test",
+ "native testSuccess()",
+ "init",
+ " var a = 10",
+ " for i = a to 20",
+ " a = 5",
+ "endpackage"
+ );
+
+ Assert.assertTrue(
+ result.getGui().getWarningList().stream()
+ .anyMatch(w -> w.getMessage().contains("assignment to local variable a is never read")),
+ "Expected the dead body assignment to 'a' to still warn, got: " + result.getGui().getWarningList()
+ );
+ }
+
+ /**
+ * "to" is hoisted into a temporary before the loop just like the start value, so a body write
+ * to a local it reads is equally dead. Same shape as the start expression, different slot.
+ */
+ @Test
+ public void forRangeToBoundDoesNotCountBodyReassignmentAsRead() {
+ CompilationResult result = test()
+ .setStopOnFirstError(false)
+ .executeProg(false)
+ .lines(
+ "package test",
+ "native testSuccess()",
+ "init",
+ " var n = 20",
+ " for i = 0 to n",
+ " n = 5",
+ "endpackage"
+ );
+
+ Assert.assertTrue(
+ result.getGui().getWarningList().stream()
+ .anyMatch(w -> w.getMessage().contains("assignment to local variable n is never read")),
+ "Expected the dead body assignment to 'n' to still warn, got: " + result.getGui().getWarningList()
+ );
+ }
+
+ /** A body write that is genuinely read later must not warn. */
+ @Test
+ public void forRangeBodyReassignmentReadAfterLoopDoesNotWarn() {
+ CompilationResult result = test()
+ .setStopOnFirstError(false)
+ .executeProg(false)
+ .lines(
+ "package test",
+ "native testSuccess()",
+ "@extern native I2S(int x) returns string",
+ "init",
+ " var a = 10",
+ " for i = a to 20",
+ " a = 5",
+ " I2S(a)",
+ "endpackage"
+ );
+
+ Assert.assertTrue(
+ result.getGui().getWarningList().stream()
+ .noneMatch(w -> w.getMessage().contains("assignment to local variable a is never read")),
+ "Unexpected never-read warning when 'a' is read after the loop: " + result.getGui().getWarningList()
+ );
+ }
+
@Test
public void forRangeLoopVarMutationWarns() {
testAssertWarningsLines(false, "unexpected iteration side effects",