diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index e7e8eab5b..91672d04e 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -11,27 +11,15 @@ concurrency:
cancel-in-progress: true
jobs:
- # Emit a wider matrix on master pushes (adds macOS), narrow on PRs (saves minutes)
- setup:
- runs-on: ubuntu-latest
- outputs:
- matrix: ${{ steps.set-matrix.outputs.matrix }}
- steps:
- - id: set-matrix
- shell: bash
- run: |
- if [[ "${{ github.event_name }}" == "push" ]]; then
- echo 'matrix={"include":[{"os":"ubuntu-latest"},{"os":"windows-latest"},{"os":"macos-latest"},{"os":"macos-15-intel"}]}' >> "$GITHUB_OUTPUT"
- else
- echo 'matrix={"include":[{"os":"ubuntu-latest"},{"os":"windows-latest"}]}' >> "$GITHUB_OUTPUT"
- fi
-
+ # Master pushes exercise every supported host. Pull requests use one Linux job
+ # so that the normal feedback loop does not spend four runner-minutes on the
+ # same compiler test suite; the full host matrix still runs before release.
build:
name: Gradle build on ${{ matrix.os }}
- needs: setup
strategy:
fail-fast: false
- matrix: ${{ fromJson(needs.setup.outputs.matrix) }}
+ matrix:
+ os: ${{ fromJSON(github.event_name == 'push' && '["ubuntu-latest","windows-latest","macos-latest","macos-15-intel"]' || '["ubuntu-latest"]') }}
runs-on: ${{ matrix.os }}
permissions:
checks: write
@@ -96,8 +84,10 @@ jobs:
- name: Setup Gradle (cache)
uses: gradle/actions/setup-gradle@v4
- # ---- FAIL FAST: package first (so jlink issues show immediately) ----
+ # Packaging is a release artifact, not a pull-request gate. Keeping it on
+ # master avoids paying for a second full build on every PR update.
- name: Package slim runtime (fail fast)
+ if: github.event_name == 'push'
shell: bash
run: ./gradlew packageSlimCompilerDist --no-daemon --stacktrace
@@ -105,9 +95,17 @@ jobs:
if: runner.os == 'Linux'
shell: bash
run: |
- if [[ -f src/test/resources/lua53 ]]; then
- chmod +x src/test/resources/lua53
- fi
+ set -euo pipefail
+ # Use Ubuntu's maintained Lua 5.3 build. The checked-in portable
+ # binary is linked against libreadline.so.6, which is absent from
+ # current runner images.
+ sudo apt-get update -qq
+ sudo apt-get install -y --no-install-recommends lua5.3
+ lua5.3 -e 'assert(_VERSION == "Lua 5.3")'
+ luac5.3 -v
+ # These binaries are tracked with mode 0644 for cross-platform
+ # checkouts; keep them usable as a fallback for local/older images.
+ chmod +x src/test/resources/lua53 src/test/resources/luac53
- name: Install Lua compiler (macOS)
if: runner.os == 'macOS'
@@ -119,14 +117,14 @@ jobs:
- name: Run tests
shell: bash
run: |
- if [[ "${{ runner.os }}" == "Linux" ]]; then
+ if [[ "${{ github.event_name }}" == "push" && "${{ runner.os }}" == "Linux" ]]; then
./gradlew test jacocoTestReport --no-daemon --stacktrace --quiet
else
./gradlew test --no-daemon --stacktrace --quiet
fi
- name: Upload coverage to Coveralls
- if: runner.os == 'Linux'
+ if: github.event_name == 'push' && runner.os == 'Linux'
uses: coverallsapp/github-action@v2
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
@@ -157,6 +155,7 @@ jobs:
retention-days: 14
- name: Upload packaged artifact (per-OS)
+ if: github.event_name == 'push'
uses: actions/upload-artifact@v4
with:
name: wurst-compiler-${{ matrix.os }}
diff --git a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/translation/lua/translation/LuaTranslator.java b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/translation/lua/translation/LuaTranslator.java
index a178d416c..59f149520 100644
--- a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/translation/lua/translation/LuaTranslator.java
+++ b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/translation/lua/translation/LuaTranslator.java
@@ -207,8 +207,9 @@ public LuaVariable initFor(ImClass a) {
* per canonical IM field, indexed by that id; class descriptors remain static tables and are
* reached through {@link #objectClass}. Allocation therefore creates no per-instance table.
*
- *
Destroy clears every field slot before putting the id on the free stack. As in the Jass
- * backend, a stale reference aliases a later object after that id is recycled; before reuse its
+ *
Destroy only removes the live-object descriptor before putting the id on the free stack.
+ * Field storage intentionally retains its value, matching the Jass backend's array-backed
+ * fields. A stale reference aliases a later object after that id is recycled; before reuse its
* descriptor is absent, so virtual dispatch fails and {@code instanceof} is false. Capturing
* closures use the same representation and, like Jass closures, retain their id until destroyed.
*/
@@ -1031,12 +1032,6 @@ private void translateClass(ImClass c) {
LuaFunction cleanup = luaClassCleanup.getFor(c);
LuaVariable object = LuaAst.LuaVariable("object", LuaAst.LuaNoExpr());
cleanup.getParams().add(object);
- for (ImVar field : collectFieldsForAllocation(c)) {
- cleanup.getBody().add(LuaAst.LuaAssignment(
- LuaAst.LuaExprArrayAccess(LuaAst.LuaExprVarAccess(fieldStorage(field)),
- LuaAst.LuaExprlist(LuaAst.LuaExprVarAccess(object))),
- LuaAst.LuaExprNull()));
- }
luaModel.add(cleanup);
deferMainInit(LuaAst.LuaAssignment(
LuaAst.LuaExprFieldAccess(LuaAst.LuaExprVarAccess(classVar), "__wurst_dealloc"),
diff --git a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/validation/WurstValidator.java b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/validation/WurstValidator.java
index 7e93250a4..5d2f55ba3 100644
--- a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/validation/WurstValidator.java
+++ b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/validation/WurstValidator.java
@@ -60,6 +60,9 @@ private enum Phase { LIGHT, HEAVY }
private final HashSet trveWrapperFuncs = new HashSet<>();
private final HashMap> wrapperCalls = new HashMap<>();
private final Map> classVarInitOrderCache = new HashMap<>();
+ private final Map guaranteedClassFieldInitCache = new IdentityHashMap<>();
+ private final Map> moduleFieldCopiesCache = new IdentityHashMap<>();
+ private boolean moduleFieldCopiesIndexed;
/**
* When true, the build targets a legacy patch (pre-1.24) whose Blizzard-provided
@@ -83,6 +86,9 @@ public void validate(Collection toCheck) {
visitedFunctions = 0;
heavyFunctions.clear();
heavyBlocks.clear();
+ guaranteedClassFieldInitCache.clear();
+ moduleFieldCopiesCache.clear();
+ moduleFieldCopiesIndexed = false;
lightValidation(toCheck);
@@ -1747,9 +1753,415 @@ private void checkUninitializedVars(FunctionLike f) {
&& !f.getSource().getFile().endsWith("war3map.j")) {
new DataflowAnomalyAnalysis(Utils.isJassCode(f)).execute(f);
}
+ checkPotentiallyUninitializedClassFields(f);
checkJassImplicitNullLocalsReadWithoutExplicitWrite(f);
}
+ /**
+ * Instance fields without an initializer are reset to the language default when an object is
+ * allocated, but that value is often accidental. Warn when a constructor reads such a field
+ * before its value is definitely established. Ordinary methods are intentionally out of scope:
+ * their callers may establish fields through APIs or other construction-time hooks that this
+ * cheap local check cannot see.
+ */
+ private void checkPotentiallyUninitializedClassFields(FunctionLike function) {
+ if (function instanceof OnDestroyDef || !(function instanceof ConstructorDef)) {
+ return;
+ }
+
+ Deque> writtenFieldScopes = new ArrayDeque<>();
+ writtenFieldScopes.push(Collections.newSetFromMap(new IdentityHashMap<>()));
+ Set warned = Collections.newSetFromMap(new IdentityHashMap<>());
+ FunctionCall delegatedConstructorCall = function instanceof ConstructorDef
+ ? getFirstThisConstructorCall((ConstructorDef) function) : null;
+ function.accept(new Element.DefaultVisitor() {
+ private void checkField(NameRef access) {
+ NameDef nameDef = access.attrNameDef();
+ if (!(nameDef instanceof GlobalVarDef field) || !field.attrIsDynamicClassMember()) {
+ return;
+ }
+ if (isWriteTarget(access)) {
+ return;
+ }
+ if (!(field.getInitialExpr() instanceof NoExpr)
+ || (isCurrentInstanceAccess(access)
+ && writtenFieldScopes.peek().contains(field))
+ || ((!isCurrentInstanceAccess(access) || !(function instanceof ConstructorDef))
+ && hasGuaranteedConstructorAssignment(field))
+ || (isCurrentInstanceAccess(access)
+ && function instanceof ConstructorDef
+ && delegatedConstructorCall == null
+ && !access.isSubtreeOf(((ConstructorDef) function).getSuperConstructorCall())
+ && initializedBySuperConstructor((ConstructorDef) function, field))
+ || (delegatedConstructorCall != null && !access.isSubtreeOf(delegatedConstructorCall)
+ && hasGuaranteedConstructorAssignment(field))
+ || !warned.add(field)) {
+ return;
+ }
+ access.addWarning("Field '" + field.getName()
+ + "' has no explicit initializer and is not definitely assigned by every constructor;"
+ + " this access may observe its default value."
+ + " Initialize it explicitly in every construction path.");
+ }
+
+ @Override
+ public void visit(ExprVarAccess access) {
+ super.visit(access);
+ checkField(access);
+ }
+
+ @Override
+ public void visit(ExprVarArrayAccess access) {
+ super.visit(access);
+ checkField(access);
+ }
+
+ @Override
+ public void visit(ExprMemberVarDot access) {
+ super.visit(access);
+ checkField(access);
+ }
+
+ @Override
+ public void visit(ExprMemberVarDotDot access) {
+ super.visit(access);
+ checkField(access);
+ }
+
+ @Override
+ public void visit(ExprMemberVarQuestionDot access) {
+ super.visit(access);
+ checkField(access);
+ }
+
+ @Override
+ public void visit(ExprMemberArrayVarDot access) {
+ super.visit(access);
+ checkField(access);
+ }
+
+ @Override
+ public void visit(ExprMemberArrayVarDotDot access) {
+ super.visit(access);
+ checkField(access);
+ }
+
+ @Override
+ public void visit(ExprClosure closure) {
+ Set closureScope = Collections.newSetFromMap(new IdentityHashMap<>());
+ closureScope.addAll(writtenFieldScopes.peek());
+ writtenFieldScopes.push(closureScope);
+ super.visit(closure);
+ writtenFieldScopes.pop();
+ }
+
+ @Override
+ public void visit(StmtSet assignment) {
+ super.visit(assignment);
+ if (!(assignment.getUpdatedExpr() instanceof NameRef access)
+ || !isCurrentInstanceAccess(access)
+ || !isWriteTarget(access)) {
+ return;
+ }
+ NameDef nameDef = access.attrNameDef();
+ if (nameDef instanceof GlobalVarDef field && field.attrIsDynamicClassMember()
+ && isWholeFieldAccess(access)) {
+ writtenFieldScopes.peek().add(field);
+ }
+ }
+
+ });
+ }
+
+ private Set collectWrittenDynamicFields(Element root) {
+ Set result = Collections.newSetFromMap(new IdentityHashMap<>());
+ root.accept(new Element.DefaultVisitor() {
+ private void collect(NameRef access) {
+ if (access.attrNearestExprClosure() != null
+ || !isWriteTarget(access)
+ || !isCurrentInstanceAccess(access)) {
+ return;
+ }
+ NameDef nameDef = access.attrNameDef();
+ if (nameDef instanceof GlobalVarDef field && field.attrIsDynamicClassMember()
+ && isWholeFieldAccess(access)) {
+ result.add(field);
+ }
+ }
+
+ @Override
+ public void visit(ExprVarAccess access) {
+ super.visit(access);
+ collect(access);
+ }
+
+ @Override
+ public void visit(ExprVarArrayAccess access) {
+ super.visit(access);
+ collect(access);
+ }
+
+ @Override
+ public void visit(ExprClosure closure) {
+ // A closure runs later (and may never run), so writes in its body do not
+ // initialize the object during construction.
+ }
+
+ @Override
+ public void visit(ExprMemberVarDot access) {
+ super.visit(access);
+ collect(access);
+ }
+
+ @Override
+ public void visit(ExprMemberVarDotDot access) {
+ super.visit(access);
+ collect(access);
+ }
+
+ @Override
+ public void visit(ExprMemberVarQuestionDot access) {
+ super.visit(access);
+ collect(access);
+ }
+
+ @Override
+ public void visit(ExprMemberArrayVarDot access) {
+ super.visit(access);
+ collect(access);
+ }
+
+ @Override
+ public void visit(ExprMemberArrayVarDotDot access) {
+ super.visit(access);
+ collect(access);
+ }
+ });
+ return result;
+ }
+
+ private boolean hasGuaranteedConstructorAssignment(GlobalVarDef field) {
+ Boolean cached = guaranteedClassFieldInitCache.get(field);
+ if (cached != null) {
+ return cached;
+ }
+ List constructors = constructorsFor(field);
+ if (allConstructorsAssign(constructors, field)
+ || moduleFieldCopies(field).stream().anyMatch(copy ->
+ allConstructorsAssign(constructorsFor(copy), copy)
+ || allConstructorsAssign(enclosingClassConstructors(copy), copy))
+ || allConstructorsAssign(enclosingClassConstructors(field), field)) {
+ guaranteedClassFieldInitCache.put(field, true);
+ return true;
+ }
+ guaranteedClassFieldInitCache.put(field, false);
+ return false;
+ }
+
+ private boolean allConstructorsAssign(List constructors, GlobalVarDef field) {
+ if (constructors.isEmpty()) {
+ return false;
+ }
+ for (ConstructorDef constructor : constructors) {
+ if (!constructorAssignsField(constructor, field, Collections.newSetFromMap(new IdentityHashMap<>()))) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ private boolean isCurrentInstanceAccess(NameRef access) {
+ return access.attrImplicitParameter() instanceof ExprThis;
+ }
+
+ private boolean isInNestedClosure(NameRef access) {
+ return access.attrNearestExprClosure() != null;
+ }
+
+ private boolean isWholeFieldAccess(NameRef access) {
+ return !(access instanceof AstElementWithIndexes);
+ }
+
+ private boolean constructorAssignsField(ConstructorDef constructor, GlobalVarDef field,
+ Set visiting) {
+ if (!visiting.add(constructor)) {
+ return false;
+ }
+ if (collectWrittenDynamicFields(constructor).contains(field)) {
+ return true;
+ }
+ FunctionCall thisCall = getFirstThisConstructorCall(constructor);
+ if (thisCall != null) {
+ ConstructorDef target = OverloadingResolver.resolveThisCall(constructorsFor(constructor), thisCall);
+ return target != null && target != constructor && constructorAssignsField(target, field, visiting);
+ }
+ ConstructorDef superConstructor = constructor.attrSuperConstructor();
+ return superConstructor != null && constructorAssignsField(superConstructor, field, visiting);
+ }
+
+ private List constructorsFor(GlobalVarDef field) {
+ Element current = field;
+ while (current != null) {
+ if (current instanceof ModuleInstanciation module) {
+ return module.getConstructors();
+ }
+ if (current instanceof ClassOrModule owner) {
+ return owner.getConstructors();
+ }
+ current = current.getParent();
+ }
+ return Collections.emptyList();
+ }
+
+ private List constructorsFor(ConstructorDef constructor) {
+ Element current = constructor;
+ while (current != null) {
+ if (current instanceof ModuleInstanciation module) {
+ return module.getConstructors();
+ }
+ if (current instanceof ClassOrModule owner) {
+ return owner.getConstructors();
+ }
+ current = current.getParent();
+ }
+ return Collections.emptyList();
+ }
+
+ private List enclosingClassConstructors(GlobalVarDef field) {
+ Element current = field;
+ while (current != null) {
+ if (current instanceof ClassDef classDef) {
+ return classDef.getConstructors();
+ }
+ current = current.getParent();
+ }
+ return Collections.emptyList();
+ }
+
+ private List moduleFieldCopies(GlobalVarDef field) {
+ if (!moduleFieldCopiesIndexed) {
+ indexModuleFieldCopies();
+ }
+ return moduleFieldCopiesCache.getOrDefault(field, Collections.emptyList());
+ }
+
+ private void indexModuleFieldCopies() {
+ if (moduleFieldCopiesIndexed) {
+ return;
+ }
+ prog.accept(new Element.DefaultVisitor() {
+ @Override
+ public void visit(ModuleInstanciation instantiation) {
+ ModuleDef origin = instantiation.attrModuleOrigin();
+ if (origin != null) {
+ int count = Math.min(origin.getVars().size(), instantiation.getVars().size());
+ for (int i = 0; i < count; i++) {
+ GlobalVarDef originField = origin.getVars().get(i);
+ moduleFieldCopiesCache.computeIfAbsent(originField, ignored -> new ArrayList<>())
+ .add(instantiation.getVars().get(i));
+ }
+ }
+ super.visit(instantiation);
+ }
+ });
+ moduleFieldCopiesIndexed = true;
+ }
+
+ private boolean initializedBySuperConstructor(ConstructorDef constructor, GlobalVarDef field) {
+ ConstructorDef superConstructor = constructor.attrSuperConstructor();
+ return superConstructor != null
+ && constructorAssignsField(superConstructor, field,
+ Collections.newSetFromMap(new IdentityHashMap<>()));
+ }
+
+ private boolean initializedBySuperclass(GlobalVarDef initializedField, GlobalVarDef referencedField) {
+ ClassDef child = initializedField.attrNearestClassDef();
+ ClassDef declaringClass = referencedField.attrNearestClassDef();
+ if (child == null || declaringClass == null || child == declaringClass) {
+ return false;
+ }
+ WurstTypeClass superType = child.attrTypC().extendedClass();
+ while (superType != null) {
+ if (superType.getClassDef() == declaringClass) {
+ return hasGuaranteedConstructorAssignment(referencedField);
+ }
+ superType = superType.extendedClass();
+ }
+ return false;
+ }
+
+ private void checkClassFieldInitializerReads(GlobalVarDef field) {
+ if (!field.attrIsDynamicClassMember() || !(field.getInitialExpr() instanceof Expr initializer)) {
+ return;
+ }
+ Set warned = Collections.newSetFromMap(new IdentityHashMap<>());
+ initializer.accept(new Element.DefaultVisitor() {
+ private void checkField(NameRef access) {
+ NameDef nameDef = access.attrNameDef();
+ if (!(nameDef instanceof GlobalVarDef referenced)
+ || !referenced.attrIsDynamicClassMember()
+ || !(referenced.getInitialExpr() instanceof NoExpr)
+ || (!isCurrentInstanceAccess(access) && hasGuaranteedConstructorAssignment(referenced))
+ || (isCurrentInstanceAccess(access)
+ && initializedBySuperclass(field, referenced))
+ || !warned.add(referenced)) {
+ return;
+ }
+ access.addWarning("Field '" + referenced.getName()
+ + "' is read from a field initializer without an explicit initializer;"
+ + " this access may observe its default value."
+ + " Initialize it explicitly before using it.");
+ }
+
+ @Override
+ public void visit(ExprClosure closure) {
+ // A closure runs later (and may never run), so its body is not field initialization.
+ }
+
+ @Override
+ public void visit(ExprVarAccess access) {
+ super.visit(access);
+ checkField(access);
+ }
+
+ @Override
+ public void visit(ExprVarArrayAccess access) {
+ super.visit(access);
+ checkField(access);
+ }
+
+ @Override
+ public void visit(ExprMemberVarDot access) {
+ super.visit(access);
+ checkField(access);
+ }
+
+ @Override
+ public void visit(ExprMemberVarDotDot access) {
+ super.visit(access);
+ checkField(access);
+ }
+
+ @Override
+ public void visit(ExprMemberVarQuestionDot access) {
+ super.visit(access);
+ checkField(access);
+ }
+
+ @Override
+ public void visit(ExprMemberArrayVarDot access) {
+ super.visit(access);
+ checkField(access);
+ }
+
+ @Override
+ public void visit(ExprMemberArrayVarDotDot access) {
+ super.visit(access);
+ checkField(access);
+ }
+ });
+ }
+
/**
* JASS compatibility shim: we currently synthesize "= null" for uninitialized non-primitive
* locals to avoid invalid emitted JASS. Still report likely user bugs early when such a local
@@ -1836,11 +2248,15 @@ public void visit(ExprVarAccess varAccess) {
}
private boolean isWriteTarget(ExprVarAccess varAccess) {
- if (!(varAccess.getParent() instanceof StmtSet)) {
+ return isWriteTarget((Element) varAccess);
+ }
+
+ private boolean isWriteTarget(Element access) {
+ if (!(access.getParent() instanceof StmtSet)) {
return false;
}
- StmtSet set = (StmtSet) varAccess.getParent();
- return set.getUpdatedExpr() == varAccess;
+ StmtSet set = (StmtSet) access.getParent();
+ return set.getUpdatedExpr() == access;
}
private @Nullable StmtSet nearestEnclosingStmtSet(Element e) {
@@ -3574,7 +3990,9 @@ private void checkVarDef(VarDef v) {
}
if (v instanceof GlobalVarDef) {
- checkClassMemberInitializerOrder((GlobalVarDef) v);
+ GlobalVarDef field = (GlobalVarDef) v;
+ checkClassMemberInitializerOrder(field);
+ checkClassFieldInitializerReads(field);
}
}
diff --git a/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/ClassesTests.java b/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/ClassesTests.java
index b61b4c7f9..a6773e701 100644
--- a/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/ClassesTests.java
+++ b/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/ClassesTests.java
@@ -9,6 +9,7 @@
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
+import static org.testng.Assert.assertEquals;
import static org.testng.Assert.assertFalse;
import static org.testng.Assert.assertTrue;
@@ -17,6 +18,431 @@ public class ClassesTests extends WurstScriptTest {
private static final String TEST_DIR = "./testscripts/valid/classes/";
private static final String TEST_DIR2 = "./testscripts/concept/";
+ @Test
+ public void warnsWhenClassFieldHasNoInitializerOrConstructorAssignment() {
+ test()
+ .setStopOnFirstError(false)
+ .executeProg(false)
+ .expectWarning("no explicit initializer and is not definitely assigned")
+ .lines(
+ "package Test",
+ "class Counter",
+ " int value",
+ " construct()",
+ " int oldValue = value",
+ " value = 42"
+ );
+ }
+
+ @Test
+ public void doesNotWarnWhenEveryConstructorAssignsClassField() {
+ CompilationResult result = test()
+ .setStopOnFirstError(false)
+ .executeProg(false)
+ .lines(
+ "package Test",
+ "class Counter",
+ " int value",
+ " construct(int value)",
+ " this.value = value",
+ " function get() returns int",
+ " return value",
+ " function setAndGet() returns int",
+ " value = 42",
+ " return value"
+ );
+
+ assertFalse(result.getGui().getWarningList().stream()
+ .anyMatch(w -> w.getMessage().contains("no explicit initializer and is not definitely assigned")));
+ }
+
+ @Test
+ public void warnsWhenConstructorOnlyAssignsOtherInstanceField() {
+ CompilationResult result = test()
+ .setStopOnFirstError(false)
+ .executeProg(false)
+ .lines(
+ "package Test",
+ "class Counter",
+ " int value",
+ " construct(Counter other)",
+ " other.value = 1",
+ " int _observed = this.value"
+ );
+
+ assertEquals(result.getGui().getWarningList().stream()
+ .filter(w -> w.getMessage().contains("no explicit initializer and is not definitely assigned"))
+ .count(), 1);
+ }
+
+ @Test
+ public void warnsWhenConstructorReadsFieldBeforeAssigningIt() {
+ test()
+ .setStopOnFirstError(false)
+ .executeProg(false)
+ .expectWarning("no explicit initializer and is not definitely assigned")
+ .lines(
+ "package Test",
+ "class Counter",
+ " int value",
+ " construct()",
+ " int oldValue = value",
+ " value = 1"
+ );
+ }
+
+ @Test
+ public void warnsWhenUnqualifiedArrayFieldHasNoInitializer() {
+ test()
+ .setStopOnFirstError(false)
+ .executeProg(false)
+ .expectWarning("no explicit initializer and is not definitely assigned")
+ .lines(
+ "package Test",
+ "class Counter",
+ " int values[2]",
+ " construct()",
+ " int first = values[0]"
+ );
+ }
+
+ @Test
+ public void warnsWhenFieldIsReadOnRightHandSideBeforeAssignment() {
+ test()
+ .setStopOnFirstError(false)
+ .executeProg(false)
+ .expectWarning("no explicit initializer and is not definitely assigned")
+ .lines(
+ "package Test",
+ "class Counter",
+ " int value",
+ " construct()",
+ " value = value + 1",
+ " skip"
+ );
+ }
+
+ @Test
+ public void warnsWhenOtherInstanceFieldIsReadInConstructor() {
+ test()
+ .setStopOnFirstError(false)
+ .executeProg(false)
+ .expectWarning("no explicit initializer and is not definitely assigned")
+ .lines(
+ "package Test",
+ "class Counter",
+ " int value",
+ " construct(Counter other)",
+ " let _observed = other.value"
+ );
+ }
+
+ @Test
+ public void warnsWhenOnlyOneArrayElementWasAssigned() {
+ test()
+ .setStopOnFirstError(false)
+ .executeProg(false)
+ .expectWarning("no explicit initializer and is not definitely assigned")
+ .lines(
+ "package Test",
+ "class Counter",
+ " int values[2]",
+ " construct()",
+ " values[0] = 1",
+ " int observed = values[1]"
+ );
+ }
+
+ @Test
+ public void doesNotWarnWhenDelegatingConstructorAssignsClassField() {
+ CompilationResult result = test()
+ .setStopOnFirstError(false)
+ .executeProg(false)
+ .lines(
+ "package Test",
+ "class Counter",
+ " int value",
+ " construct(int value)",
+ " this.value = value",
+ " construct()",
+ " this(1)",
+ " function get() returns int",
+ " return value"
+ );
+
+ assertFalse(result.getGui().getWarningList().stream()
+ .anyMatch(w -> w.getMessage().contains("no explicit initializer and is not definitely assigned")));
+ }
+
+ @Test
+ public void doesNotWarnAfterDelegatingConstructorBeforeLaterRead() {
+ CompilationResult result = test()
+ .setStopOnFirstError(false)
+ .executeProg(false)
+ .lines(
+ "package Test",
+ "class Counter",
+ " int value",
+ " construct(int value)",
+ " this.value = value",
+ " construct()",
+ " this(1)",
+ " int observed = value",
+ " value = observed",
+ " function get() returns int",
+ " return value"
+ );
+
+ assertFalse(result.getGui().getWarningList().stream()
+ .anyMatch(w -> w.getMessage().contains("no explicit initializer")));
+ }
+
+ @Test
+ public void warnsWhenFieldInitializerReadsUninitializedField() {
+ test()
+ .setStopOnFirstError(false)
+ .executeProg(false)
+ .expectWarning("read from a field initializer without an explicit initializer")
+ .lines(
+ "package Test",
+ "class Counter",
+ " int value",
+ " int copy = value"
+ );
+ }
+
+ @Test
+ public void doesNotWarnWhenModuleConstructorAssignsClassField() {
+ CompilationResult result = test()
+ .setStopOnFirstError(false)
+ .executeProg(false)
+ .lines(
+ "package Test",
+ "module Values",
+ " int value",
+ " construct()",
+ " value = 1",
+ " function get() returns int",
+ " return value",
+ "class Counter",
+ " use Values"
+ );
+
+ assertFalse(result.getGui().getWarningList().stream()
+ .anyMatch(w -> w.getMessage().contains("no explicit initializer")),
+ result.getGui().getWarningList().toString());
+ }
+
+ @Test
+ public void doesNotTreatDeferredClosureInitializerAsImmediateFieldRead() {
+ CompilationResult result = test()
+ .setStopOnFirstError(false)
+ .executeProg(false)
+ .lines(
+ "package Test",
+ "interface Reader",
+ " function read() returns int",
+ "class Counter",
+ " int value",
+ " Reader reader = () -> value"
+ );
+
+ assertFalse(result.getGui().getWarningList().stream()
+ .anyMatch(w -> w.getMessage().contains("read from a field initializer")),
+ result.getGui().getWarningList().toString());
+ }
+
+ @Test
+ public void doesNotWarnForExplicitReceiverWithGuaranteedConstructorAssignment() {
+ CompilationResult result = test()
+ .setStopOnFirstError(false)
+ .executeProg(false)
+ .lines(
+ "package Test",
+ "class Source",
+ " int value",
+ " construct()",
+ " value = 1",
+ "class Holder",
+ " Source source = new Source()",
+ " int copy = source.value"
+ );
+
+ assertFalse(result.getGui().getWarningList().stream()
+ .anyMatch(w -> w.getMessage().contains("read from a field initializer")),
+ result.getGui().getWarningList().toString());
+ }
+
+ @Test
+ public void doesNotWarnForInitializedExplicitReceiverInsideConstructor() {
+ CompilationResult result = test()
+ .setStopOnFirstError(false)
+ .executeProg(false)
+ .lines(
+ "package Test",
+ "class Source",
+ " int value",
+ " construct()",
+ " value = 1",
+ "class Holder",
+ " construct(Source source)",
+ " int copy = source.value"
+ );
+
+ assertFalse(result.getGui().getWarningList().stream()
+ .anyMatch(w -> w.getMessage().contains("no explicit initializer and is not definitely assigned")),
+ result.getGui().getWarningList().toString());
+ }
+
+ @Test
+ public void doesNotWarnForUninitializedFieldReadFromPackageFunction() {
+ CompilationResult result = test()
+ .setStopOnFirstError(false)
+ .executeProg(false)
+ .lines(
+ "package Test",
+ "class Counter",
+ " int value",
+ "function read(Counter counter) returns int",
+ " return counter.value"
+ );
+
+ assertFalse(result.getGui().getWarningList().stream()
+ .anyMatch(w -> w.getMessage().contains("no explicit initializer and is not definitely assigned")),
+ result.getGui().getWarningList().toString());
+ }
+
+ @Test
+ public void doesNotWarnForClosureReadAfterPriorAssignment() {
+ CompilationResult result = test()
+ .setStopOnFirstError(false)
+ .executeProg(false)
+ .lines(
+ "package Test",
+ "interface Reader",
+ " function read() returns int",
+ "function consume(Reader reader)",
+ "class Counter",
+ " int value",
+ " construct()",
+ " value = 1",
+ " consume(() -> value)"
+ );
+
+ assertFalse(result.getGui().getWarningList().stream()
+ .anyMatch(w -> w.getMessage().contains("no explicit initializer and is not definitely assigned")),
+ result.getGui().getWarningList().toString());
+ }
+
+ @Test
+ public void doesNotWarnForClosureReadAfterClosureAssignment() {
+ CompilationResult result = test()
+ .setStopOnFirstError(false)
+ .executeProg(false)
+ .lines(
+ "package Test",
+ "interface Reader",
+ " function read() returns int",
+ "function consume(Reader reader)",
+ "class Counter",
+ " int value",
+ " construct()",
+ " consume() ->",
+ " value = 1",
+ " return value"
+ );
+
+ assertFalse(result.getGui().getWarningList().stream()
+ .anyMatch(w -> w.getMessage().contains("no explicit initializer and is not definitely assigned")),
+ result.getGui().getWarningList().toString());
+ }
+
+ @Test
+ public void doesNotWarnForInheritedFieldAfterSuperclassConstructor() {
+ CompilationResult result = test()
+ .setStopOnFirstError(false)
+ .executeProg(false)
+ .lines(
+ "package Test",
+ "class Base",
+ " int value",
+ " construct()",
+ " value = 1",
+ "class Child extends Base",
+ " construct()",
+ " int copy = value"
+ );
+
+ assertFalse(result.getGui().getWarningList().stream()
+ .anyMatch(w -> w.getMessage().contains("no explicit initializer and is not definitely assigned")),
+ result.getGui().getWarningList().toString());
+ }
+
+ @Test
+ public void doesNotWarnForInheritedFieldAfterGrandparentConstructor() {
+ CompilationResult result = test()
+ .setStopOnFirstError(false)
+ .executeProg(false)
+ .lines(
+ "package Test",
+ "class GrandBase",
+ " int value",
+ " construct()",
+ " value = 1",
+ "class Base extends GrandBase",
+ " construct()",
+ "class Child extends Base",
+ " construct()",
+ " int copy = value"
+ );
+
+ assertFalse(result.getGui().getWarningList().stream()
+ .anyMatch(w -> w.getMessage().contains("no explicit initializer and is not definitely assigned")),
+ result.getGui().getWarningList().toString());
+ }
+
+ @Test
+ public void doesNotWarnForInheritedFieldInitializerAfterSuperclassConstructor() {
+ CompilationResult result = test()
+ .setStopOnFirstError(false)
+ .executeProg(false)
+ .lines(
+ "package Test",
+ "class Base",
+ " int value",
+ " construct()",
+ " value = 1",
+ "class Child extends Base",
+ " int copy = value"
+ );
+
+ assertFalse(result.getGui().getWarningList().stream()
+ .anyMatch(w -> w.getMessage().contains("read from a field initializer")),
+ result.getGui().getWarningList().toString());
+ }
+
+ @Test
+ public void doesNotWarnWhenClassConstructorAssignsModuleField() {
+ CompilationResult result = test()
+ .setStopOnFirstError(false)
+ .executeProg(false)
+ .lines(
+ "package Test",
+ "module Values",
+ " int value",
+ " function get() returns int",
+ " return value",
+ "class Counter",
+ " use Values",
+ " construct()",
+ " value = 1"
+ );
+
+ assertFalse(result.getGui().getWarningList().stream()
+ .anyMatch(w -> w.getMessage().contains("no explicit initializer and is not definitely assigned")),
+ result.getGui().getWarningList().toString());
+ }
+
@Test
public void classes1() throws IOException {
testAssertOkFile(new File(TEST_DIR + "Classes_1.wurst"), true);
diff --git a/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/LuaBackendAuditTests.java b/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/LuaBackendAuditTests.java
index ae565f849..2044833a1 100644
--- a/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/LuaBackendAuditTests.java
+++ b/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/LuaBackendAuditTests.java
@@ -132,7 +132,7 @@ public void classInstancesUseRecycledIntegerIdsAndStaticFieldStorage() throws IO
compiled.contains("__wurst_classToIndex(first)"));
assertFalse("class casts must not allocate boxed-number identity wrappers",
compiled.contains("firstId = __wurst_objectToIndex(first)"));
- assertTrue("deallocation must clear reference-bearing field slots before recycling",
+ assertFalse("deallocation must preserve field values just like Jass storage",
compiled.contains("Base_reference_storage[object] = nil"));
}
diff --git a/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/WurstScriptTest.java b/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/WurstScriptTest.java
index 999452e98..023bac4e4 100644
--- a/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/WurstScriptTest.java
+++ b/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/WurstScriptTest.java
@@ -757,6 +757,10 @@ protected String getLuaExecutable() {
candidates.add("lua53.exe");
candidates.add("lua");
} else {
+ // Prefer the distribution's versioned Lua 5.3 binary when present.
+ // The checked-in portable binary may depend on an older system
+ // readline ABI on newer Linux runner images.
+ candidates.add("lua5.3");
if (bundledLuaUnix.exists()) {
// best effort in case execute bit was lost by checkout settings
// (e.g. core.filemode false on some environments)
@@ -817,6 +821,7 @@ private String getLuacExecutable() {
candidates.add("luac.exe");
candidates.add("luac");
} else {
+ candidates.add("luac5.3");
if (bundledLuacUnix.exists()) {
bundledLuacUnix.setExecutable(true);
if (bundledLuacUnix.canExecute()) {