From 1c47373a12547c89674210ce796b063c3a490bdb Mon Sep 17 00:00:00 2001 From: Frotty Date: Fri, 14 Aug 2026 22:59:21 +0200 Subject: [PATCH 1/3] Make type substitution carry what is known about a type Substitution was two parallel lists -- a function's or class's type variables next to the arguments given at a use -- paired up positionally at each point of use. Pairing them unwrapped the ImTypeArgument and returned the bare ImType, so everything else the argument carried was dropped. A type class binding lives on the argument, which meant it could not survive being substituted: callers that needed it re-attached it by hand, and the ones with nowhere to re-attach it from silently produced an argument with no instance. Introduce TypeSubst, one primitive for the operation. Substituting into an argument position now yields the argument that was bound, so the instance travels with the type it belongs to. Lookup is by identity, matching what positional lookup already did, since IM nodes do not override equals. TypeRewriter missed one type variable reference: the one held directly by ImTypeVarDispatch rather than as a type. A closure lifting its body into a class of its own therefore left the dispatch naming a variable of the function it had left, with nothing able to bind it. Rewriting it with the rest is what makes a bound usable from inside a closure. Also drops ProgramState.substituteTypeVars, a second implementation of substitution that disagreed with the first about whether bindings survive, and was only ever reached from itself. --- CHANGELOG.md | 12 + .../interpreter/ProgramState.java | 53 ----- .../translation/imtojass/ImAttrType.java | 15 +- .../imtojass/TypeRewriteMatcher.java | 12 +- .../translation/imtojass/TypeRewriter.java | 10 + .../translation/imtojass/TypeSubst.java | 166 +++++++++++++ .../wurstscript/tests/TypeClassTests.java | 54 +++++ .../wurstscript/tests/TypeSubstTests.java | 219 ++++++++++++++++++ 8 files changed, 472 insertions(+), 69 deletions(-) create mode 100644 de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/translation/imtojass/TypeSubst.java create mode 100644 de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/TypeSubstTests.java diff --git a/CHANGELOG.md b/CHANGELOG.md index f6d6b5c13..413b51211 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,18 @@ An instance of `I` for type `X` may only be declared in the package declaring `I` or the one declaring `X`, and only once, so `I` for `X` means the same thing throughout a program regardless of imports. +- A type class bound is now usable from inside a closure, so a bounded generic can hand work to one: + + interface Producer + function produce() returns int + + function indexLater(T x) returns Producer + return () -> T.toIndex(x) + + Substituting a type variable now carries the instance chosen for it along with the type, rather than the + type alone, so lifting a body into a class of its own no longer loses it. Jass only for now: Lua reaches + such a class through its interface and still reports the bound as unresolvable there. + - Added new pseudo-natives for debugging memory leaks: // returns the maximum type id, can be usd to diff --git a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/intermediatelang/interpreter/ProgramState.java b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/intermediatelang/interpreter/ProgramState.java index 357019bdb..d89f0b376 100644 --- a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/intermediatelang/interpreter/ProgramState.java +++ b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/intermediatelang/interpreter/ProgramState.java @@ -461,59 +461,6 @@ public ImType case_ImTupleType(ImTupleType tt) { } - // Helper method to substitute type variables - private ImType substituteTypeVars(ImType type, Map substitutions) { - return type.match(new ImType.Matcher() { - @Override - public ImType case_ImTypeVarRef(ImTypeVarRef typeVarRef) { - ImType concrete = substitutions.get(typeVarRef.getTypeVariable()); - return concrete != null ? concrete : typeVarRef; - } - - @Override - public ImType case_ImClassType(ImClassType classType) { - // Recursively substitute in type arguments - ImTypeArguments newArgs = JassIm.ImTypeArguments(); - for (ImTypeArgument arg : classType.getTypeArguments()) { - ImType substituted = substituteTypeVars(arg.getType(), substitutions); - newArgs.add(JassIm.ImTypeArgument(substituted, typeClassBindingFor(arg))); - } - return JassIm.ImClassType(classType.getClassDef(), newArgs); - } - - // For other types, return as-is - @Override - public ImType case_ImSimpleType(ImSimpleType t) { - return t; - } - - @Override - public ImType case_ImArrayType(ImArrayType t) { - return t; - } - - @Override - public ImType case_ImTupleType(ImTupleType t) { - return t; - } - - @Override - public ImType case_ImVoid(ImVoid t) { - return t; - } - - @Override - public ImType case_ImAnyType(ImAnyType t) { - return t; - } - - @Override - public ImType case_ImArrayTypeMulti(ImArrayTypeMulti t) { - return t; - } - }); - } - public void pushStackframe(ImCompiletimeExpr f, WPos trace) { WLogger.trace(() -> "pushStackframe compiletime expr " + f); stackFrames.push(new ILStackFrame(f, trace)); diff --git a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/translation/imtojass/ImAttrType.java b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/translation/imtojass/ImAttrType.java index 5807e00ed..08a35af3b 100644 --- a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/translation/imtojass/ImAttrType.java +++ b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/translation/imtojass/ImAttrType.java @@ -35,20 +35,7 @@ public static ImType getType(ImFunctionCall e) { } public static ImType substituteType(ImType type, List generics, List typeVars) { - return type.match(new TypeRewriteMatcher() { - - @Override - public ImType case_ImTypeVarRef(ImTypeVarRef t) { - int index = typeVars.indexOf(t.getTypeVariable()); - if (index < 0) { - return t; - } else if (index >= generics.size()) { - throw new RuntimeException("Could not find replacement for " + t + " when replacing " + typeVars + " with " + generics); - } - return generics.get(index).getType(); - } - - }); + return TypeSubst.of(typeVars, generics).apply(type); } diff --git a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/translation/imtojass/TypeRewriteMatcher.java b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/translation/imtojass/TypeRewriteMatcher.java index d182ad6b7..1fabf4b38 100644 --- a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/translation/imtojass/TypeRewriteMatcher.java +++ b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/translation/imtojass/TypeRewriteMatcher.java @@ -57,9 +57,17 @@ public ImType case_ImArrayType(ImArrayType t) { public ImType case_ImClassType(ImClassType t) { ImTypeArguments args = JassIm.ImTypeArguments(); for (ImTypeArgument ta : t.getTypeArguments()) { - ImTypeArgument imTypeArgument = JassIm.ImTypeArgument(ta.getType().match(this), ta.getTypeClassBinding()); - args.add(imTypeArgument); + args.add(rewriteTypeArgument(ta)); } return JassIm.ImClassType(t.getClassDef(), args); } + + /** + * Rewrites one type argument. Only the type is rewritten by default, since a plain rewrite has + * nothing to say about what is known of the argument. Subclasses which do -- substitution, where + * the replacing argument carries its own binding -- override this. + */ + protected ImTypeArgument rewriteTypeArgument(ImTypeArgument ta) { + return JassIm.ImTypeArgument(ta.getType().match(this), ta.getTypeClassBinding()); + } } diff --git a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/translation/imtojass/TypeRewriter.java b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/translation/imtojass/TypeRewriter.java index 08ee051d0..879a87d46 100644 --- a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/translation/imtojass/TypeRewriter.java +++ b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/translation/imtojass/TypeRewriter.java @@ -36,6 +36,16 @@ private static void rewrite(Element e, Function rewriteFunc) { case ImTypeIdOfObj t -> t.setClazz((ImClassType) rewriteFunc.apply(t.getClazz())); case ImTypeIdOfClass t -> t.setClazz((ImClassType) rewriteFunc.apply(t.getClazz())); case ImMethod m -> m.setMethodClass((ImClassType) rewriteFunc.apply(m.getMethodClass())); + case ImTypeVarDispatch d -> { + // The dispatched type variable is a reference to a variable like any other, but it + // is held directly instead of as a type, so a walk over types alone passes it by. + // A closure capturing the type parameter it dispatches on has to rewrite it too, + // otherwise the lifted body still names a variable of the function it left. + ImType rewritten = rewriteFunc.apply(JassIm.ImTypeVarRef(d.getTypeVariable())); + if (rewritten instanceof ImTypeVarRef ref) { + d.setTypeVariable(ref.getTypeVariable()); + } + } case ImClass c -> { List newSuperClasses = new ArrayList<>(); for (ImClassType tt : c.getSuperClasses()) { diff --git a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/translation/imtojass/TypeSubst.java b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/translation/imtojass/TypeSubst.java new file mode 100644 index 000000000..5107c9cd2 --- /dev/null +++ b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/translation/imtojass/TypeSubst.java @@ -0,0 +1,166 @@ +package de.peeeq.wurstscript.translation.imtojass; + +import de.peeeq.wurstscript.jassIm.ImFunction; +import de.peeeq.wurstscript.jassIm.ImMethod; +import de.peeeq.wurstscript.jassIm.ImType; +import de.peeeq.wurstscript.jassIm.ImTypeArgument; +import de.peeeq.wurstscript.jassIm.ImTypeClassFunc; +import de.peeeq.wurstscript.jassIm.ImTypeVar; +import de.peeeq.wurstscript.jassIm.ImTypeVarRef; +import de.peeeq.wurstscript.jassIm.JassIm; +import io.vavr.control.Either; +import org.eclipse.jdt.annotation.Nullable; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * Replaces type variables by the type arguments bound to them. + *

