Add type class bounds for T: generics - #1226
Conversation
Introduces a top-level 'instance Iface<Type>' entity which binds a generic interface to one concrete type. The dead 'typeclass' grammar rule is removed, so this adds no net reserved word. - Wurst.g4: instanceDef rule, replaces unreachable typeclassDef - wurstscript.parseq: InstanceDecl node on WEntity, WScope and HasModifier - attribute hooks: NameLinks, TypeNameLinks, ReadVariables, DescriptionHtml, PrettyPrinter, TLDTranslation - extend the exhaustive matchers broken by the new WEntity alternative
TypeClassConstraints reads the interfaces named by a <T: Iface> bound, requiring a single-parameter interface referenced without type arguments. TypeClassInstances resolves a (interface, type) pair to its instance by looking only in the package declaring the interface and the package declaring the type. Restricting instances to those two packages keeps selection independent of the import graph, so a pair always resolves to the same instance program-wide.
A new 'instance' keyword reserved a plausible identifier and broke the standard library, which uses it as a local (TimerUtils.wurst:65). Softening the token would mean threading it through every name=ID site in the grammar, so reuse the existing 'implements' keyword at package level instead: it is unambiguous there, since no other top level entity can start with it, and costs no reserved word.
Makes T.f(x) type check inside a generic constrained by <T: Iface>: - WurstTypeTypeParam gains a static-ref form which exposes the methods required by its bounds, substituting the interface's type parameter by T - FuncLink.withReceiverType re-points a requirement at the type parameter, so the requirement resolves on T rather than on an interface instance - name resolution accepts a bounded type parameter in receiver position, only after ordinary variable lookup fails, so it can never shadow a real variable - such a call takes no implicit 'this': the value is an ordinary argument
Completes the path from T.f(x) to executable code: - call sites of a bounded generic record, per type argument, the instance function implementing each requirement (the typeClassBinding slot that already existed on ImTypeArgument but was never filled) - generic elimination replaces a dispatch with a plain call to that function once the type variable is substituted, so a bounded generic costs no more than a hand written monomorphic call - the interpreter resolves dispatch through the calling frame's type arguments, since compiletime code runs before generic elimination - flattening treats a dispatch like any other call instead of rejecting it Type variables for one source type parameter can be distinct nodes, so the lookup matches on name, as the surrounding pass already does.
Diagnostics: unsatisfied bound at the point the type argument is chosen, plus orphan, duplicate, incomplete and not-required checks on an instance declaration. A bounded generic passing its own parameter to another one has no instance at the inner call site, since the parameter is abstract there. Both the specialiser and the interpreter now take it from the frame which supplied it, so chains of bounded generics still resolve statically. Also fixes a regression where probing for a type parameter receiver swallowed the ambiguity error for an ordinary name: the normal lookup stays the last word, and the probe is gated on receiver position so other lookups keep their original cost.
Lua keeps generics erased and only specializes paths which need the concrete type argument. Type class dispatch is such a path, so it now triggers specialization alongside generic construction. A bounded generic therefore lowers to a direct call on Lua as well, rather than needing a runtime dictionary. Renames the predicate accordingly, since it is no longer only about construction.
An unsatisfied bound was only reported when the return type happened to mention the type parameter, which is rarely the case. Check the call signature instead, since that is where the inferred type arguments and their bounds are both known. Test corpus covers dispatch on classes and primitives, several instances of one interface, multiple bounds, two requirements, transitive bounds, distinct specialisations per type, Lua execution, and the diagnostics for unsatisfied, duplicate, incomplete, orphan and over-specified instances. A codegen assertion pins the performance claim: the emitted Lua contains a direct call and no dispatch or dictionary.
Adds the feature to the shipped language digest and the changelog, including the call form, the orphan and uniqueness rules, and the fact that a bound costs nothing at runtime.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: aa9558bd5f
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| if (provided.getName().equals(requirement.getName())) { | ||
| found++; |
There was a problem hiding this comment.
Validate each instance method against its requirement
Matching only by name accepts an instance method with the wrong parameter count/types or return type. For example, implements ToIndex<int> with function toIndex(string x) passes Wurst validation, is selected by findImplementation, and emits a Jass call that pjass rejects because an integer is passed to a string parameter. Compare the implementation's substituted signature with the interface requirement before counting it as supplied.
AGENTS.md reference: AGENTS.md:L77-L79
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in de966e2. Instance methods are now compared against the requirement with the interface's type parameter replaced by the instance type, covering parameter count, parameter types and return type, and the error reports the expected signature. Tests: instanceMethodWithWrongParameterTypeIsRejected, instanceMethodWithWrongParameterCountIsRejected, instanceMethodWithWrongReturnTypeIsRejected, plus instanceMethodMatchingSubstitutedRequirementIsAccepted to pin that substitution is what is matched.
| if (typ instanceof WurstTypeUnknown || typ instanceof WurstTypeTypeParam | ||
| || typ instanceof WurstTypeBoundTypeParam) { | ||
| // still abstract, or already broken: checked once it is bound to something concrete | ||
| return; |
There was a problem hiding this comment.
Reject abstract arguments that do not carry the required bound
Returning for every abstract type argument also accepts calls where the caller's type parameter does not provide the callee's bound. A focused repro where outer<R:>(R x) calls inner<Q: Show>(x) validates, but outer(42) throws No type class instance bound for show on type argument R in the interpreter even when Show<int> exists, because the outer frame has no binding to inherit. Either require the abstract argument to declare the needed bound here or resolve its concrete instance during propagation.
AGENTS.md reference: AGENTS.md:L84-L85
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in de966e2. An abstract type argument now has to declare the bound itself; if it does not, the call is rejected where the argument is chosen rather than failing later with no instance to dispatch to. Your repro is the test unboundedTypeParameterCannotSatisfyBound, and boundedTypeParameterSatisfiesBound covers the same shape once outer declares <R: Show>.
I took requiring the bound rather than resolving a concrete instance during propagation: with the outer parameter still abstract there is no single instance to resolve to, since the outer generic may later be instantiated at several types.
| InterfaceDef def = resolveBound(e); | ||
| if (def != null) { | ||
| result.add(def); |
There was a problem hiding this comment.
Diagnose invalid type-class bounds
Bounds that resolve to a class, an applied interface, or an interface with the wrong arity are silently omitted here, despite the comment claiming that the validator reports them separately. No such validation exists: for example, function foo<Q: Marker> where Marker is a class compiles and permits foo(1), making the declared constraint a no-op. Report the invalid bound at its source instead of dropping it.
AGENTS.md reference: AGENTS.md:L84-L85
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in de966e2. invalidBoundReason now explains why a bound is unusable and the validator reports it at the bound, so the comment and the behaviour agree. Covered by classAsBoundIsRejected (your repro), appliedInterfaceAsBoundIsRejected and multiParameterInterfaceAsBoundIsRejected.
Checking this also surfaced a related hole you did not hit: an interface that extends another was accepted as a bound, but requirements are collected from the interface's own functions, so inherited ones were silently dropped. Extending interfaces are now rejected at both the bound and the instance rather than half-supported — see extendingInterfaceAsBoundIsRejected.
…d bounds Instance methods were matched by name only, so an implementation with the wrong parameter types, parameter count or return type was accepted and emitted, leaving the error to pjass. Compare against the requirement with the interface's type parameter replaced by the instance type, and report the expected signature. A type argument which is itself a type parameter was accepted unconditionally. That let a generic without the bound pass its parameter to one that needs it: the call type checked but had no instance to dispatch to at runtime. Such an argument now has to declare the bound itself. Bounds which do not name a usable type class were silently dropped, so a bound on a class or an applied interface became a no-op. They are reported at the bound. Interfaces which extend another interface are rejected as bounds, at the bound and at the instance: the requirements of a bound are the interface's own functions, so allowing inheritance would silently omit the inherited ones.
|
@codex review All three findings are addressed in de966e2:
Checking the last one also surfaced a related hole: an interface extending another was accepted as a bound while requirements were collected only from its own functions, silently dropping the inherited ones. Extending interfaces are now rejected at both the bound and the instance rather than half-supported. 10 new tests (29 in |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: de966e2c32
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| List<ImTypeVar> typeVars = f.getTypeVariables(); | ||
| if (typeArgs.isEmpty() || typeVars.isEmpty()) { | ||
| return; |
There was a problem hiding this comment.
Bind owning-class parameters when entering methods
When a bounded type parameter belongs to a generic class rather than the method itself, f.getTypeVariables() does not include that parameter, so this returns without recording the class argument. For example, calling new Box<int>().get(42) where class Box<T: Show> and get evaluates T.show(...) fails in test().executeProg() with No type argument bound for T; the same gap affects compile-time interpreter execution of the documented bounded-class pattern. Include the owning class's type variables and the receiver's arguments when binding a method invocation.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in 5bc6ee6, and this turned out to be four separate causes rather than a missing binding step. Thanks for the repro — it was exactly right, and it exposed that the class case was broken end to end.
- Instances travelled on
ImTypeArgument, an argument position, whilesubstituteTypereturns a plainImType. They therefore could not survive substitution, which is precisely what happens when a generic class type flows through a return type or a receiver. Since selection is static, implementations are now registered per requirement and concrete type and looked up from the type at the dispatch. GenericTypes.equalscompared those bindings whilehashCodehad always hashed types alone. Besides violating the contract, it made one request with instances and another without into different keys, so the class was specialised twice: the object was allocated against one copy and dispatched against the other.- Your point directly: a generic class holds its type variables on the class, not its functions, so a constructor or method is called with arguments it has no variable of its own to bind. The interpreter now binds the owning class's variables too, mirroring how type substitutions were already built.
- Lua erases generics and specialises only what needs a concrete type argument; a bound cannot be expressed by erasure at all, so a program using one now gets the same full elimination as Jass.
Covered by boundOnGenericClass, boundOnGenericClassWithField, boundOnGenericClassTwoTypes and boundOnGenericClassLua.
| for (FuncDef provided : decl.getMethods()) { | ||
| if (provided.getName().equals(requirement.getName())) { | ||
| found++; | ||
| checkInstanceMethodSignature(provided, requirement, binding, iface); |
There was a problem hiding this comment.
Match overloaded requirements by signature
When an ordinary interface overloads a requirement name, every provided overload is counted and checked against every same-named requirement here. A valid instance implementing convert(T) and convert(int, T) is therefore rejected because the two-parameter implementation is compared against the one-parameter requirement (and found > 1 is also reported). Match each requirement to the implementation with its substituted parameter and return signature; the lowering's name-only findImplementation must select the same match.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in de966e2. Each requirement is now paired with the implementation matching its substituted signature, and validation and lowering share one TypeClassInstances.findImplementation, so a rejected instance and a selected implementation cannot disagree — which was the part of your comment I most wanted to close. Covered by overloadedRequirementsAreMatchedBySignature (your convert(T) / convert(int, T) case) and missingOverloadIsRejected.
class HashMap<K: Indexable, V: Indexable> is the shape the feature exists for, but a bound on a class did not work. Four distinct causes, each fixed at its source. An instance travelled on ImTypeArgument, which is an argument position, while substituteType returns a plain ImType. The instances therefore could not survive substitution, which is exactly what happens when a generic class type flows through a return type or a receiver. Since selection is static, implementations are now registered per requirement and concrete type and looked up from the type at the dispatch, with the per-argument binding kept as a fast path. GenericTypes.equals compared those bindings while hashCode had always hashed the types alone. Beyond violating the contract, it made one specialisation request with instances and another without into different keys, producing two specialised classes: the object was allocated against one and dispatched against the other. Identity now rests on the types. A generic class holds its type variables on the class, not on its functions, so a constructor or method is called with arguments it has no variable of its own to bind. The interpreter now binds the owning class's variables too, mirroring how type substitutions were already built. Lua erases generics and specialises only what needs a concrete type argument. A bound cannot be expressed by erasure at all, so a program using one now gets the same full elimination as Jass. That also removes the need to prune leftovers. Lua identifiers: the backend assumed intermediate names were already valid Lua, which held only because it had never seen a specialised generic. It now sanitises names itself rather than expecting earlier passes to know about Lua. The IM printer shows the instances carried by a type argument, and the internal dispatch error names the type variable and enclosing function; both were needed to find the above and are worth keeping.
|
@codex review Both findings addressed; the P1 was the more serious of the two and needed root fixes rather than plumbing. P1, bounds on a generic class (5bc6ee6) — four distinct causes:
Also fixed while chasing it: the Lua backend assumed intermediate names were already valid Lua identifiers, which held only because it had never seen a specialised generic. It sanitises names itself now. P2, overloaded requirements (de966e2) — requirements pair with the implementation matching their substituted signature, and validation and lowering share one selector so they cannot disagree. Tests: 35 in |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 5bc6ee623b
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| if (containsTypeClassDispatch()) { | ||
| // A type class bound is resolved by monomorphisation: the implementation is chosen per | ||
| // concrete type argument. Erasure cannot express that, so a program which uses a bound | ||
| // gets the same full elimination as the Jass target rather than the targeted pass. | ||
| beginPhase(2, "Eliminate generics for type class dispatch"); | ||
| new EliminateGenerics(getImTranslator(), getImProg()).transform(); |
There was a problem hiding this comment.
Keep inherited generic classes valid during Lua elimination
On the Lua target, any type-class dispatch now selects full generic elimination, but that pass leaves dangling inherited method references. A valid repro with class Parent<T: Show> defining render, class Child extends Parent<int>, and new Child().render(1) fails during Lua compilation in LuaTranslator.assertNoDanglingFunctionReferences with dangling reference to removed function 'destroyChild'. Either the elimination pass must update inherited method implementations consistently or Lua must specialize only the paths needed for type-class dispatch; otherwise bounded generic inheritance cannot be compiled to Lua.
AGENTS.md reference: AGENTS.md:L217-L221
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in 0dbce7b. You were right that routing Lua through the full elimination was the wrong call: that pass is designed to be followed by class elimination, which the Lua pipeline never runs, so it left exactly the dangling inherited references you saw. Reverted, and the gap that motivated it is fixed at its source instead.
Of the two options you offered I took the second, specialising only the paths that need it, with two fixes:
- Constructing a class whose members dispatch must itself be specialised. Otherwise the constructor keeps a generic result type and a method call on that result never becomes concrete enough to resolve, which is why nothing was collected before.
- A method called directly on a freshly constructed receiver takes its instantiation from the construction, since the receiver's declared type is still generic at that point. Collection repeats until it settles and is idempotent, so it converges in two passes.
Your repro also exposed a second, unrelated bug on the Jass side: a subclass fixing its parent's parameter left the interpreter with nothing to bind, because the receiver carries no type arguments of its own and the concrete type lives on the supertype chain. Both are covered by boundInheritedFromGenericParent and boundInheritedFromGenericParentLua.
One further consequence worth flagging: the second fix turns out to retire an existing restriction in AGENTS.md section 9, which told callers that a generic-construction method on a freshly constructed receiver was unsupported on Lua and to bind a typed local first. Verified against master — new Loader<State>().load() fails there with a dangling reference to the construction marker and passes here — so the guidance is corrected and the shape is now covered in FieldIterationTests.
Full suite: 1617 tests, 0 failures.
A subclass may fix its parent's bounded parameter, as in class Child extends Parent<int>. The receiver then carries no type arguments of its own, so the interpreter found nothing to bind; it now walks the supertype chain, which is where the concrete type is recorded. The previous commit routed Lua through the full generic elimination when a bound was used. That was wrong: the pass is designed to be followed by class elimination, which the Lua pipeline never runs, so it left inherited method references dangling and the backend's own invariant check rejected them. Lua is back on targeted specialisation, with the gap that motivated the change fixed at its source instead: constructing a class whose members dispatch must itself be specialised, otherwise the constructor keeps a generic result type and a method call on that result never becomes concrete. Collection repeats until it settles, and is idempotent so it settles in two passes. A method called directly on a freshly constructed generic receiver takes its instantiation from the construction, since the receiver's declared type is still generic at that point.
…eiver Taking a member call's instantiation from the construction that produced its receiver also fixes the generic-construction case, which is the same code path. Verified against master: 'new Loader<State>().load()' fails there with a dangling reference to the construction marker, and passes here. AGENTS.md section 9 told callers to work around this by binding the receiver to a typed local first. That guidance is now wrong, so it says the shape is supported and why. Added to FieldIterationTests, the suite that section nominates, so the restriction cannot quietly come back.
|
@codex review Addressed in 0dbce7b and 8c9198d. Your diagnosis was correct on both counts, and it turned up a second bug plus a stale restriction elsewhere in the repo. Lua strategy was wrong. Routing Lua through the full elimination assumed that pass leaves generally valid IM; it does not, because it expects class elimination to follow and the Lua pipeline never runs it. Reverted to targeted specialisation, with the gap fixed at its source: constructing a class whose members dispatch must itself be specialised, or the constructor keeps a generic result type and no method call on that result becomes concrete. Collection repeats until it settles and is idempotent, so it converges in two passes. A second bug your repro exposed, on Jass. A subclass fixing its parent's parameter left the interpreter with nothing to bind: the receiver carries no type arguments of its own, and the concrete type lives on the supertype chain, which is now walked. A stale restriction retired. Taking a member call's instantiation from its construction also fixes the generic-construction case, since it is the same code path. AGENTS.md section 9 told callers that shape was unsupported on Lua and to bind a typed local first. Verified against master: Tests: |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 8c9198d0d8
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| binding.putIfAbsent(classVars.get(i), inheritIfStillAbstract(globalState, classArgs.get(i))); | ||
| } | ||
| for (ImClassType superType : classDef.getSuperClasses()) { | ||
| bindClassTypeArguments(globalState, binding, superType, visited); |
There was a problem hiding this comment.
Substitute child arguments before traversing parent types
When a generic subclass forwards its parameter, such as class Child<U: Show> extends Parent<U>, and Child<int> invokes an inherited bounded method, this recurses into the raw Parent<U> type without applying the already-known U -> int binding. A focused testAssertOkLines(true, ...) repro fails with No type class instance bound for show on type argument U, while the equivalent Lua execution succeeds; specialize each supertype using the current class arguments before recursing.
AGENTS.md reference: AGENTS.md:L217-L221
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in 0db9fa0, and your diagnosis was exactly right: the supertype is written in terms of the subclass's variables, so descending into the raw Parent<U> bound the parent's parameter to the variable U rather than to what Child was instantiated with.
Each supertype's arguments are now resolved against the current class's before descending, as you suggested. I verified in both directions rather than assuming: without the change your repro fails with No type class instance bound for show on type argument U846, and with it it passes.
I also added a two level case, class Bottom<B: Show> extends Middle<B> over class Middle<M: Show> extends Top<M>, because a fix that substituted only once would have passed the single level test by accident. That one fails without the change too, with M86. Both are covered on Jass and on Lua: boundForwardedByGenericSubclass, boundForwardedByGenericSubclassLua, boundForwardedThroughTwoLevels.
Full suite: 1620 tests, 0 failures.
A generic subclass may forward its own parameter, as in class Child<U: Show> extends Parent<U>. The supertype is written in terms of the subclass's variables, so descending into the raw Parent<U> bound the parent's parameter to the variable U rather than to what Child was instantiated with, and the dispatch had no instance. Each supertype's arguments are now resolved against the current class's before descending, so the substitution composes through any depth of forwarding rather than applying once. Covered on both targets, with a two level case so that composing is tested and not just a single substitution.
|
@codex review Addressed in 0db9fa0. A generic subclass forwarding its own parameter ( Verified both ways: your repro fails without the change (
|
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 0db9fa06f9
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| TypeDef typeDef = node.lookupType(varName, false); | ||
| if (!(typeDef instanceof TypeParamDef tp) || !TypeClassConstraints.hasBounds(tp)) { | ||
| return null; |
There was a problem hiding this comment.
Preserve bounded module parameters through expansion
When a generic module declares a bound such as module M<T: Show> and a class uses M<int>, any T.show(x) inside the module method is re-resolved in the copied module-instantiation scope; node.lookupType("T") here returns null, so the compiler emits Could not find variable T before either backend. This makes type-class bounds unusable with existing generic modules; preserve the original bound/type-parameter link or rewrite the type receiver during module expansion.
AGENTS.md reference: AGENTS.md:L217-L221
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Confirmed and addressed in 50b8413, though not by supporting it. The cause is as you say: using a module copies its body into the class and substitutes the module's type parameters in type positions, but a requirement is called on the parameter as an expression, so it survives the copy and no longer resolves.
Supporting it needs either the copy to rewrite those receivers, or type parameters on ModuleInstanciation, which has none — a parseq change with the matcher fallout that implies. That is more than this change should carry, so bounds on a module type parameter are now rejected with a message that says what is wrong and what to do instead, rather than failing later as an unknown name. Covered by boundOnGenericModule.
Worth doing properly as a follow-up: modules are common enough in container code that the combination will come up.
| ImTypeVars classVars = owner.getTypeVariables(); | ||
| for (int i = 0; i < Math.min(classVars.size(), typeArgs.size()); i++) { | ||
| binding.put(classVars.get(i), inheritIfStillAbstract(globalState, typeArgs.get(i))); |
There was a problem hiding this comment.
Do not overwrite receiver type arguments with method arguments
When class Box<T: Show> has an independently generic method first<U:>(...), calling it on Box<int> with U = string first records T = int from the receiver, but this loop then binds the owning class variables from the method-call arguments and overwrites T with string. Consequently T.show(...) dispatches through Show<string>—silently producing the wrong result if that instance exists, or throwing otherwise—during interpreter and compile-time execution; ordinary methods must retain the receiver's class mapping and bind only their own method parameters.
AGENTS.md reference: AGENTS.md:L106-L110
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in 50b8413, and this was the serious one of the three: not just an error but a silently wrong result wherever the bogus instance exists. Reproduced exactly as you described — describe<string> on a Box<int> bound the class's T to string.
The cause was mine, from the class-bounds work: the owning class's variables were bound positionally from the method call's type arguments. That is only correct when the arguments really are the class's, which is the constructor case that motivated it. It is now gated on the function having no type parameters of its own, and the receiver's mapping is never overwritten. Covered by classBoundWithIndependentMethodTypeParam.
| for (int i = 0; i < expectedParams.size(); i++) { | ||
| WParameter actual = provided.getParameters().get(i); | ||
| WurstType expected = expectedParams.get(i); | ||
| if (!actual.attrTyp().equalsType(expected, actual)) { |
There was a problem hiding this comment.
Alpha-rename generic requirement parameters before matching
When a requirement itself is generic, for example function pair<U:>(T x, U y) returns U, a structurally identical instance implementation is rejected with the contradictory diagnostic that U was expected but U was provided. The comparison uses the distinct TypeParamDef identities from the interface method and implementation without first pairing their method type parameters, so no instance can satisfy such a requirement; map equal-position method parameters—including arity and constraints—before comparing signatures, and carry that mapping through the shared implementation matcher.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Addressed in 50b8413. The diagnostic was indeed self-contradictory: the comparison used the interface's and the implementation's distinct TypeParamDef definitions, so an identical signature was rejected with a mismatch between U and itself.
Rather than add the pairing, this version rejects a requirement that carries its own type parameters, with a reason that is actually true. Alpha-renaming plus arity and constraint matching, threaded through the shared implementation matcher as you describe, is the right fix if the shape is ever needed; nothing requires it yet, and a wrong-but-confident diagnostic seemed worse than an honest restriction. Covered by genericRequirement.
…clearly A method with its own type parameters was binding the owning class's variables positionally from the method call's type arguments, overwriting the mapping taken from the receiver. Calling describe<string> on a Box<int> therefore dispatched the class's bound through string, which is wrong wherever that instance exists and silently so. The owning class is now bound only when the function has no type parameters of its own, which is the constructor case that needed it, and the receiver's mapping is never overwritten. Two shapes that cannot work yet reported misleading errors rather than saying so: A bound on a module type parameter failed with an unknown name. Using a module copies its body into the class and substitutes type parameters in type positions, but a requirement is called on the parameter as an expression, so it survives the copy and no longer resolves. Supporting it needs the copy to rewrite those receivers, or type parameters on the instantiation, which it has none of. A requirement carrying its own type parameters was compared using the interface's and the implementation's distinct definitions, so an identical signature was rejected with a mismatch between a name and itself. Matching those would mean pairing them first, which this version does not do.
|
@codex review All three addressed in 50b8413. One was a genuine defect, two were misleading diagnostics over real limits. Method type arguments overwriting the receiver's class binding was the serious one, and it was mine: the owning class's variables were bound positionally from the method call's type arguments, so Bound on a module type parameter is rejected with an accurate message instead of an unknown name. Your diagnosis is right: the copy substitutes type parameters in type positions, while a requirement is called on the parameter as an expression, so it survives into a scope that no longer declares it. Supporting it needs receiver rewriting during expansion or type parameters on Generic requirement likewise rejects with a true reason rather than reporting a mismatch between a name and itself. Pairing the method type parameters is the right fix if the shape is ever needed.
|
|
Full suite on 50b8413: 1623 tests, 0 failures. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 50b8413984
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| if (typeArgumentsContainTypeVariable(call.getTypeArguments())) { | ||
| // The receiver's declared type is still generic, which happens when the method is | ||
| // called straight on a freshly constructed value. The construction states the | ||
| // instantiation, so take the arguments from it. | ||
| useConstructionTypeArguments(call); |
There was a problem hiding this comment.
Reject mixed class/method generics before specializing Lua
On the Lua backend, a bounded generic class method that declares an independent type parameter—such as Box<T: Show>.describe<U:>(...)—reaches this branch with only the explicit U argument. Because that argument is already concrete, receiver recovery is skipped and the specializer receives one argument while expecting both T and U, aborting with the internal diagnostic Generics should match class method type variables. Since this shape is intentionally unsupported, reject it with a clear target-specific diagnostic before lowering rather than failing inside generic specialization.
AGENTS.md reference: AGENTS.md:L330-L332
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Reproduced, and I reached a slightly different conclusion, so the reasoning is worth stating.
The shape does fail on Lua as you describe. What it does not do is produce a wrong result: I checked that specifically by adding an implements Show<string> so a mis-dispatch could resolve to a visible wrong answer, and it still errored rather than returning it. Combined with the shape being unsupported on Lua by design, and the diagnostic having to be target-aware since it compiles fine on Jass, a dedicated pre-lowering rejection looked like more machinery than the message is worth. Left as is.
Reproducing it did expose a real defect in the specializer, fixed in 2ec9571. Resolving a dispatch looked up the type variable's position and then indexed the type arguments without checking that the two lists correspond. With a method declaring its own type parameters alongside its class's there are two variables and one argument, so index 0 read the method's argument for the class's variable, and index 1 would have run off the end. That is the same positional confusion you found in the interpreter last round, still live here. Both lookups now bail when the arities disagree.
So the symptom stays an honest error on an unsupported shape, and the latent mis-index behind it is gone.
Resolving a type class dispatch looked up the type variable's position and then indexed the type arguments without checking the two lists correspond. A method which declares its own type parameters alongside its class's is called with only its own, so position no longer means anything: the class's variable read the method's argument, and a later variable would have run off the end. That is the same positional confusion already fixed for the interpreter, still present in the specializer. Both lookups now bail when the arities disagree, so a dispatch is left for a pass that has matching arguments rather than resolved against whichever type happened to sit at that index. The shape which exposed this, a bounded generic class method with an independent type parameter, is unsupported on Lua by design and still reports an error there; it never produced a wrong result, which was verified by making a wrong dispatch resolvable and confirming it still failed.
|
@codex review Addressed in 2ec9571, though not as reported — the reasoning differs, so it is worth setting out. The mixed class/method generics shape does fail on Lua as described. It does not produce a wrong result: I verified that specifically by adding an Reproducing it did surface a real defect. Resolving a dispatch looked up a type variable's position and indexed the type arguments without checking the lists correspond. With a method declaring its own type parameters alongside its class's there are two variables and one argument, so the class's variable read the method's argument and a later one would have run off the end — the same positional confusion found in the interpreter last round, still live in the specializer. Both lookups now bail when the arities disagree. Net: the reported symptom is unchanged and honest, the latent mis-index behind it is fixed. |
|
Full suite on 2ec9571: 1623 tests, 0 failures. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 2ec9571447
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| private static String typeClassKey(ImType type) { | ||
| return type.toString(); |
There was a problem hiding this comment.
Key type-class implementations by structural type identity
When fallback lookup is needed after a type-class binding is lost during generic substitution, two distinct classes with the same simple name in different packages both stringify as that name because ImClassType printing omits the class identity/package. Their registrations therefore collide in putIfAbsent, and the later type can silently dispatch through the first type's implementation. Use structural ImType equality or an identity-preserving key rather than the printed representation.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Confirmed and fixed in 47c3049. This one was worth the check: it is a silent wrong dispatch, not an error.
The mechanism is exactly as you describe — ImClass is created with the simple name (JassIm.ImClass(s1, s1.getName(), ...)), so two classes of the same name in different packages produce the same printed key and the second registration is dropped by putIfAbsent.
My first attempt to reproduce it passed, because the per-argument binding survived and the registry was never consulted; the registry is only the fallback for when that binding is lost. Routing the same program through a bounded generic class, which is the case the registry exists for, reproduced it: the second package's value came back with the first package's implementation, no error raised.
Implementations are now held per requirement and matched by structural type equality rather than by printed text. Covered by sameSimpleNameThroughRegistryFallback.
| if (!ta.getTypeClassBinding().isEmpty()) { | ||
| append(sb, "{"); | ||
| boolean firstBinding = true; | ||
| for (ImTypeClassFunc requirement : ta.getTypeClassBinding().keySet()) { |
There was a problem hiding this comment.
Sort type-class bindings before printing them
When a nested generic type argument carries multiple requirements, its binding is a HashMap, so iterating keySet() gives no stable order. This printed representation feeds both typeClassKey and generic specialization names, allowing identical input to produce different Lua symbols across compiler runs or independently constructed registration/lookup keys to disagree. Emit bindings in a deterministic order.
AGENTS.md reference: AGENTS.md:L277-L280
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in 47c3049, and this was my regression: the binding printing was added for debuggability and leaked into keys and specialization names, where a hash map's iteration order has no business being. AGENTS.md section 8 requires identical input to produce byte-identical Lua, so this was a genuine break of an existing guarantee rather than a cosmetic one.
Two changes: the requirement names are sorted before printing, and the instance registry no longer builds keys from the printed form at all, matching on structural type equality instead. So the printing is back to being only for reading, which is what it was meant to be.
The fallback registry keyed implementations on the printed type. A class prints as its simple name, so two classes of the same name in different packages collided: the second registration was dropped and its values silently dispatched through the first's implementation, with no error. Implementations are now held per requirement and matched by structural type equality. The printed form also fed those keys and reaches specialization names, while the binding it prints is a hash map, so identical input could produce different symbols between runs. The names are sorted, and with the keys no longer built from text the printing is once again only for reading.
|
@codex review Both needed and both fixed in 47c3049. Instance selection by printed name was a silent wrong dispatch, not an error, so it was worth confirming rather than reasoning about. Non-deterministic binding printing was my regression: added for debuggability, it leaked into keys and specialization names, which AGENTS.md section 8 requires to be stable for identical input. Names are sorted, and the registry no longer derives keys from printed text at all, so the printing is once again only for reading.
|
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 47c304932c
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| if (index < 0) { | ||
| // dispatching on a variable of some enclosing generic; it is resolved when that one is | ||
| // specialised. | ||
| return; |
There was a problem hiding this comment.
Resolve dispatches on class-owned or captured type variables
When a bound dispatch occurs in a generic-class constructor or inside a closure nested in a bounded generic, its type variable is not in the specialized function's typeVars, so this branch returns on the assumption that an enclosing specialization will resolve it. No later pass revisits that node: a valid constructor such as Box<T: Show>.construct(T x) calling T.show(x) reaches the Lua translator as ImTypeVarDispatch and throws Error: not implemented; the closure form also fails in the interpreter with No type argument bound for Q. Carry the owning or captured type argument into the specialized function instead of leaving the dispatch unresolved.
AGENTS.md reference: AGENTS.md:L217-L221
Useful? React with 👍 / 👎.
| for (InterfaceDef bound : TypeClassConstraints.boundInterfaces(def)) { | ||
| for (FuncDef method : bound.getMethods()) { | ||
| if (method.getName().equals(name)) { | ||
| result.add(requirementLink(node, bound, method)); |
There was a problem hiding this comment.
Honor bound order for duplicate requirement signatures
When two bounds declare the same requirement signature, such as <Q: First and Second> where both interfaces define show(Q), this loop exposes both links and every Q.show(x) call is rejected as ambiguous. TypeClassConstraints.findRequiredMethod explicitly defines earlier bounds as winning, but this member lookup never applies that rule, so an otherwise satisfiable combination of bounds cannot use the shared operation; stop after the first applicable bound or otherwise deduplicate according to source order.
Useful? React with 👍 / 👎.
Adds type class bounds to
T:generics, so a generic can require operations of the type it is bound to instead of only storing and returning values. This is whatFastHashMap<K:, V:>needs: a way to say "K can be turned into an integer" without forcing K to be a class.Design
A bound is satisfied by an instance, not by subtyping. An upper bound in the Java sense would have been useless here: the types that matter most (
int,real,string, tuples, handle types) can never implement an interface, and boxing them would reintroduce exactly the indirectionT:generics exist to remove.HashMap<int, V>is the central case, and it works.Requirements are called on the type parameter,
K.toIndex(key), not on the value. Because there is no receiver, a requirement which produces a value of the type —fromIndex— needs no special form; it is just another function. This is also why noSelftype is required.Bounds reuse the syntax that was already in the grammar.
typeParamConstraintshas parsed<T: A and B>since new generics landed; the constraint list was simply ignored. A bound names the interface unapplied, so<K: Indexable>reads as "there is an instance ofIndexable<K>".Instances are coherent by construction. An instance of
IforXmay only be declared in the package declaringIor the package declaringX, and only once. Resolution is therefore a direct lookup in two known packages: no search over the import graph, no divergence detection, and no way forIforXto mean different things in different files. This is a deliberate departure from the implicit-search design in #931, which allowed conflicting instances to coexist and made aTreeSet<string>from one package incompatible with another's.No new reserved word. An
instancekeyword was the obvious spelling and it broke the standard library, which usesinstanceas a local inTimerUtils.wurst. Softening the token would mean threading it through everyname=IDsite in the grammar, so instances are declared with the existingimplementskeyword, which is unambiguous at package level. The unreachabletypeclassrule is removed at the same time, which frees a word that was reserved for nothing.Lowering
Resolution is entirely static; nothing is searched at runtime.
ImTypeArgument.typeClassBinding, so filling that binding in is enough. Each dispatch becomes a plain call to the instance function.A bounded generic passing its own parameter to another one has no instance at the inner call site, since the parameter is abstract there. Both the specialiser and the interpreter take it from the frame that supplied it, so chains of bounded generics still resolve statically.
This adds no node to any
.parseqsum type in the IM:ImTypeVarDispatch,ImTypeClassFuncandImTypeArgument.typeClassBindingwere already present and unused. The only new AST node is theInstanceDeclentity.Diagnostics
Unsatisfied bound (reported where the type argument is chosen, naming both the type and the bound), orphan instance, duplicate instance, incomplete instance, a method not required by the interface, and an interface with the wrong number of type parameters used as a bound.
Scope
Single-parameter interfaces only. Multi-parameter type classes and dependent bounds are deliberately excluded: they need functional dependencies or associated types to infer, which is why the equivalent example in #931 only worked with all type arguments spelled out explicitly. Instances have no type parameters of their own, so "every
List<T>isIndexablewhenTis" cannot yet be written.No compiler-derived instances. #931 hardcoded a list of blessed interface names and auto-derived instances for class types; that runs against the principle set out in AGENTS.md §9 for the serialization surface, where the compiler holds no knowledge of specific library names. Retiring the type-parameter
castTo intsites inHashMap,HashList,HashSetandTableneeds one explicit instance declaration per type instead.Testing
19 new tests in
TypeClassTests: dispatch on classes and primitives, several instances of one interface, multiple bounds, an interface with two requirements, transitive bounds, distinct specialisations per type, Lua execution, and each diagnostic above.dispatchLowersToDirectCallLuareads the emitted Lua and asserts the instance function is called directly, with no dispatch node and no dictionary, so the performance claim is pinned rather than asserted.Full suite: 1598 tests, 0 failures.