Skip to content

Commit 5ccc791

Browse files
authored
Resolve a type parameter's default where the binding is known (#1234)
1 parent 886f556 commit 5ccc791

7 files changed

Lines changed: 205 additions & 50 deletions

File tree

‎BACKLOG.md‎

Lines changed: 66 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -13,35 +13,23 @@ Notes rather than leaving it in a commit message.
1313
Numbering is stable: finished items leave a gap rather than shifting the ones below,
1414
because `LOOP.md` refers to items by number.
1515

16-
16. **A never-written array of a type parameter reads as nothing, silently.** In the interpreter
17-
only — Jass and Lua both give the type argument's default. `DefaultValue.get(ImTypeVarRef)`
18-
returns `ILconstUnsafeDefault`, whose `isEqualTo` matches only another `ILconstUnsafeDefault`,
19-
so a comparison against the real default is quietly false rather than an error. Repro:
20-
21-
class Box<T:>
22-
private static T array none
23-
static function first() returns T
24-
return none[0]
25-
init
26-
if Box<int>.first() == 0
27-
testSuccess()
28-
29-
Passes on every Jass configuration and fails on the pre-transform interpreter run. The plain
30-
`int array` version passes, so this is specific to the type parameter. The interpreter knows
31-
the current type argument (`ProgramState.resolveType`), but `DefaultValue` is a static
32-
attribute with no access to it, and the array's default supplier is bound when the array is
33-
allocated rather than when it is read. Either resolve at read time where the state is in hand,
34-
or make the placeholder throw when used — what it must not do is compare unequal in silence.
35-
Found by `FastHashMapTests`: the tombstone fixture needs a "no value" for `V`.
36-
37-
15. **One junk dispatch slot per specialised class.** Left over from item 3, same heuristic in
38-
the other place it is used. `addDirectAliases` composes `owner.getName() + "_" +
16+
15. **One junk dispatch slot per specialised class.** `addDirectAliases` and
17+
`LuaTranslator.collectDispatchSlotNames` both compose `owner.getName() + "_" +
3918
semanticNameFromMethodName(name)`, and for a specialised method that trailing segment is the
40-
type argument, so every method of `FastHashMap<int, int>` claims the same
41-
`FastHashMap_specialized_integer__integer_integer` slot and the alphabetically first wins.
42-
Nothing calls it, so it is dead weight rather than a wrong result — but it is the same
43-
mistake, and the alias it *should* produce is the class qualified with the declared name.
44-
Fixing it changes emitted slot names, so it wants its own commit and its own suite run.
19+
type argument — so every method of `FastHashMap<int, int>` claims one shared
20+
`FastHashMap_specialized_integer__integer_integer` slot and the alphabetically first wins it.
21+
Nothing calls it, so it is dead weight rather than a wrong result.
22+
23+
Tried using the declared name instead and reverted it: overloads share a declared name, so
24+
`setup(int)` and `setup(string)` collapse into one slot, which is what
25+
`LuaTranslationTests.overloadedMethodsDoNotAliasInLuaDispatchTables` and
26+
`moduleProvidedOverloadedOverrideDoesNotCollapseLuaSlots` exist to prevent. Both sources of a
27+
semantic name are wrong, in opposite directions: the mangled trailing segment collides across
28+
the siblings of one specialisation, the declared name collides across overloads. A fix needs a
29+
name that separates both — the declared name together with the dispatch signature key would,
30+
since that is already what distinguishes overloads elsewhere in the same file. Worth doing only
31+
if this stops being dead weight, because the cost of getting it wrong is a real mis-binding
32+
while the cost of leaving it is one unused table key per specialised class.
4533

4634
6. **Lua dispatch inside the constructor** of a bounded generic class. Works on Jass; there is now
4735
a repro for both targets, `TypeClassTests.dispatchInsideConstructor` and
@@ -55,15 +43,20 @@ because `LOOP.md` refers to items by number.
5543
outermost one a concrete argument. `collectGenericNewUse` requires non-empty type arguments, so
5644
it never starts.
5745

58-
The instantiation is only on the type of what the call is assigned to. Three ways to get at it,
59-
roughly in order of how much they would disturb: attach the class's type arguments to
60-
constructor calls when the intermediate language is built, which is where the frontend still
61-
knows them and would serve both targets uniformly — but it changes the Jass path, which reaches
62-
the same answer another way today, so the emitted `.j` needs checking; read them from the
63-
assignment target on the Lua path, which is a syntactic shape and would miss
64-
`foo(new Box<int>(21))`; or specialise from the `#alloc` inside the constructor, which is the
65-
item 5 mechanism but would have to reach back out to the caller. The first looks right; confirm
66-
it is what the Jass path already relies on before changing it.
46+
What Jass does, from `TypeClassTests_dispatchInsideConstructor_no_opts.jim`: it specialises the
47+
constructor function itself, `b_8 = new_Box⟪integer⟫(21)`. It gets there from *types*, not from
48+
the call — `collectGenericUsages` collects a `GenericVar` for the local declared
49+
`Box<integer{show}>` and a `GenericReturnTypeFunc` for `new_Box`, whose return type is generic.
50+
The Lua collector has neither; it only ever looks at calls. So attaching type arguments to
51+
constructor calls, which an earlier note here proposed, is not what the Jass path relies on and
52+
would be a second mechanism rather than the same one.
53+
54+
The honest next step is to collect from types on the Lua path too, restricted the way item 5's
55+
collection is. That runs straight into the same design question, though: `GenericVar` and
56+
`GenericReturnTypeFunc` specialise the *class*, and item 5 showed that an object coming from a
57+
specialised class while its methods are bound to the erased one breaks everything. Either the
58+
collection has to specialise only the constructor path and leave the object erased, or Lua stops
59+
erasing constructed generic classes — which is a decision about the erasure model, not a patch.
6760