+ * Substitution used to be expressed as two parallel lists -- the type variables of a function or + * class next to the type arguments given at a use of it -- paired up positionally at each point of + * use. That form lost information: a {@link ImTypeArgument} is a type plus what is known + * about it, but pairing the lists unwrapped the argument and returned the bare {@link ImType}, so + * the type class binding never survived. Callers which needed it had to re-attach it by hand, and + * the ones which did not know to do so silently produced an argument with no binding. + *

+ * Keeping the argument whole fixes that: substituting into an argument position yields the argument + * that was bound, so its binding travels with the type it belongs to. + *

+ * Lookup is by identity. IM nodes do not override {@code equals}, and a type variable stands for one + * particular declaration, so two variables which merely share a name are different variables. + */ +public final class TypeSubst { + + private static final TypeSubst EMPTY = + new TypeSubst(Collections.emptyMap(), Collections.emptyList(), Collections.emptyList(), Collections.emptyList()); + + private final Map bindings; + /** Variables this substitution covers but for which no argument was supplied. */ + private final List unbound; + /** Kept so that a missing argument is reported the same way it was before. */ + private final List typeVars; + private final List typeArguments; + + private TypeSubst(Map bindings, List unbound, + List typeVars, List typeArguments) { + this.bindings = bindings; + this.unbound = unbound; + this.typeVars = typeVars; + this.typeArguments = typeArguments; + } + + public static TypeSubst empty() { + return EMPTY; + } + + /** + * Binds {@code typeVars} to {@code typeArguments} by position. + *

+ * Fewer arguments than variables is allowed: the surplus variables stay unbound, and only a type + * which actually mentions one of them fails. Callers rely on that, because a use may legitimately + * leave the arguments off when it does not name the variable. + */ + public static TypeSubst of(List typeVars, List typeArguments) { + if (typeVars.isEmpty()) { + return EMPTY; + } + // LinkedHashMap rather than IdentityHashMap: the keys already compare by identity, and this + // keeps iteration order stable, which matters wherever substitution feeds emitted names. + Map bindings = new LinkedHashMap<>(); + List unbound = new ArrayList<>(); + for (int i = 0; i < typeVars.size(); i++) { + ImTypeVar typeVar = typeVars.get(i); + if (i < typeArguments.size()) { + // A variable repeated in the list keeps its first argument, as positional lookup did. + bindings.putIfAbsent(typeVar, typeArguments.get(i)); + } else if (!bindings.containsKey(typeVar)) { + unbound.add(typeVar); + } + } + return new TypeSubst(bindings, unbound, typeVars, typeArguments); + } + + public boolean isEmpty() { + return bindings.isEmpty() && unbound.isEmpty(); + } + + /** The argument bound to {@code typeVar}, or null when this substitution does not cover it. */ + public @Nullable ImTypeArgument get(ImTypeVar typeVar) { + return bindings.get(typeVar); + } + + /** Applies this substitution to a type. Any binding on the replacing argument is not part of a type. */ + public ImType apply(ImType type) { + if (isEmpty()) { + return type; + } + return type.match(new TypeRewriteMatcher() { + @Override + public ImType case_ImTypeVarRef(ImTypeVarRef t) { + ImTypeArgument replacement = resolve(t); + return replacement == null ? t : replacement.getType(); + } + + @Override + protected ImTypeArgument rewriteTypeArgument(ImTypeArgument argument) { + return TypeSubst.this.apply(argument); + } + }); + } + + /** + * Applies this substitution to a type argument. + *

+ * When the argument is exactly a type variable this substitution binds, the bound argument + * replaces it whole, so the binding recorded at the use site reaches the body being substituted + * into. A binding already present on the argument is more specific and is kept. + */ + public ImTypeArgument apply(ImTypeArgument argument) { + if (isEmpty()) { + return argument; + } + if (argument.getType() instanceof ImTypeVarRef ref) { + ImTypeArgument replacement = resolve(ref); + if (replacement != null) { + return JassIm.ImTypeArgument(replacement.getType(), binding(argument, replacement)); + } + } + return JassIm.ImTypeArgument(apply(argument.getType()), argument.getTypeClassBinding()); + } + + private static Map> binding(ImTypeArgument argument, + ImTypeArgument replacement) { + return argument.getTypeClassBinding().isEmpty() + ? replacement.getTypeClassBinding() + : argument.getTypeClassBinding(); + } + + private @Nullable ImTypeArgument resolve(ImTypeVarRef ref) { + ImTypeVar typeVar = ref.getTypeVariable(); + ImTypeArgument bound = bindings.get(typeVar); + if (bound != null) { + return bound; + } + if (unbound.contains(typeVar)) { + throw new RuntimeException("Could not find replacement for " + ref + + " when replacing " + typeVars + " with " + typeArguments); + } + return null; + } + + @Override + public String toString() { + StringBuilder sb = new StringBuilder("["); + bindings.forEach((typeVar, argument) -> { + if (sb.length() > 1) { + sb.append(", "); + } + sb.append(typeVar.getName()).append(" -> ").append(argument.getType()); + }); + return sb.append("]").toString(); + } +} diff --git a/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/TypeClassTests.java b/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/TypeClassTests.java index c074a6c5d..6e0ba186c 100644 --- a/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/TypeClassTests.java +++ b/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/TypeClassTests.java @@ -67,6 +67,60 @@ public void dispatchRuntime() { ); } + /** + * A closure lifts its body into a class of its own, capturing the enclosing type variables. The + * requirement is dispatched from inside that class, so the binding has to survive being carried + * across into it. + */ + @Test + public void dispatchInsideClosure() { + testAssertOkLines(true, + "package test", + "native testSuccess()", + "interface ToIndex", + " function toIndex(T x) returns int", + "implements ToIndex", + " function toIndex(int x) returns int", + " return x * 2", + "interface Producer", + " function produce() returns int", + "function foo(Q x) returns int", + " Producer p = () -> Q.toIndex(x)", + " return p.produce()", + "init", + " if foo(21) == 42", + " testSuccess()" + ); + } + + /** + * The same closure is still rejected for Lua, and this pins that it is rejected clearly rather + * than mistranslated. Lua keeps generics erased and specialises only what it can reach through a + * concrete type; a closure is reached through its interface, so the specialised class exists but + * nothing calls it. Making that work is a change to Lua's erasure, not to substitution. Should + * it be made, this test fails and becomes the success case above. + */ + @Test + public void dispatchInsideClosureIsRejectedForLua() { + test().testLua(true).executeProg().expectError("could not be resolved for the Lua target").lines( + "package test", + "native testSuccess()", + "interface ToIndex", + " function toIndex(T x) returns int", + "implements ToIndex", + " function toIndex(int x) returns int", + " return x * 2", + "interface Producer", + " function produce() returns int", + "function foo(Q x) returns int", + " Producer p = () -> Q.toIndex(x)", + " return p.produce()", + "init", + " if foo(21) == 42", + " testSuccess()" + ); + } + /** Each type argument picks its own instance, so one generic serves several types. */ @Test public void twoInstancesOfOneClass() { diff --git a/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/TypeSubstTests.java b/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/TypeSubstTests.java new file mode 100644 index 000000000..5e7ec339d --- /dev/null +++ b/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/TypeSubstTests.java @@ -0,0 +1,219 @@ +package tests.wurstscript.tests; + +import de.peeeq.wurstscript.ast.Ast; +import de.peeeq.wurstscript.ast.Element; +import de.peeeq.wurstscript.jassIm.ImClass; +import de.peeeq.wurstscript.jassIm.ImClassType; +import de.peeeq.wurstscript.jassIm.ImFunction; +import de.peeeq.wurstscript.jassIm.ImMethod; +import de.peeeq.wurstscript.jassIm.ImSimpleType; +import de.peeeq.wurstscript.jassIm.ImType; +import de.peeeq.wurstscript.jassIm.ImTypeArgument; +import de.peeeq.wurstscript.jassIm.ImTypeClassFunc; +import de.peeeq.wurstscript.jassIm.ImTypeVar; +import de.peeeq.wurstscript.jassIm.ImTypeVarRef; +import de.peeeq.wurstscript.jassIm.JassIm; +import de.peeeq.wurstscript.translation.imtojass.TypeSubst; +import io.vavr.control.Either; +import org.testng.annotations.Test; + +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import static org.testng.Assert.assertEquals; +import static org.testng.Assert.assertSame; +import static org.testng.Assert.assertThrows; +import static org.testng.Assert.assertTrue; + +/** + * Tests for substitution of type variables. + *

+ * The property that matters beyond replacing a variable by a type is that a type argument is a type + * together with what is known about it. Substituting in an argument position has to carry the whole + * argument across, because the type class binding is what lets a bound resolve to a direct call. + */ +public class TypeSubstTests { + + @Test + public void substitutesTypeVariable() { + ImTypeVar t = JassIm.ImTypeVar("T"); + TypeSubst subst = TypeSubst.of(List.of(t), List.of(argument(integer()))); + + assertEquals(typename(subst.apply(JassIm.ImTypeVarRef(t))), "integer"); + } + + @Test + public void leavesVariablesItDoesNotCoverAlone() { + ImTypeVar covered = JassIm.ImTypeVar("T"); + ImTypeVar other = JassIm.ImTypeVar("U"); + TypeSubst subst = TypeSubst.of(List.of(covered), List.of(argument(integer()))); + + ImTypeVarRef ref = JassIm.ImTypeVarRef(other); + assertSame(subst.apply((ImType) ref), ref); + } + + /** A variable stands for one declaration; sharing a name with another does not make it the same. */ + @Test + public void distinguishesVariablesSharingAName() { + ImTypeVar declared = JassIm.ImTypeVar("T"); + ImTypeVar unrelated = JassIm.ImTypeVar("T"); + TypeSubst subst = TypeSubst.of(List.of(declared), List.of(argument(integer()))); + + ImTypeVarRef ref = JassIm.ImTypeVarRef(unrelated); + assertSame(subst.apply((ImType) ref), ref); + } + + /** + * The regression this exists for: substituting {@code Box} with an argument that carries an + * instance has to yield {@code Box} still carrying it. Returning only the type left the + * argument bare, and the dispatch inside the body then had nothing to resolve against. + */ + @Test + public void bindingSurvivesIntoArgumentPosition() { + ImTypeVar t = JassIm.ImTypeVar("T"); + ImTypeClassFunc requirement = requirement("toIndex"); + ImFunction instance = function("intToIndex"); + TypeSubst subst = TypeSubst.of(List.of(t), + List.of(JassIm.ImTypeArgument(integer(), binding(requirement, instance)))); + + ImClassType boxOfT = classType("Box", argument(JassIm.ImTypeVarRef(t))); + ImTypeArgument substituted = onlyArgument(subst.apply(boxOfT)); + + assertEquals(typename(substituted.getType()), "integer"); + assertEquals(substituted.getTypeClassBinding().size(), 1, "the binding must travel with the type"); + assertSame(substituted.getTypeClassBinding().get(requirement).get(), instance); + } + + /** Applied directly to an argument rather than reached through a class type. */ + @Test + public void bindingSurvivesOnADirectArgument() { + ImTypeVar t = JassIm.ImTypeVar("T"); + ImTypeClassFunc requirement = requirement("toIndex"); + ImFunction instance = function("intToIndex"); + TypeSubst subst = TypeSubst.of(List.of(t), + List.of(JassIm.ImTypeArgument(integer(), binding(requirement, instance)))); + + ImTypeArgument substituted = subst.apply(argument(JassIm.ImTypeVarRef(t))); + + assertEquals(typename(substituted.getType()), "integer"); + assertSame(substituted.getTypeClassBinding().get(requirement).get(), instance); + } + + /** An argument which already names an instance keeps it: it is the more specific of the two. */ + @Test + public void bindingAlreadyPresentIsKept() { + ImTypeVar t = JassIm.ImTypeVar("T"); + ImTypeClassFunc requirement = requirement("toIndex"); + ImFunction fromUse = function("chosenAtUse"); + ImFunction fromSubstitution = function("chosenBySubstitution"); + + TypeSubst subst = TypeSubst.of(List.of(t), + List.of(JassIm.ImTypeArgument(integer(), binding(requirement, fromSubstitution)))); + ImClassType boxOfT = classType("Box", + JassIm.ImTypeArgument(JassIm.ImTypeVarRef(t), binding(requirement, fromUse))); + + ImTypeArgument substituted = onlyArgument(subst.apply(boxOfT)); + assertSame(substituted.getTypeClassBinding().get(requirement).get(), fromUse); + } + + /** Nested arguments are reached too, so a bound on an inner position resolves the same way. */ + @Test + public void bindingSurvivesThroughNesting() { + ImTypeVar t = JassIm.ImTypeVar("T"); + ImTypeClassFunc requirement = requirement("toIndex"); + ImFunction instance = function("intToIndex"); + TypeSubst subst = TypeSubst.of(List.of(t), + List.of(JassIm.ImTypeArgument(integer(), binding(requirement, instance)))); + + ImClassType inner = classType("Box", argument(JassIm.ImTypeVarRef(t))); + ImClassType outer = classType("List", argument(inner)); + + ImTypeArgument substituted = onlyArgument(onlyArgument(subst.apply(outer)).getType()); + assertEquals(typename(substituted.getType()), "integer"); + assertSame(substituted.getTypeClassBinding().get(requirement).get(), instance); + } + + /** + * Fewer arguments than variables is allowed as long as the missing ones are not named, because a + * use may legitimately leave the arguments off. + */ + @Test + public void missingArgumentIsToleratedUntilItIsNamed() { + ImTypeVar t = JassIm.ImTypeVar("T"); + TypeSubst subst = TypeSubst.of(List.of(t), List.of()); + + ImType untouched = integer(); + assertSame(subst.apply(untouched), untouched); + assertThrows(RuntimeException.class, () -> subst.apply(JassIm.ImTypeVarRef(t))); + } + + @Test + public void emptySubstitutionChangesNothing() { + ImTypeVar t = JassIm.ImTypeVar("T"); + ImClassType boxOfT = classType("Box", argument(JassIm.ImTypeVarRef(t))); + + assertSame(TypeSubst.of(List.of(), List.of()).apply((ImType) boxOfT), boxOfT); + assertTrue(TypeSubst.empty().isEmpty()); + } + + /** A variable named twice keeps the first argument, which is what positional lookup did. */ + @Test + public void repeatedVariableKeepsItsFirstArgument() { + ImTypeVar t = JassIm.ImTypeVar("T"); + TypeSubst subst = TypeSubst.of(List.of(t, t), + List.of(argument(integer()), argument(JassIm.ImSimpleType("real")))); + + assertEquals(typename(subst.apply(JassIm.ImTypeVarRef(t))), "integer"); + } + + // --- helpers --------------------------------------------------------------------------------- + + private static ImType integer() { + return JassIm.ImSimpleType("integer"); + } + + private static String typename(ImType type) { + assertTrue(type instanceof ImSimpleType, "expected a simple type but got " + type); + return ((ImSimpleType) type).getTypename(); + } + + private static ImTypeArgument argument(ImType type) { + return JassIm.ImTypeArgument(type, Collections.emptyMap()); + } + + private static ImTypeArgument onlyArgument(ImType type) { + assertTrue(type instanceof ImClassType, "expected a class type but got " + type); + ImClassType classType = (ImClassType) type; + assertEquals(classType.getTypeArguments().size(), 1); + return classType.getTypeArguments().get(0); + } + + private static ImClassType classType(String name, ImTypeArgument argument) { + ImClass classDef = JassIm.ImClass(trace(), name, JassIm.ImTypeVars(JassIm.ImTypeVar(name + "Param")), + JassIm.ImVars(), JassIm.ImMethods(), JassIm.ImFunctions(), List.of()); + return JassIm.ImClassType(classDef, JassIm.ImTypeArguments(argument)); + } + + private static ImTypeClassFunc requirement(String name) { + return JassIm.ImTypeClassFunc(trace(), name, JassIm.ImTypeVars(), JassIm.ImVars(), integer()); + } + + private static ImFunction function(String name) { + return JassIm.ImFunction(trace(), name, JassIm.ImTypeVars(), JassIm.ImVars(), integer(), + JassIm.ImVars(), JassIm.ImStmts(), List.of()); + } + + /** IM nodes keep a trace back to the source they came from; these have none, so stand one in. */ + private static Element trace() { + return Ast.NoExpr(); + } + + private static Map> binding(ImTypeClassFunc requirement, + ImFunction instance) { + Map> binding = new LinkedHashMap<>(); + binding.put(requirement, Either.right(instance)); + return binding; + } +} From fb1fe968ab9be8ef56dea86288d06b12f427225c Mon Sep 17 00:00:00 2001 From: Frotty Date: Sat, 15 Aug 2026 02:35:00 +0200 Subject: [PATCH 2/3] Skip rebuilding a type when there is nothing to substitute --- .../de/peeeq/wurstscript/translation/imtojass/TypeSubst.java | 3 +++ 1 file changed, 3 insertions(+) diff --git a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/translation/imtojass/TypeSubst.java b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/translation/imtojass/TypeSubst.java index 5107c9cd2..30b08a76f 100644 --- a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/translation/imtojass/TypeSubst.java +++ b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/translation/imtojass/TypeSubst.java @@ -96,6 +96,9 @@ public boolean isEmpty() { /** Applies this substitution to a type. Any binding on the replacing argument is not part of a type. */ public ImType apply(ImType type) { if (isEmpty()) { + // Nothing to replace, so hand back the type itself. Worth doing: this runs for the + // return type of every call, and rebuilding a class or tuple type only to get an equal + // one back was pure allocation. return type; } return type.match(new TypeRewriteMatcher() { From 5041d422f2e67fbd0294407f52911acaea07e505 Mon Sep 17 00:00:00 2001 From: Frotty Date: Sat, 15 Aug 2026 02:43:36 +0200 Subject: [PATCH 3/3] Drop an unused accessor and note why the binding map is shared --- .../wurstscript/translation/imtojass/TypeSubst.java | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/translation/imtojass/TypeSubst.java b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/translation/imtojass/TypeSubst.java index 30b08a76f..ad86e4397 100644 --- a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/translation/imtojass/TypeSubst.java +++ b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/translation/imtojass/TypeSubst.java @@ -88,11 +88,6 @@ public boolean isEmpty() { return bindings.isEmpty() && unbound.isEmpty(); } - /** The argument bound to {@code typeVar}, or null when this substitution does not cover it. */ - public @Nullable ImTypeArgument get(ImTypeVar typeVar) { - return bindings.get(typeVar); - } - /** Applies this substitution to a type. Any binding on the replacing argument is not part of a type. */ public ImType apply(ImType type) { if (isEmpty()) { @@ -135,6 +130,11 @@ public ImTypeArgument apply(ImTypeArgument argument) { return JassIm.ImTypeArgument(apply(argument.getType()), argument.getTypeClassBinding()); } + /** + * Shares the map rather than copying it. A binding is only ever replaced wholesale through + * {@code setTypeClassBinding}, never added to in place, so two arguments naming the same + * instances can name the same map. + */ private static Map> binding(ImTypeArgument argument, ImTypeArgument replacement) { return argument.getTypeClassBinding().isEmpty()