From 9ca7a20ed28047e06dfb55a2ed95d78334f654ef Mon Sep 17 00:00:00 2001 From: Frotty Date: Mon, 3 Aug 2026 17:23:25 +0200 Subject: [PATCH 01/10] Infer toString in string concatenation --- .../wurstscript/attributes/AttrExprType.java | 4 ++ .../wurstscript/attributes/AttrFuncDef.java | 45 +++++++++++++++++++ .../imtranslation/ExprTranslation.java | 28 ++++++++++++ .../wurstscript/tests/ExpressionTests.java | 26 +++++++++++ 4 files changed, 103 insertions(+) diff --git a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/attributes/AttrExprType.java b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/attributes/AttrExprType.java index 45d8148f3..17f7f223a 100644 --- a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/attributes/AttrExprType.java +++ b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/attributes/AttrExprType.java @@ -285,6 +285,10 @@ public static WurstType calculate(final ExprBinary term) { if (leftType instanceof WurstTypeString && rightType instanceof WurstTypeString) { return WurstTypeString.instance(); } + if (AttrFuncDef.implicitToStringForConcatOperand(term, term.getLeft()) != null + || AttrFuncDef.implicitToStringForConcatOperand(term, term.getRight()) != null) { + return WurstTypeString.instance(); + } if (bothTypesRealOrInt(term)) { return caseMathOperation(term); } else { diff --git a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/attributes/AttrFuncDef.java b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/attributes/AttrFuncDef.java index 8e3d6cbfa..020197d46 100644 --- a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/attributes/AttrFuncDef.java +++ b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/attributes/AttrFuncDef.java @@ -67,9 +67,54 @@ public static FuncLink calculate(final ExprFuncRef node) { public static @Nullable FuncLink calculate(ExprBinary node) { + if (implicitToStringForConcatOperand(node, node.getLeft()) != null + || implicitToStringForConcatOperand(node, node.getRight()) != null) { + return null; + } return getExtensionFunction(node.getLeft(), node.getRight(), node.getOp()); } + /** Finds the same zero-argument string conversion that an explicit operand.toString() call would use. */ + public static @Nullable FuncLink implicitToStringForConcatOperand(ExprBinary concat, Expr operand) { + if (concat.getOp() != WurstOperator.PLUS || operand.attrTyp() instanceof WurstTypeString) { + return null; + } + Expr other = concat.getLeft() == operand ? concat.getRight() : concat.getLeft(); + if (!(other.attrTyp() instanceof WurstTypeString)) { + return null; + } + + Collection raw = NameResolution.lookupMemberFuncs( + operand, operand.attrTyp(), "toString", false); + List methods = new ArrayList<>(); + List extensions = new ArrayList<>(); + for (FuncLink candidate : raw) { + if (!isVisible(candidate) + || (candidate.getDef() instanceof FuncDef && ((FuncDef) candidate.getDef()).attrIsStatic())) { + continue; + } + FunctionSignature matched = FunctionSignature.fromNameLink(candidate) + .matchAgainstArgs(Collections.emptyList(), operand); + if (matched == null || !matched.getReturnType().isSubtypeOf(WurstTypeString.instance(), operand)) { + continue; + } + FuncLink adapted = candidate.withTypeArgBinding(operand, matched.getMapping()); + if (isExtension(adapted)) { + extensions.add(adapted); + } else { + methods.add(adapted); + } + } + + if (!methods.isEmpty()) { + return keepMostSpecificReceivers(methods, FuncLink::getReceiverType, operand).get(0); + } + if (!extensions.isEmpty()) { + return keepMostSpecificReceivers(extensions, FuncLink::getReceiverType, operand).get(0); + } + return null; + } + public static @Nullable FuncLink calculate(final ExprMemberMethod node) { WurstType recvT = node.getLeft().attrTyp(); var raw = NameResolution.lookupMemberFuncs(node, recvT, node.getFuncName(), /*showErrors=*/false); diff --git a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/translation/imtranslation/ExprTranslation.java b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/translation/imtranslation/ExprTranslation.java index 068a365e7..b84b7ec6e 100644 --- a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/translation/imtranslation/ExprTranslation.java +++ b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/translation/imtranslation/ExprTranslation.java @@ -172,6 +172,10 @@ public static ImExpr translateIntern(ExprBinary e, ImTranslator t, ImFunction f) ImExpr left = e.getLeft().imTranslateExpr(t, f); ImExpr right = e.getRight().imTranslateExpr(t, f); WurstOperator op = e.getOp(); + if (op == WurstOperator.PLUS) { + left = wrapImplicitToString(e, e.getLeft(), left, t); + right = wrapImplicitToString(e, e.getRight(), right, t); + } if (e.attrFuncLink() != null) { // overloaded operator ImFunction calledFunc = t.getFuncFor(e.attrFuncDef()); @@ -199,6 +203,30 @@ public static ImExpr translateIntern(ExprBinary e, ImTranslator t, ImFunction f) return ImOperatorCall(op, ImExprs(left, right)); } + private static ImExpr wrapImplicitToString(ExprBinary concat, Expr operand, ImExpr translated, + ImTranslator t) { + FuncLink toString = AttrFuncDef.implicitToStringForConcatOperand(concat, operand); + if (toString == null) { + return translated; + } + + FunctionDefinition calledFunc = toString.getDef().attrRealFuncDef(); + FunctionSignature signature = FunctionSignature.fromNameLink(toString); + if (calledFunc instanceof FuncDef + && !((FuncDef) calledFunc).attrIsStatic() + && operand.attrTyp().allowsDynamicDispatch()) { + ImMethod method = t.getMethodFor((FuncDef) calledFunc); + ImTypeArguments typeArguments = getFunctionCallTypeArguments( + t, signature, operand, method.getImplementation().getTypeVariables()); + return ImMethodCall(operand, method, typeArguments, translated, ImExprs(), false); + } + + ImFunction calledImFunc = t.getFuncFor(calledFunc); + ImTypeArguments typeArguments = getFunctionCallTypeArguments( + t, signature, operand, calledImFunc.getTypeVariables()); + return ImFunctionCall(operand, calledImFunc, typeArguments, ImExprs(translated), false, CallType.NORMAL); + } + public static ImExpr translateIntern(ExprUnary e, ImTranslator t, ImFunction f) { return ImOperatorCall(e.getOpU(), ImExprs(e.getRight().imTranslateExpr(t, f))); } diff --git a/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/ExpressionTests.java b/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/ExpressionTests.java index 300f936d8..53657437d 100644 --- a/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/ExpressionTests.java +++ b/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/ExpressionTests.java @@ -10,6 +10,32 @@ public void plus() { assertOk("3 + 7 == 10"); } + @Test + public void inferToStringInStringConcatenation() { + test().testLua(true).luaOnly(false).executeProg().lines( + "package test", + "native testSuccess()", + "class Vec", + " function toString() returns string", + " return \"vec\"", + "class FancyVec extends Vec", + " override function toString() returns string", + " return \"fancy\"", + "class Box", + " function toString() returns string", + " return \"box\"", + "function int.toString() returns string", + " if this == 7", + " return \"seven\"", + " return \"other\"", + "init", + " Vec v = new FancyVec", + " let box = new Box", + " if \"before \" + v + \" after\" == \"before fancy after\" and v + \"!\" == \"fancy!\" and \"number \" + 7 == \"number seven\" and box + \"ed\" == \"boxed\"", + " testSuccess()" + ); + } + @Test public void real1() { assertOk(".3 + .7 == 1."); From b4cacbd76e7a5172ecf51081714310b072862bdd Mon Sep 17 00:00:00 2001 From: Frotty Date: Mon, 3 Aug 2026 17:50:15 +0200 Subject: [PATCH 02/10] Add redundant toString quick fix --- .../peeeq/wurstio/WurstCompilerJassImpl.java | 4 +- .../requests/CodeActionRequest.java | 17 ++++++++ .../wurstscript/attributes/AttrFuncDef.java | 9 ++++- .../attributes/CompilationUnitInfo.java | 9 +++++ .../validation/WurstValidator.java | 25 +++++++++++- .../wurstscript/tests/ExpressionTests.java | 13 ++++++ .../tests/LspNativeFeaturesTests.java | 40 +++++++++++++++++++ 7 files changed, 114 insertions(+), 3 deletions(-) diff --git a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstio/WurstCompilerJassImpl.java b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstio/WurstCompilerJassImpl.java index 27771de5d..5f38f5f97 100644 --- a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstio/WurstCompilerJassImpl.java +++ b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstio/WurstCompilerJassImpl.java @@ -336,7 +336,9 @@ private CompilationUnit loadLibPackage(Function addCompil gui.sendError(new CompileError(new WPos("", null, 0, 0), "Could not find lib-package " + imp + ". Is your dependency present in _build/dependencies?")); return Ast.CompilationUnit(new CompilationUnitInfo(errorHandler), Ast.JassToplevelDeclarations(), Ast.WPackages()); } else { - return addCompilationUnit.apply(file); + CompilationUnit lib = addCompilationUnit.apply(file); + lib.getCuInfo().setLibrary(true); + return lib; } } diff --git a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstio/languageserver/requests/CodeActionRequest.java b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstio/languageserver/requests/CodeActionRequest.java index 3d5963e43..b5814731a 100644 --- a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstio/languageserver/requests/CodeActionRequest.java +++ b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstio/languageserver/requests/CodeActionRequest.java @@ -1,10 +1,12 @@ package de.peeeq.wurstio.languageserver.requests; import de.peeeq.wurstio.languageserver.BufferManager; +import de.peeeq.wurstio.languageserver.Convert; import de.peeeq.wurstio.languageserver.ModelManager; import de.peeeq.wurstio.languageserver.WFile; import de.peeeq.wurstscript.WLogger; import de.peeeq.wurstscript.ast.*; +import de.peeeq.wurstscript.attributes.AttrFuncDef; import de.peeeq.wurstscript.attributes.CompilationUnitInfo; import de.peeeq.wurstscript.attributes.names.DefLink; import de.peeeq.wurstscript.attributes.names.FuncLink; @@ -155,6 +157,21 @@ private List> makeWarningQuickFixes(Optional call.getFuncName().equals("toString") && call.getArgs().isEmpty()) + .ifPresent(call -> { + Range receiverRange = Convert.range(call.getLeft()); + Range callRange = Convert.range(call); + TextEdit edit = new TextEdit( + new Range(receiverRange.getEnd(), callRange.getEnd()), ""); + result.add(Either.forRight(makeQuickFix( + "Remove redundant .toString()", + workspaceEdit(filename.getUriString(), edit) + ))); + }); + } + if (hasDiagnosticMessage(msg -> msg.startsWith("The import ") && msg.endsWith(UNUSED_IMPORT_WARNING_SUFFIX))) { int line0 = e.flatMap(elem -> findNearest(elem, WImport.class)) .map(imp -> imp.attrSource().getLine() - 1) diff --git a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/attributes/AttrFuncDef.java b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/attributes/AttrFuncDef.java index 020197d46..46e7c3911 100644 --- a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/attributes/AttrFuncDef.java +++ b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/attributes/AttrFuncDef.java @@ -27,6 +27,8 @@ * this attribute find the variable definition for every variable reference */ public class AttrFuncDef { + public static final String REDUNDANT_TO_STRING_WARNING = + "Explicit .toString() is redundant in this string concatenation."; // TODO just use the attr function signature to get the def @@ -74,7 +76,7 @@ public static FuncLink calculate(final ExprFuncRef node) { return getExtensionFunction(node.getLeft(), node.getRight(), node.getOp()); } - /** Finds the same zero-argument string conversion that an explicit operand.toString() call would use. */ + /** Returns the implicit conversion for a non-string operand next to a string in a + expression. */ public static @Nullable FuncLink implicitToStringForConcatOperand(ExprBinary concat, Expr operand) { if (concat.getOp() != WurstOperator.PLUS || operand.attrTyp() instanceof WurstTypeString) { return null; @@ -84,6 +86,11 @@ public static FuncLink calculate(final ExprFuncRef node) { return null; } + return findToStringConversion(operand); + } + + /** Resolves the same zero-argument string conversion that an explicit operand.toString() call would use. */ + public static @Nullable FuncLink findToStringConversion(Expr operand) { Collection raw = NameResolution.lookupMemberFuncs( operand, operand.attrTyp(), "toString", false); List methods = new ArrayList<>(); diff --git a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/attributes/CompilationUnitInfo.java b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/attributes/CompilationUnitInfo.java index 4490c5764..303b4ee8a 100644 --- a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/attributes/CompilationUnitInfo.java +++ b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/attributes/CompilationUnitInfo.java @@ -11,6 +11,7 @@ public class CompilationUnitInfo { private de.peeeq.wurstscript.attributes.ErrorHandler cuErrorHandler; private IndentationMode indentationMode = IndentationMode.spaces(4); private TriviaIndex triviaIndex = TriviaIndex.empty(); + private boolean library; public CompilationUnitInfo(ErrorHandler cuErrorHandler) { this.cuErrorHandler = cuErrorHandler; @@ -48,6 +49,14 @@ public void setTriviaIndex(TriviaIndex triviaIndex) { this.triviaIndex = triviaIndex == null ? TriviaIndex.empty() : triviaIndex; } + public boolean isLibrary() { + return library; + } + + public void setLibrary(boolean library) { + this.library = library; + } + public interface IndentationMode { static IndentationMode tabs() { 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 d5d4dc070..dcb6527e9 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 @@ -2,12 +2,13 @@ import com.google.common.collect.*; import de.peeeq.wurstscript.WLogger; +import de.peeeq.wurstscript.WurstOperator; import de.peeeq.wurstscript.ast.*; +import de.peeeq.wurstscript.attributes.AttrFuncDef; import de.peeeq.wurstscript.attributes.CofigOverridePackages; import de.peeeq.wurstscript.attributes.CompileError; import de.peeeq.wurstscript.attributes.ImplicitFuncs; import de.peeeq.wurstscript.attributes.OverloadingResolver; -import de.peeeq.wurstscript.attributes.AttrFuncDef; import de.peeeq.wurstscript.attributes.names.DefLink; import de.peeeq.wurstscript.attributes.names.FuncLink; import de.peeeq.wurstscript.attributes.names.NameLink; @@ -2086,6 +2087,28 @@ private void visit(ExprBinary expr) { private void visit(ExprMemberMethod stmtCall) { // calculating the exprType should reveal all errors: stmtCall.attrTyp(); + if (stmtCall.attrCompilationUnit().getCuInfo().isLibrary()) { + return; + } + if (!(stmtCall instanceof ExprMemberMethodDot) + || !stmtCall.getFuncName().equals("toString") + || !stmtCall.getArgs().isEmpty() + || !(stmtCall.getParent() instanceof ExprBinary)) { + return; + } + ExprBinary concat = (ExprBinary) stmtCall.getParent(); + if (concat.getOp() != WurstOperator.PLUS) { + return; + } + Expr other = concat.getLeft() == stmtCall ? concat.getRight() : concat.getLeft(); + if (!(other.attrTyp() instanceof WurstTypeString)) { + return; + } + FuncLink explicit = stmtCall.attrFuncLink(); + FuncLink inferred = AttrFuncDef.findToStringConversion(stmtCall.getLeft()); + if (explicit != null && explicit.equals(inferred)) { + stmtCall.addWarning(AttrFuncDef.REDUNDANT_TO_STRING_WARNING); + } } private void visit(ExprNewObject stmtCall) { diff --git a/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/ExpressionTests.java b/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/ExpressionTests.java index 53657437d..c5d0ea936 100644 --- a/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/ExpressionTests.java +++ b/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/ExpressionTests.java @@ -36,6 +36,19 @@ public void inferToStringInStringConcatenation() { ); } + @Test + public void redundantToStringInStringConcatenation() { + testAssertWarningsLines(false, "Explicit .toString() is redundant in this string concatenation.", + "package test", + "class Vec", + " function toString() returns string", + " return \"vec\"", + "init", + " let v = new Vec", + " let message = \"value: \" + v.toString()" + ); + } + @Test public void real1() { assertOk(".3 + .7 == 1."); diff --git a/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/LspNativeFeaturesTests.java b/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/LspNativeFeaturesTests.java index f1633079b..788729cec 100644 --- a/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/LspNativeFeaturesTests.java +++ b/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/LspNativeFeaturesTests.java @@ -201,6 +201,46 @@ public void codeActionCanReplaceVarWithLetForConstantLocal() throws IOException assertEquals(edit.getNewText(), "let"); } + @Test + public void codeActionCanRemoveRedundantToStringFromConcatenation() throws IOException { + CompletionTestData data = input( + "package test", + "class Vec", + " function toString() returns string", + " return \"vec\"", + "init", + " let v = new Vec", + " let message = \"value: \" + v.toStr|ing()", + "endpackage" + ); + TestContext ctx = createContext(data, data.buffer); + + CodeActionParams params = new CodeActionParams(); + params.setTextDocument(new TextDocumentIdentifier(ctx.uri)); + params.setRange(new Range(new Position(data.line, data.column), new Position(data.line, data.column))); + Diagnostic d = new Diagnostic(); + d.setRange(params.getRange()); + d.setMessage("Explicit .toString() is redundant in this string concatenation."); + params.setContext(new CodeActionContext(Collections.singletonList(d))); + + List codeActions = new CodeActionRequest(params, ctx.bufferManager).execute(ctx.modelManager).stream() + .filter(Either::isRight) + .map(Either::getRight) + .collect(Collectors.toList()); + CodeAction fix = codeActions.stream() + .filter(a -> "Remove redundant .toString()".equals(a.getTitle())) + .findFirst() + .orElseThrow(() -> new AssertionError("expected toString removal quickfix, got: " + + codeActions.stream().map(CodeAction::getTitle).collect(Collectors.toList()))); + + TextEdit edit = allTextEdits(fix.getEdit()).get(0); + String sourceLine = data.buffer.split("\\r?\\n", -1)[data.line]; + int suffixStart = sourceLine.indexOf(".toString()"); + assertEquals(edit.getRange().getStart(), new Position(data.line, suffixStart)); + assertEquals(edit.getRange().getEnd(), new Position(data.line, suffixStart + ".toString()".length())); + assertEquals(edit.getNewText(), ""); + } + @Test public void codeActionCanRemoveUnusedImport() throws IOException { CompletionTestData data = input( From ce6b3db055b7e08b244802ea8f9d70589ab16e0f Mon Sep 17 00:00:00 2001 From: Frotty Date: Mon, 3 Aug 2026 19:06:04 +0200 Subject: [PATCH 03/10] Fix implicit toString resolution edge cases --- .../languageserver/ModelManagerImpl.java | 3 + .../wurstscript/attributes/AttrExprType.java | 8 +++ .../wurstscript/attributes/AttrFuncDef.java | 58 +++++++++++++++++-- .../validation/WurstValidator.java | 8 +++ .../wurstscript/tests/ExpressionTests.java | 52 +++++++++++++++++ .../wurstscript/tests/ModelManagerTests.java | 15 ++++- 6 files changed, 138 insertions(+), 6 deletions(-) diff --git a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstio/languageserver/ModelManagerImpl.java b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstio/languageserver/ModelManagerImpl.java index dface9274..c7b21f278 100644 --- a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstio/languageserver/ModelManagerImpl.java +++ b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstio/languageserver/ModelManagerImpl.java @@ -644,6 +644,9 @@ private CompilationUnit replaceCompilationUnit(WFile filename, String contents, WurstCompilerJassImpl c = getCompiler(gui); CompilationUnit cu = c.parse(filename.toString(), new StringReader(contents)); cu.getCuInfo().setFile(filename.toString()); + if (isUnderDependenciesFolder(filename)) { + cu.getCuInfo().setLibrary(true); + } updateModel(cu, gui); fileHashcodes.put(filename, newHash); if (reportErrors) { diff --git a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/attributes/AttrExprType.java b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/attributes/AttrExprType.java index 17f7f223a..36d5ba153 100644 --- a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/attributes/AttrExprType.java +++ b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/attributes/AttrExprType.java @@ -289,6 +289,14 @@ public static WurstType calculate(final ExprBinary term) { || AttrFuncDef.implicitToStringForConcatOperand(term, term.getRight()) != null) { return WurstTypeString.instance(); } + String conversionError = AttrFuncDef.implicitToStringErrorForConcatOperand(term, term.getLeft()); + if (conversionError == null) { + conversionError = AttrFuncDef.implicitToStringErrorForConcatOperand(term, term.getRight()); + } + if (conversionError != null) { + term.addError(conversionError); + return WurstTypeUnknown.instance(); + } if (bothTypesRealOrInt(term)) { return caseMathOperation(term); } else { diff --git a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/attributes/AttrFuncDef.java b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/attributes/AttrFuncDef.java index 46e7c3911..2d8f772de 100644 --- a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/attributes/AttrFuncDef.java +++ b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/attributes/AttrFuncDef.java @@ -91,10 +91,27 @@ public static FuncLink calculate(final ExprFuncRef node) { /** Resolves the same zero-argument string conversion that an explicit operand.toString() call would use. */ public static @Nullable FuncLink findToStringConversion(Expr operand) { + return resolveToStringConversion(operand).conversion; + } + + /** Returns why an otherwise applicable implicit conversion cannot be selected. */ + public static @Nullable String implicitToStringErrorForConcatOperand(ExprBinary concat, Expr operand) { + if (concat.getOp() != WurstOperator.PLUS || operand.attrTyp() instanceof WurstTypeString) { + return null; + } + Expr other = concat.getLeft() == operand ? concat.getRight() : concat.getLeft(); + if (!(other.attrTyp() instanceof WurstTypeString)) { + return null; + } + return resolveToStringConversion(operand).error; + } + + private static ToStringConversionResolution resolveToStringConversion(Expr operand) { Collection raw = NameResolution.lookupMemberFuncs( operand, operand.attrTyp(), "toString", false); List methods = new ArrayList<>(); List extensions = new ArrayList<>(); + String inferenceError = null; for (FuncLink candidate : raw) { if (!isVisible(candidate) || (candidate.getDef() instanceof FuncDef && ((FuncDef) candidate.getDef()).attrIsStatic())) { @@ -105,21 +122,52 @@ public static FuncLink calculate(final ExprFuncRef node) { if (matched == null || !matched.getReturnType().isSubtypeOf(WurstTypeString.instance(), operand)) { continue; } + if (matched.getMapping().hasUnboundTypeVars()) { + if (inferenceError == null) { + inferenceError = "Cannot infer type for type parameter " + + matched.getMapping().printUnboundTypeVars(); + } + continue; + } FuncLink adapted = candidate.withTypeArgBinding(operand, matched.getMapping()); if (isExtension(adapted)) { - extensions.add(adapted); + if (!extensions.contains(adapted)) { + extensions.add(adapted); + } } else { - methods.add(adapted); + if (!methods.contains(adapted)) { + methods.add(adapted); + } } } if (!methods.isEmpty()) { - return keepMostSpecificReceivers(methods, FuncLink::getReceiverType, operand).get(0); + return selectUniqueToStringConversion( + keepMostSpecificReceivers(methods, FuncLink::getReceiverType, operand)); } if (!extensions.isEmpty()) { - return keepMostSpecificReceivers(extensions, FuncLink::getReceiverType, operand).get(0); + return selectUniqueToStringConversion( + keepMostSpecificReceivers(extensions, FuncLink::getReceiverType, operand)); + } + return new ToStringConversionResolution(null, inferenceError); + } + + private static ToStringConversionResolution selectUniqueToStringConversion(List candidates) { + if (candidates.size() == 1) { + return new ToStringConversionResolution(candidates.get(0), null); + } + return new ToStringConversionResolution(null, + "Call to function toString is ambiguous. Alternatives are:\n" + Utils.printAlternatives(candidates)); + } + + private static class ToStringConversionResolution { + private final @Nullable FuncLink conversion; + private final @Nullable String error; + + private ToStringConversionResolution(@Nullable FuncLink conversion, @Nullable String error) { + this.conversion = conversion; + this.error = error; } - return null; } public static @Nullable FuncLink calculate(final ExprMemberMethod node) { 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 dcb6527e9..e68bcb8ed 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 @@ -334,6 +334,14 @@ private void collectUsedPackages(Set used, Element root) { if (def != null) { used.add(def.getDef().attrNearestPackage()); } + FuncLink leftConversion = AttrFuncDef.implicitToStringForConcatOperand(binop, binop.getLeft()); + if (leftConversion != null) { + used.add(leftConversion.getDef().attrNearestPackage()); + } + FuncLink rightConversion = AttrFuncDef.implicitToStringForConcatOperand(binop, binop.getRight()); + if (rightConversion != null) { + used.add(rightConversion.getDef().attrNearestPackage()); + } } if (e instanceof Expr) { diff --git a/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/ExpressionTests.java b/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/ExpressionTests.java index c5d0ea936..ded4f087b 100644 --- a/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/ExpressionTests.java +++ b/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/ExpressionTests.java @@ -3,6 +3,8 @@ import de.peeeq.wurstio.UtilsIO; import org.testng.annotations.Test; +import static org.testng.Assert.assertFalse; + public class ExpressionTests extends WurstScriptTest { @Test @@ -49,6 +51,56 @@ public void redundantToStringInStringConcatenation() { ); } + @Test + public void inferredToStringCountsAsImportUsage() { + CompilationResult result = test().setStopOnFirstError(false).compilationUnits( + compilationUnit("Conversions.wurst", + "package Conversions", + "public function int.toString() returns string", + " return \"converted\""), + compilationUnit("Test.wurst", + "package Test", + "import Conversions", + "init", + " let message = \"value: \" + 1") + ); + + assertFalse(result.getGui().getWarningList().stream() + .anyMatch(w -> w.getMessage().contains("The import Conversions is never used"))); + } + + @Test + public void inferredToStringRejectsUninferredTypeParameters() { + testAssertErrorsLines(false, "Cannot infer type for type parameter T", + "package test", + "class C", + " function toString() returns string", + " return \"c\"", + "init", + " let message = \"value: \" + new C" + ); + } + + @Test + public void inferredToStringRejectsAmbiguousExtensions() { + test().expectError("Call to function toString is ambiguous").compilationUnits( + compilationUnit("First.wurst", + "package First", + "public function int.toString() returns string", + " return \"first\""), + compilationUnit("Second.wurst", + "package Second", + "public function int.toString() returns string", + " return \"second\""), + compilationUnit("Test.wurst", + "package Test", + "import First", + "import Second", + "init", + " let message = \"value: \" + 1") + ); + } + @Test public void real1() { assertOk(".3 + .7 == 1."); diff --git a/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/ModelManagerTests.java b/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/ModelManagerTests.java index 671df8987..6e67ab622 100644 --- a/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/ModelManagerTests.java +++ b/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/ModelManagerTests.java @@ -36,6 +36,7 @@ import static org.hamcrest.MatcherAssert.assertThat; import static org.testng.Assert.assertEquals; import static org.testng.Assert.assertNotNull; +import static org.testng.Assert.assertTrue; public class ModelManagerTests { @@ -1110,6 +1111,18 @@ public void dependencySyncHandlesReplaceMoveRenameDelete() throws Exception { manager.buildProject(); assertEquals(errors.get(fileMain), "", "baseline build with depA should be clean"); assertNotNull(manager.getCompilationUnit(depAFile), "depA CU should be loaded"); + assertTrue(manager.getCompilationUnit(depAFile).getCuInfo().isLibrary(), + "dependency CU should initially be marked as a library"); + + writeFile(depAFile, string( + "package DummyDamage", + "public function foo()", + "public function changedDependency()" + )); + ModelManager.Changes changes = manager.syncDependencyCompilationUnits(); + manager.reconcile(changes); + assertTrue(manager.getCompilationUnit(depAFile).getCuInfo().isLibrary(), + "reparsed dependency CU should remain marked as a library"); // replace: depA is removed and depB provides the same package depAFile.getFile().delete(); @@ -1117,7 +1130,7 @@ public void dependencySyncHandlesReplaceMoveRenameDelete() throws Exception { "package DummyDamage", "public function foo()" )); - ModelManager.Changes changes = manager.syncDependencyCompilationUnits(); + changes = manager.syncDependencyCompilationUnits(); manager.reconcile(changes); assertEquals(manager.getCompilationUnit(depAFile), null, "removed dependency CU must be dropped from model"); assertNotNull(manager.getCompilationUnit(depBFile), "replacement dependency CU should be loaded"); From 2ff097a35468abc9a13ff5f5fd30b6d5d1aa316a Mon Sep 17 00:00:00 2001 From: Frotty Date: Mon, 3 Aug 2026 19:47:14 +0200 Subject: [PATCH 04/10] Preserve plus overload precedence --- .../wurstscript/attributes/AttrExprType.java | 3 +++ .../wurstscript/attributes/AttrFuncDef.java | 12 +++++++++- .../imtranslation/ExprTranslation.java | 5 ++-- .../validation/WurstValidator.java | 19 +++++++++------ .../wurstscript/tests/ExpressionTests.java | 18 +++++++++++++++ .../wurstscript/tests/OpOverloading.java | 23 +++++++++++++++++++ 6 files changed, 70 insertions(+), 10 deletions(-) diff --git a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/attributes/AttrExprType.java b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/attributes/AttrExprType.java index 36d5ba153..1a385b49b 100644 --- a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/attributes/AttrExprType.java +++ b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/attributes/AttrExprType.java @@ -285,6 +285,9 @@ public static WurstType calculate(final ExprBinary term) { if (leftType instanceof WurstTypeString && rightType instanceof WurstTypeString) { return WurstTypeString.instance(); } + if (term.attrFuncLink() != null) { + return handleOperatorOverloading(term); + } if (AttrFuncDef.implicitToStringForConcatOperand(term, term.getLeft()) != null || AttrFuncDef.implicitToStringForConcatOperand(term, term.getRight()) != null) { return WurstTypeString.instance(); diff --git a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/attributes/AttrFuncDef.java b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/attributes/AttrFuncDef.java index 2d8f772de..4257a6808 100644 --- a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/attributes/AttrFuncDef.java +++ b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/attributes/AttrFuncDef.java @@ -69,11 +69,16 @@ public static FuncLink calculate(final ExprFuncRef node) { public static @Nullable FuncLink calculate(ExprBinary node) { + FuncLink overloadedOperator = getExtensionFunction(node.getLeft(), node.getRight(), node.getOp()); + if (overloadedOperator != null && matchesArguments(node, overloadedOperator, + Collections.singletonList(node.getRight().attrTyp()))) { + return overloadedOperator; + } if (implicitToStringForConcatOperand(node, node.getLeft()) != null || implicitToStringForConcatOperand(node, node.getRight()) != null) { return null; } - return getExtensionFunction(node.getLeft(), node.getRight(), node.getOp()); + return overloadedOperator; } /** Returns the implicit conversion for a non-string operand next to a string in a + expression. */ @@ -94,6 +99,11 @@ public static FuncLink calculate(final ExprFuncRef node) { return resolveToStringConversion(operand).conversion; } + /** Whether removing an explicit conversion could expose a left-hand plus overload. */ + public static boolean hasPotentialPlusOverload(Expr operand) { + return !operand.lookupMemberFuncs(operand.attrTyp(), overloadingPlus).isEmpty(); + } + /** Returns why an otherwise applicable implicit conversion cannot be selected. */ public static @Nullable String implicitToStringErrorForConcatOperand(ExprBinary concat, Expr operand) { if (concat.getOp() != WurstOperator.PLUS || operand.attrTyp() instanceof WurstTypeString) { diff --git a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/translation/imtranslation/ExprTranslation.java b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/translation/imtranslation/ExprTranslation.java index b84b7ec6e..d9235248c 100644 --- a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/translation/imtranslation/ExprTranslation.java +++ b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/translation/imtranslation/ExprTranslation.java @@ -172,11 +172,12 @@ public static ImExpr translateIntern(ExprBinary e, ImTranslator t, ImFunction f) ImExpr left = e.getLeft().imTranslateExpr(t, f); ImExpr right = e.getRight().imTranslateExpr(t, f); WurstOperator op = e.getOp(); - if (op == WurstOperator.PLUS) { + FuncLink overloadedOperator = e.attrFuncLink(); + if (op == WurstOperator.PLUS && overloadedOperator == null) { left = wrapImplicitToString(e, e.getLeft(), left, t); right = wrapImplicitToString(e, e.getRight(), right, t); } - if (e.attrFuncLink() != null) { + if (overloadedOperator != null) { // overloaded operator ImFunction calledFunc = t.getFuncFor(e.attrFuncDef()); return ImFunctionCall(e, calledFunc, ImTypeArguments(), ImExprs(left, right), false, CallType.NORMAL); 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 e68bcb8ed..f5d7efe15 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 @@ -334,13 +334,15 @@ private void collectUsedPackages(Set used, Element root) { if (def != null) { used.add(def.getDef().attrNearestPackage()); } - FuncLink leftConversion = AttrFuncDef.implicitToStringForConcatOperand(binop, binop.getLeft()); - if (leftConversion != null) { - used.add(leftConversion.getDef().attrNearestPackage()); - } - FuncLink rightConversion = AttrFuncDef.implicitToStringForConcatOperand(binop, binop.getRight()); - if (rightConversion != null) { - used.add(rightConversion.getDef().attrNearestPackage()); + if (def == null) { + FuncLink leftConversion = AttrFuncDef.implicitToStringForConcatOperand(binop, binop.getLeft()); + if (leftConversion != null) { + used.add(leftConversion.getDef().attrNearestPackage()); + } + FuncLink rightConversion = AttrFuncDef.implicitToStringForConcatOperand(binop, binop.getRight()); + if (rightConversion != null) { + used.add(rightConversion.getDef().attrNearestPackage()); + } } } @@ -2112,6 +2114,9 @@ private void visit(ExprMemberMethod stmtCall) { if (!(other.attrTyp() instanceof WurstTypeString)) { return; } + if (concat.getLeft() == stmtCall && AttrFuncDef.hasPotentialPlusOverload(stmtCall.getLeft())) { + return; + } FuncLink explicit = stmtCall.attrFuncLink(); FuncLink inferred = AttrFuncDef.findToStringConversion(stmtCall.getLeft()); if (explicit != null && explicit.equals(inferred)) { diff --git a/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/ExpressionTests.java b/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/ExpressionTests.java index ded4f087b..76aa5f116 100644 --- a/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/ExpressionTests.java +++ b/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/ExpressionTests.java @@ -51,6 +51,24 @@ public void redundantToStringInStringConcatenation() { ); } + @Test + public void explicitToStringIsNotRedundantWhenRemovalExposesPlusOverload() { + CompilationResult result = test().setStopOnFirstError(false).lines( + "package test", + "class A", + " function toString() returns string", + " return \"converted\"", + " function op_plus(string suffix) returns string", + " return \"overloaded\" + suffix", + "init", + " let a = new A", + " let message = a.toString() + \"x\"" + ); + + assertFalse(result.getGui().getWarningList().stream() + .anyMatch(w -> w.getMessage().contains("Explicit .toString() is redundant"))); + } + @Test public void inferredToStringCountsAsImportUsage() { CompilationResult result = test().setStopOnFirstError(false).compilationUnits( diff --git a/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/OpOverloading.java b/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/OpOverloading.java index 0c27bec6b..8b568b3d6 100644 --- a/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/OpOverloading.java +++ b/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/OpOverloading.java @@ -4,6 +4,29 @@ public class OpOverloading extends WurstScriptTest { + @Test + public void plusOverloadTakesPrecedenceOverImplicitToString() { + test().testLua(true).luaOnly(false).executeProg().lines( + "package test", + "native testSuccess()", + "class A", + " function toString() returns string", + " return \"converted\"", + " function op_plus(string suffix) returns string", + " return \"overloaded\" + suffix", + "class B", + " function toString() returns string", + " return \"fallback\"", + " function op_plus(int value) returns string", + " return \"wrong overload\"", + "init", + " let a = new A", + " let b = new B", + " if a + \"x\" == \"overloadedx\" and b + \"x\" == \"fallbackx\"", + " testSuccess()" + ); + } + @Test public void testOverloading1() { From 2327011a87a7d69e6b853664274739f81456ace1 Mon Sep 17 00:00:00 2001 From: Frotty Date: Mon, 3 Aug 2026 20:25:58 +0200 Subject: [PATCH 05/10] Preserve toString member precedence --- .../wurstscript/attributes/AttrFuncDef.java | 55 ++++++++++--------- .../wurstscript/tests/ExpressionTests.java | 14 +++++ 2 files changed, 44 insertions(+), 25 deletions(-) diff --git a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/attributes/AttrFuncDef.java b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/attributes/AttrFuncDef.java index 4257a6808..7cc4a100d 100644 --- a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/attributes/AttrFuncDef.java +++ b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/attributes/AttrFuncDef.java @@ -121,7 +121,6 @@ private static ToStringConversionResolution resolveToStringConversion(Expr opera operand, operand.attrTyp(), "toString", false); List methods = new ArrayList<>(); List extensions = new ArrayList<>(); - String inferenceError = null; for (FuncLink candidate : raw) { if (!isVisible(candidate) || (candidate.getDef() instanceof FuncDef && ((FuncDef) candidate.getDef()).attrIsStatic())) { @@ -129,45 +128,51 @@ private static ToStringConversionResolution resolveToStringConversion(Expr opera } FunctionSignature matched = FunctionSignature.fromNameLink(candidate) .matchAgainstArgs(Collections.emptyList(), operand); - if (matched == null || !matched.getReturnType().isSubtypeOf(WurstTypeString.instance(), operand)) { + if (matched == null) { continue; } - if (matched.getMapping().hasUnboundTypeVars()) { - if (inferenceError == null) { - inferenceError = "Cannot infer type for type parameter " - + matched.getMapping().printUnboundTypeVars(); - } - continue; - } - FuncLink adapted = candidate.withTypeArgBinding(operand, matched.getMapping()); - if (isExtension(adapted)) { - if (!extensions.contains(adapted)) { - extensions.add(adapted); + if (isExtension(candidate)) { + if (!extensions.contains(candidate)) { + extensions.add(candidate); } } else { - if (!methods.contains(adapted)) { - methods.add(adapted); + if (!methods.contains(candidate)) { + methods.add(candidate); } } } if (!methods.isEmpty()) { - return selectUniqueToStringConversion( - keepMostSpecificReceivers(methods, FuncLink::getReceiverType, operand)); + return selectToStringConversion( + keepMostSpecificReceivers(methods, FuncLink::getReceiverType, operand), operand); } if (!extensions.isEmpty()) { - return selectUniqueToStringConversion( - keepMostSpecificReceivers(extensions, FuncLink::getReceiverType, operand)); + return selectToStringConversion( + keepMostSpecificReceivers(extensions, FuncLink::getReceiverType, operand), operand); } - return new ToStringConversionResolution(null, inferenceError); + return new ToStringConversionResolution(null, null); } - private static ToStringConversionResolution selectUniqueToStringConversion(List candidates) { - if (candidates.size() == 1) { - return new ToStringConversionResolution(candidates.get(0), null); + private static ToStringConversionResolution selectToStringConversion(List candidates, Expr operand) { + if (candidates.size() != 1) { + return new ToStringConversionResolution(null, + "Call to function toString is ambiguous. Alternatives are:\n" + Utils.printAlternatives(candidates)); + } + FuncLink candidate = candidates.get(0); + FunctionSignature matched = FunctionSignature.fromNameLink(candidate) + .matchAgainstArgs(Collections.emptyList(), operand); + if (matched == null) { + return new ToStringConversionResolution(null, null); + } + if (matched.getMapping().hasUnboundTypeVars()) { + return new ToStringConversionResolution(null, + "Cannot infer type for type parameter " + matched.getMapping().printUnboundTypeVars()); + } + if (!matched.getReturnType().isSubtypeOf(WurstTypeString.instance(), operand)) { + return new ToStringConversionResolution(null, null); } - return new ToStringConversionResolution(null, - "Call to function toString is ambiguous. Alternatives are:\n" + Utils.printAlternatives(candidates)); + return new ToStringConversionResolution( + candidate.withTypeArgBinding(operand, matched.getMapping()), null); } private static class ToStringConversionResolution { diff --git a/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/ExpressionTests.java b/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/ExpressionTests.java index 76aa5f116..de5c74d19 100644 --- a/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/ExpressionTests.java +++ b/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/ExpressionTests.java @@ -119,6 +119,20 @@ public void inferredToStringRejectsAmbiguousExtensions() { ); } + @Test + public void inferredToStringPreservesMemberPrecedenceOverExtension() { + testAssertErrorsLines(false, "No operator overloading function for operator + was found", + "package test", + "class C", + " function toString() returns int", + " return 1", + "function C.toString() returns string", + " return \"extension\"", + "init", + " let message = \"value: \" + new C" + ); + } + @Test public void real1() { assertOk(".3 + .7 == 1."); From 02dac4db9f8b1e2c036936e4903e9d521e4d0852 Mon Sep 17 00:00:00 2001 From: Frotty Date: Mon, 3 Aug 2026 20:26:21 +0200 Subject: [PATCH 06/10] Pin wc3libs to resolvable full SHA --- de.peeeq.wurstscript/build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/de.peeeq.wurstscript/build.gradle b/de.peeeq.wurstscript/build.gradle index 3f0090988..f4fa668bb 100644 --- a/de.peeeq.wurstscript/build.gradle +++ b/de.peeeq.wurstscript/build.gradle @@ -109,7 +109,7 @@ dependencies { implementation 'com.github.albfernandez:juniversalchardet:2.4.0' implementation 'org.xerial:sqlite-jdbc:3.46.1.3' implementation 'com.github.inwc3:jmpq3:e28f6999c0' - implementation 'com.github.inwc3:wc3libs:ac41f780a5' + implementation 'com.github.inwc3:wc3libs:ac41f780a5e2dfc35310be4ed3267f23ab3fea44' implementation 'com.github.wurstscript:wurst-project-config:348fcd4ef5' implementation 'org.slf4j:slf4j-api:2.0.17' implementation 'ch.qos.logback:logback-classic:1.5.20' From f10a94168e9f2d35933563119ff1bb70253c44c3 Mon Sep 17 00:00:00 2001 From: Frotty Date: Mon, 3 Aug 2026 20:42:53 +0200 Subject: [PATCH 07/10] Avoid unsafe recursive toString quick fix --- .../wurstscript/validation/WurstValidator.java | 7 +++++++ .../tests/wurstscript/tests/ExpressionTests.java | 16 ++++++++++++++++ 2 files changed, 23 insertions(+) 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 f5d7efe15..b965ce4c6 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 @@ -2118,6 +2118,13 @@ private void visit(ExprMemberMethod stmtCall) { return; } FuncLink explicit = stmtCall.attrFuncLink(); + if (explicit != null + && stmtCall.getLeft() instanceof ExprThis + && explicit.getDef() == stmtCall.attrNearestFuncDef()) { + // Explicit recursive calls on this are lowered statically. Removing the call would + // make the implicit conversion dispatch virtually and could select an override. + return; + } FuncLink inferred = AttrFuncDef.findToStringConversion(stmtCall.getLeft()); if (explicit != null && explicit.equals(inferred)) { stmtCall.addWarning(AttrFuncDef.REDUNDANT_TO_STRING_WARNING); diff --git a/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/ExpressionTests.java b/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/ExpressionTests.java index de5c74d19..e36a22d78 100644 --- a/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/ExpressionTests.java +++ b/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/ExpressionTests.java @@ -69,6 +69,22 @@ public void explicitToStringIsNotRedundantWhenRemovalExposesPlusOverload() { .anyMatch(w -> w.getMessage().contains("Explicit .toString() is redundant"))); } + @Test + public void recursiveSelfToStringIsNotReportedAsRedundant() { + CompilationResult result = test().setStopOnFirstError(false).lines( + "package test", + "class Base", + " function toString() returns string", + " return \"base: \" + this.toString()", + "class Child extends Base", + " override function toString() returns string", + " return \"child\"" + ); + + assertFalse(result.getGui().getWarningList().stream() + .anyMatch(w -> w.getMessage().contains("Explicit .toString() is redundant"))); + } + @Test public void inferredToStringCountsAsImportUsage() { CompilationResult result = test().setStopOnFirstError(false).compilationUnits( From 33f65c49569a9dc92238588200669a781e7fb3f1 Mon Sep 17 00:00:00 2001 From: Frotty Date: Mon, 3 Aug 2026 20:53:48 +0200 Subject: [PATCH 08/10] Guard toString quick fix against plus overloads --- .../wurstscript/attributes/AttrFuncDef.java | 12 +++++++++--- .../wurstscript/validation/WurstValidator.java | 8 +++++++- .../wurstscript/tests/ExpressionTests.java | 18 ++++++++++++++++++ 3 files changed, 34 insertions(+), 4 deletions(-) diff --git a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/attributes/AttrFuncDef.java b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/attributes/AttrFuncDef.java index 7cc4a100d..74d8940d3 100644 --- a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/attributes/AttrFuncDef.java +++ b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/attributes/AttrFuncDef.java @@ -99,9 +99,15 @@ public static FuncLink calculate(final ExprFuncRef node) { return resolveToStringConversion(operand).conversion; } - /** Whether removing an explicit conversion could expose a left-hand plus overload. */ - public static boolean hasPotentialPlusOverload(Expr operand) { - return !operand.lookupMemberFuncs(operand.attrTyp(), overloadingPlus).isEmpty(); + /** Whether replacing the right operand with the given type could expose a left-hand plus overload. */ + public static boolean hasApplicablePlusOverload(Expr leftOperand, WurstType rightType) { + List argumentTypes = Collections.singletonList(rightType); + for (FuncLink candidate : leftOperand.lookupMemberFuncs(leftOperand.attrTyp(), overloadingPlus)) { + if (matchesArguments(leftOperand, candidate, argumentTypes)) { + return true; + } + } + return false; } /** Returns why an otherwise applicable implicit conversion cannot be selected. */ 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 b965ce4c6..e86833c3a 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 @@ -2114,7 +2114,13 @@ private void visit(ExprMemberMethod stmtCall) { if (!(other.attrTyp() instanceof WurstTypeString)) { return; } - if (concat.getLeft() == stmtCall && AttrFuncDef.hasPotentialPlusOverload(stmtCall.getLeft())) { + Expr replacement = stmtCall.getLeft(); + if (concat.getLeft() == stmtCall + && AttrFuncDef.hasApplicablePlusOverload(replacement, concat.getRight().attrTyp())) { + return; + } + if (concat.getRight() == stmtCall + && AttrFuncDef.hasApplicablePlusOverload(concat.getLeft(), replacement.attrTyp())) { return; } FuncLink explicit = stmtCall.attrFuncLink(); diff --git a/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/ExpressionTests.java b/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/ExpressionTests.java index e36a22d78..18146f2fe 100644 --- a/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/ExpressionTests.java +++ b/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/ExpressionTests.java @@ -69,6 +69,24 @@ public void explicitToStringIsNotRedundantWhenRemovalExposesPlusOverload() { .anyMatch(w -> w.getMessage().contains("Explicit .toString() is redundant"))); } + @Test + public void rightHandToStringIsNotRedundantWhenRemovalExposesLeftPlusOverload() { + CompilationResult result = test().setStopOnFirstError(false).lines( + "package test", + "class A", + " function toString() returns string", + " return \"converted\"", + "function string.op_plus(A value) returns string", + " return \"overloaded\"", + "init", + " let a = new A", + " let message = \"prefix\" + a.toString()" + ); + + assertFalse(result.getGui().getWarningList().stream() + .anyMatch(w -> w.getMessage().contains("Explicit .toString() is redundant"))); + } + @Test public void recursiveSelfToStringIsNotReportedAsRedundant() { CompilationResult result = test().setStopOnFirstError(false).lines( From 80452f2d08a5d78fcd8f8a8d0ecceab13679fb9e Mon Sep 17 00:00:00 2001 From: Frotty Date: Mon, 3 Aug 2026 21:11:00 +0200 Subject: [PATCH 09/10] Report deprecated inferred toString calls --- .../wurstscript/validation/WurstValidator.java | 3 +++ .../tests/wurstscript/tests/ExpressionTests.java | 14 ++++++++++++++ 2 files changed, 17 insertions(+) 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 e86833c3a..eba2968d4 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 @@ -2091,6 +2091,9 @@ private void visit(ExprBinary expr) { FunctionSignature sig = FunctionSignature.fromNameLink(def); CallSignature callSig = new CallSignature(expr.getLeft(), Collections.singletonList(expr.getRight())); callSig.checkSignatureCompatibility(sig, "" + expr.getOp(), expr); + } else { + checkNameRefDeprecated(expr, AttrFuncDef.implicitToStringForConcatOperand(expr, expr.getLeft())); + checkNameRefDeprecated(expr, AttrFuncDef.implicitToStringForConcatOperand(expr, expr.getRight())); } } diff --git a/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/ExpressionTests.java b/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/ExpressionTests.java index 18146f2fe..536bef397 100644 --- a/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/ExpressionTests.java +++ b/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/ExpressionTests.java @@ -167,6 +167,20 @@ public void inferredToStringPreservesMemberPrecedenceOverExtension() { ); } + @Test + public void inferredToStringReportsDeprecation() { + testAssertWarningsLines(false, " is deprecated. use explicit formatting", + "package test", + "@annotation function annotation()", + "@annotation function deprecated(string _message)", + "class A", + "@deprecated(\"use explicit formatting\") function A.toString() returns string", + " return \"a\"", + "init", + " let _message = \"value: \" + new A" + ); + } + @Test public void real1() { assertOk(".3 + .7 == 1."); From c9673dba95e138675064c7875c3cd0cabdd384ab Mon Sep 17 00:00:00 2001 From: Frotty Date: Tue, 4 Aug 2026 09:26:48 +0200 Subject: [PATCH 10/10] Skip toString quick fix for strings --- .../wurstscript/validation/WurstValidator.java | 5 +++++ .../tests/wurstscript/tests/ExpressionTests.java | 15 +++++++++++++++ 2 files changed, 20 insertions(+) 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 eba2968d4..1b56d1ce5 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 @@ -2117,6 +2117,11 @@ private void visit(ExprMemberMethod stmtCall) { if (!(other.attrTyp() instanceof WurstTypeString)) { return; } + if (stmtCall.getLeft().attrTyp() instanceof WurstTypeString) { + // Removing the explicit call would leave an ordinary string operand, so the + // implicit conversion path would not invoke this potentially non-identity method. + return; + } Expr replacement = stmtCall.getLeft(); if (concat.getLeft() == stmtCall && AttrFuncDef.hasApplicablePlusOverload(replacement, concat.getRight().attrTyp())) { diff --git a/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/ExpressionTests.java b/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/ExpressionTests.java index 536bef397..6780129a7 100644 --- a/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/ExpressionTests.java +++ b/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/ExpressionTests.java @@ -87,6 +87,21 @@ public void rightHandToStringIsNotRedundantWhenRemovalExposesLeftPlusOverload() .anyMatch(w -> w.getMessage().contains("Explicit .toString() is redundant"))); } + @Test + public void stringToStringIsNotReportedAsRedundant() { + CompilationResult result = test().setStopOnFirstError(false).lines( + "package test", + "function string.toString() returns string", + " return this + \"!\"", + "init", + " string value = \"value\"", + " let _message = \"prefix: \" + value.toString()" + ); + + assertFalse(result.getGui().getWarningList().stream() + .anyMatch(w -> w.getMessage().contains("Explicit .toString() is redundant"))); + } + @Test public void recursiveSelfToStringIsNotReportedAsRedundant() { CompilationResult result = test().setStopOnFirstError(false).lines(