6861
7. **Module bounds.** `module M<T: Show>` is rejected with a clear message today. Needs
6962
receiver rewriting during expansion, or type parameters on `ModuleInstanciation`.
@@ -126,6 +119,24 @@ because `LOOP.md` refers to items by number.
126119

127120
## Blocked on a decision
128121

122+
- **8. Should `div` and `mod` keep returning the left operand's type?** Tried returning
123+
`WurstTypeInt.instance()` to match `caseMathOperation` and reverted it: it is a user-visible
124+
breaking change, and the suite already defines the current behaviour as correct.
125+
126+
The asymmetry is real and reachable. `WurstTypeIntLiteral` is a proper subtype of both int and
127+
real, and `caseMathOperation` collapses two literals to int precisely so `real r = 1 + 1` is an
128+
error. `div`/`mod` return `leftType`, so `real r = 7 div 2` compiles. Changing that made exactly
129+
one test fail — `OptimizerTests.realFormatting_consistent_fromIntOps`, which opens with
130+
`real a = 1 div 2` — and AGENTS.md says the existing suite is the authoritative definition of
131+
behaviour. Real maps will contain the same shape.
132+
133+
So the question is the owner's: is `real r = 7 div 2` meant to compile? If yes, the branch in
134+
`AttrExprType` wants a comment saying so, and this item closes. If no, it is a deliberate
135+
breaking change that needs the changelog, and `realFormatting_consistent_fromIntOps` needs
136+
rewriting to say what it actually tests, which is real formatting rather than that assignment.
137+
`ExpressionTests.integerDivisionOfLiteralsIsStillAssignableToReal` pins the behaviour meanwhile,
138+
so whichever way it goes is deliberate rather than accidental.
139+
129140
- **Eliminating the remaining `castTo int`.** The motivating case is timer data attachment
130141
(`ClosureTimers.wurst`), and the containers behind it: `Table` has 81 casts, `HashList` 13,
131142
`HashSet` 6, `HashMap` 4. None can adopt bounds as things stand, because an instance is
@@ -142,11 +153,18 @@ because `LOOP.md` refers to items by number.
142153

