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()) {