143154
## Done
144155

145-
- 8. `div` and `mod` return int rather than the left operand's type, matching `caseMathOperation`.
146-
Reachable, not harmless: an integer literal is a proper subtype of both int and real, and
147-
addition collapses two of them to int precisely so `real r = 1 + 1` stays an error — returning
148-
`leftType` skipped that, so `real r = 7 div 2` was accepted. Three tests in `ExpressionTests`:
149-
both operators rejected against a real, and both still int.
156+
- 18. Comparing an unresolved type parameter default is now an error rather than a quiet "not
157+
equal". Item 16 closed the path that reached a program, but the stand-in is produced by a static
158+
attribute and could surface anywhere, so the silence was the part worth removing. The whole suite
159+
is green with it throwing, which says nothing reachable produces one any more — and if something
160+
starts to, it says so instead of returning a wrong answer.
161+
- 16. A never-written slot of a `T array` reads as the default of what T stands for. The default
162+
is computed by a static attribute, which cannot see the frames that know the type argument, so
163+
it produced a stand-in that compares equal only to another stand-in — `Box<int>.first() == 0`
164+
was quietly false on the interpreter while both backends had it right. `ProgramState` does know
165+
the substitution, so the stand-in is now resolved where the value is produced, at the array read
166+
and the member read, rather than at the comparison where the symptom shows. Item 18 covers the
167+
paths that could still leak one.
150168
- 17. A failing Lua test says so. `translateAndTestLua` now sets the environment label instead of
151169
reporting under whatever Jass configuration ran last.
152170
- 5 (+ the part of 9 that follows it). A type class bound now dispatches from inside a closure on
@@ -204,6 +222,12 @@ because `LOOP.md` refers to items by number.
204222
- `%` is real modulo in Wurst; `mod` is integer modulo. `int % 8` types as `real`.
205223
- Emitted Lua must be byte-identical for identical input (AGENTS.md §8). It is the only
206224
emitted output that can be diffed across runs — see item 11.
225+
- Two of this run's reverts were the same mistake: a name that looks redundant is usually carrying
226+
a distinction. The mangled method name separates overloads; `leftType` on `div` keeps a literal
227+
assignable to a real. Check what a name distinguishes before replacing it with a tidier one.
228+
- The suite is the specification. Before changing what the type checker accepts, grep the tests for
229+
the shape being rejected — item 8 looked like an oversight until one optimizer test turned out to
230+
depend on it.
207231
- A test that hangs looks exactly like a test that is slow. If the suite stops making progress,
208232
take a thread dump of the forked worker (`jstack <pid>`) before killing it — it names the line.
209233
- Method names are not what the frontend called them. `LuaDispatchPreparation` renames a whole

‎de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/WurstOperator.java‎

Lines changed: 33 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package de.peeeq.wurstscript;
22

33
import de.peeeq.wurstscript.attributes.AttrFuncDef;
4+
import de.peeeq.wurstio.jassinterpreter.InterpreterException;
45
import de.peeeq.wurstscript.intermediatelang.*;
56
import de.peeeq.wurstscript.jassAst.JassAst;
67
import de.peeeq.wurstscript.jassAst.JassOpBinary;
@@ -129,6 +130,28 @@ public LuaOpBinary luaTranslateBinary() {
129130
throw new Error("cannot translate " + this);
130131
}
131132

133+
/**
134+
* Refuses to compare the stand-in for a type parameter's default, whichever side it is on.
135+
* <p>
136+
* The stand-in exists because the default of a value is computed by a static attribute, which
137+
* cannot see what the parameter is bound to. It answers "equal" only for another stand-in, so
138+
* comparing one against a real value is a wrong answer rather than an error. Doing this here
139+
* rather than in the value itself keeps it independent of operand order: only the left operand
140+
* gets asked, so `0 == unresolved` would otherwise go quietly false while `unresolved == 0`
141+
* complained.
142+
*/
143+
private static void rejectUnresolvedDefault(ILconst left, ILconst right) {
144+
ILconstUnsafeDefault unresolved = left instanceof ILconstUnsafeDefault leftDefault ? leftDefault
145+
: right instanceof ILconstUnsafeDefault rightDefault ? rightDefault : null;
146+
if (unresolved == null || (left instanceof ILconstUnsafeDefault && right instanceof ILconstUnsafeDefault)) {
147+
return;
148+
}
149+
throw new InterpreterException("The default value of type parameter "
150+
+ unresolved.getTypeVariable().getName()
151+
+ " is not known here, so it cannot be compared to "
152+
+ (unresolved == left ? right : left).print() + ".");
153+
}
154+
132155
public ILconst evaluateBinaryOperator(ILconst left,
133156
Supplier<ILconst> right) {
134157
switch (this) {
@@ -140,8 +163,11 @@ public ILconst evaluateBinaryOperator(ILconst left,
140163
return new ILconstInt(((ILconstInt) left).getVal() / ((ILconstInt) right.get()).getVal());
141164
case DIV_REAL:
142165
return new ILconstReal(getReal(left) / getReal(right.get()));
143-
case EQ:
144-
return ILconstBool.instance(left.equals(right.get()));
166+
case EQ: {
167+
ILconst rightVal = right.get();
168+
rejectUnresolvedDefault(left, rightVal);
169+
return ILconstBool.instance(left.equals(rightVal));
170+
}
145171
case GREATER:
146172
return ((ILconstNum) left).greater((ILconstNum) right.get());
147173
case GREATER_EQ:
@@ -160,8 +186,11 @@ public ILconst evaluateBinaryOperator(ILconst left,
160186
return new ILconstReal(moduloReal(getReal(left), getReal(right.get())));
161187
case MULT:
162188
return ((ILconstNum) left).mul((ILconstNum) right.get());
163-
case NOTEQ:
164-
return ILconstBool.instance(!left.equals(right.get()));
189+
case NOTEQ: {
190+
ILconst rightVal = right.get();
191+
rejectUnresolvedDefault(left, rightVal);
192+
return ILconstBool.instance(!left.equals(rightVal));
193+
}
165194
case PLUS:
166195
return ((ILconstAddable) left).add((ILconstAddable) right.get());
167196
case NOT:

‎de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/intermediatelang/ILconstUnsafeDefault.java‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,13 +18,18 @@ public String print() {
1818
return "unsafe-default<" + typeVariable.getName() + ">";
1919
}
2020

21+
public ImTypeVar getTypeVariable() {
22+
return typeVariable;
23+
}
24+
2125
public WurstType getType() {
2226
return WurstTypeInfer.instance();
2327
}
2428

2529
@Override
2630
public boolean isEqualTo(ILconst other) {
31+
// Comparing this against a real value is refused by WurstOperator, which can see both
32+
// operands; doing it here would depend on which side the stand-in happened to land on.
2733
return other instanceof ILconstUnsafeDefault;
2834
}
29-
3035
}

‎de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/intermediatelang/interpreter/EvaluateExpr.java‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -223,9 +223,9 @@ public static ILconst eval(ImVarArrayAccess e, ProgramState globalState, LocalSt
223223
}
224224

225225
if (e.getVar().isGlobal()) {
226-
return notNull(globalState.getArrayVal(e.getVar(), indexes), e.getVar().getType(), "Variable " + e.getVar().getName() + " is null.", false);
226+
return globalState.resolveDefault(notNull(globalState.getArrayVal(e.getVar(), indexes), e.getVar().getType(), "Variable " + e.getVar().getName() + " is null.", false));
227227
} else {
228-
return notNull(localState.getArrayVal(e.getVar(), indexes), e.getVar().getType(), "Variable " + e.getVar().getName() + " is null.", false);
228+
return globalState.resolveDefault(notNull(localState.getArrayVal(e.getVar(), indexes), e.getVar().getType(), "Variable " + e.getVar().getName() + " is null.", false));
229229
}
230230
}
231231

@@ -292,7 +292,8 @@ public static ILconst eval(ImMemberAccess ma, ProgramState globalState, LocalSta
292292
Integer val = ((ILconstInt) i.evaluate(globalState, localState)).getVal();
293293
indexes.add(val);
294294
}
295-
return receiver.get(ma.getVar(), indexes).orElseGet(() -> ma.attrTyp().defaultValue());
295+
return globalState.resolveDefault(
296+
receiver.get(ma.getVar(), indexes).orElseGet(() -> ma.attrTyp().defaultValue()));
296297
}
297298

298299
public static ILconst eval(ImAlloc e, ProgramState globalState, LocalState localState) {

‎de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/intermediatelang/interpreter/ProgramState.java‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -395,6 +395,23 @@ public ImType resolveType(ImType t) {
395395
return resolveTypeDeep(t, 32); // small budget to avoid cycles
396396
}
397397

398+
/**
399+
* Replaces the stand-in default of a type parameter with the default of the type bound to it.
400+
* <p>
401+
* The default of a value is computed by a static attribute, which cannot see the frames that
402+
* know what the parameter stands for, so it produces a stand-in. Reading a slot of a
403+
* {@code T array} that was never written is how one reaches a program: the stand-in compares
404+
* equal only to another stand-in, so a comparison against the real default is quietly false.
405+
* The frames are known here, so resolve it where the value is produced.
406+
*/
407+
public ILconst resolveDefault(ILconst value) {
408+
if (!(value instanceof ILconstUnsafeDefault unsafeDefault)) {
409+
return value;
410+
}
411+
ImType resolved = resolveType(JassIm.ImTypeVarRef(unsafeDefault.getTypeVariable()));
412+
return resolved instanceof ImTypeVarRef ? value : resolved.defaultValue();
413+
}
414+
398415
private ImType resolveTypeDeep(ImType t, int budget) {
399416
if (budget <= 0 || t == null) return t;
400417

‎de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/ExpressionTests.java‎

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -486,6 +486,45 @@ private String makeProg(String booleanExpr) {
486486
return prog;
487487
}
488488

489+
/**
490+
* An integer literal is a proper subtype of both int and real. Addition collapses two of them
491+
* to int, so {@code real r = 1} is allowed while {@code real r = 1 + 1} is not; {@code div} and
492+
* {@code mod} return the left operand's type instead, so a literal stays assignable to a real
493+
* through them. This pins the asymmetry rather than endorsing it — see backlog item 8.
494+
*/
495+
@Test
496+
public void integerDivisionOfLiteralsIsStillAssignableToReal() {
497+
testAssertOkLines(false,
498+
"package test",
499+
"init",
500+
" real quotient = 7 div 2",
501+
" real remainder = 7 mod 2"
502+
);
503+
}
504+
505+
@Test
506+
public void additionOfLiteralsIsNotAssignableToReal() {
507+
testAssertErrorsLines(false, "Cannot assign int to real",
508+
"package test",
509+
"init",
510+
" real sum = 7 + 2"
511+
);
512+
}
513+
514+
/** Whatever the declared type, both are integer operations at runtime. */
515+
@Test
516+
public void integerDivisionAndModuloStayInt() {
517+
testAssertOkLines(true,
518+
"package test",
519+
"native testSuccess()",
520+
"init",
521+
" int d = 7 div 2",
522+
" int m = 7 mod 2",
523+
" if d == 3 and m == 1",
524+
" testSuccess()"
525+
);
526+
}
527+
489528
public void assertOk(String booleanExpr) {
490529
String prog = makeProg(booleanExpr);
491530
testAssertOk(UtilsIO.getMethodName(1), true, prog);

0 commit comments

Comments
 (0)