Wado

WEP: Overload Resolution

Context

A method call recv.m(args) resolves by receiver type and method name. Lookup is sequential and first-hit-wins — concrete ref impls, inherent impls, trait impls on the base type, type-parameter bounds, associated-type projection bounds (resolve_method_call_with, wado-compiler/src/elaborator/method_call.rs). Arguments are elaborated after the winner is known, against its signature, so that parameter types drive literal coercion and default insertion. That ordering is the chicken-and-egg noted in Struct and Trait System: selecting by argument type requires typing the arguments first, independently of any candidate.

Name alone leaves three collision shapes, and they need one answer, not three:

Two resolution paths already select an implementation from types at the call site:

Both bake the winning trait's spelling into the mangled method name (Type^Trait::method), which is also how monomorphization discriminates instances (InstantiationKey keys on the mangled name plus type-argument vectors). The backend is already argument-sensitive; named method calls are what lacked the mechanism.

The static path was not merely name-only, it was circular. Type::m(args) elaborated its arguments against parameter types found by (receiver, method) name — the impl-method index is searched with find, so with two conversion impls it returns whichever comes first — and then selected the impl from the resulting argument type. Selection shaped the argument and the argument drove selection; where the two disagreed no impl matched (Wrapper::from(42) against From<String> beside From<i64> typed the literal i32 and matched neither, an ICE at WIR build). Argument-directed selection is what removes the circularity, not a feature layered on it.

Selection therefore needs a type for each argument before any signature exists, and how good that answer is decides whether the feature is usable at all. A classifier that types only literals and locals leaves the most common argument forms — a call, a method call, a field read, an operator, a range — admitting every candidate, which makes the one overload set every program touches unusable:

fn pick() -> i32 { return 1 }

export fn run() {
    let l = [1, 2, 3] as List<i32>;
    let v = l.index_value(pick());   // must resolve to IndexValue<i32>
}

Arithmetic operators start out unable to exhibit a collision at all: the prelude's operator traits take no type parameters (trait Add { type Output; fn add(&self, rhs: &Self) … }), so impl Add<X> is not writable and a receiver has exactly one Add impl — which is what makes find_arithmetic_trait_impl's first-impl scan safe. Bringing + under the same rule as every other call therefore starts by parameterizing them: trait Add<Rhs = Self>, the shape Rust's std::ops uses.

The design constraint is the design philosophy: no function overloading. This WEP does not add it. What it defines is which candidate sets are legal, how a unique candidate is chosen from types available at the call site, and the explicit syntax for naming one when the compiler cannot.

Decision

What can and cannot overload

Never overloadable — declaring a second one with the same name is an error:

The only overload set Wado has: one trait implemented for one receiver at several argument lists (impl Take<A> for bool beside impl Take<B> for bool). Coherence already accepts these — they are different traits instantiation-wise, but they share one declaration, so every member has one contract and one signature shape, parameterized by the trait's type arguments. Selecting among them by argument type is the same operation indexing performs, now available to every generic trait.

Distinct traits never form an overload set. A method name reachable through two different trait declarations is an ambiguity error at the call site, in every shape, including the concrete-receiver case. The rationale is twofold: impls of different traits share no contract, so a type-directed pick is a semantic guess; and allowing it would reintroduce ad-hoc overloading through one-method traits, which the design philosophy rules out. The escape is the qualified call syntax below, not renaming.

Which declarations can collide at all is settled before this rule: a trait's methods are candidates at a call only where the calling module has its declaration in scope (Trait Resolution). Where the receiver or the impl was written does not matter. Two libraries' private trait Loud never collide through a shared receiver type, because a module importing neither sees neither. A blanket is scoped the same way and counts toward the collision like any other candidate (both are known gaps there).

Resolution algorithm

For the trait-impl step of method lookup (inherent methods still shadow trait methods; the ref-impl priority and the earlier steps are unchanged):

  1. Collect the candidate impls providing m for the receiver (impl-index buckets along the newtype chain, plus satisfied blankets; for a generic receiver, the transitive closure of its bounds).
  2. Group candidates by trait identity. Identity is structural, never a spelling: the trait is its declaration key (declaring module + declared name), so two modules' same-named traits stay distinct and an alias compares equal to the original; an argument list is its resolved types, so Take<Alias> and Take<A> name one list. Each candidate's identity is resolved in its impl's own frame — the impl module's imports, with the impl's bound type parameters substituted.
  3. More than one group → ambiguity error naming every group's method as Type^Trait::m and suggesting a qualified call.
  4. One group with one argument list → resolved.
  5. One group with several argument lists → argument-directed selection:
    1. Classify each argument (next section).
    2. Keep each candidate whose substituted parameter list matches: at every position the parameter type must lie in the argument's denoted set. A parameter still containing an unsubstituted method-level type parameter accepts anything.
    3. All survivors naming the same trait instantiation → the existing specific-impls-win rule picks the impl. Survivors naming distinct instantiations → ambiguity error. No other ranking exists — there is no best match, only a unique match.
    4. A unique survivor is selected: its full trait spelling is recorded in the dispatch fact and mangled name, and the arguments are then elaborated against its signature exactly as an unambiguous call is (literal coercion, range checks, defaults, effect checking).

Static calls Type::m(args) follow the same grouping and selection over associated functions. from / try_from add a preselect step on top of it (see Conversions).

Because a trait declaration fixes its methods' arity and owns their default arguments, every candidate in an overload set has the same argument count — arity never discriminates, and no arity filter exists.

Argument synthesis

Selection needs argument types before any signature is chosen, so each argument is classified by a judgement over its expression — argument synthesis. It is total (every ast::Expr variant has a rule) and side-effect-free (it never runs resolve_expr; see Side-effect discipline).

A class denotes the set of types the argument could elaborate to at this call site, under any candidate's parameter type:

Class Denotes
Exact(t) {t} — plus a &mut t argument answering a &t parameter
Head(h) every type whose head declaration is h, and newtypes over them
IntLit integer and float types, and their newtypes
FloatLit float types and their newtypes
StrLit String and its newtypes
NullLit every Option<T>
Opaque(reason) every type

Head is what lets a partially known type still select. 0..<n is RangeExclusive<T> for a T the endpoints need not pin, but its head is RangeExclusive whatever T is — enough to reject IndexValue<i32> and IndexValue<RangeInclusive<i32>> and admit exactly one candidate. It also carries a generic call's return type (fn ids() -> List<T> synthesizes Head(List)) and a generic named struct literal.

Opaque names why no type was produced, from a closed set:

Reason Arises from
Closure a closure whose parameters or return type are unannotated
CompoundLiteral […], {…}, a spread — typed by the expected type through builder traits
Inference ?, resume, a tuple comprehension, branches that disagree
Unresolved a name or type that did not resolve — error recovery

There is deliberately no "unsupported" reason. The judgement matches on ast::Expr with no wildcard arm (per the crate's rules), so a new expression form cannot be added without either giving it a rule or naming the language-level reason it has none. Incompleteness that remains is stated, never implied by a catch-all.

The soundness invariant

For every argument e and every parameter type p a candidate declares at that position, if synthesis produces class C, then the type e elaborates to under expected = p lies in C's denotation.

Over-approximation (a wider set than the truth) costs a resolution and yields an ambiguity error asking the user to disambiguate. Under-approximation selects the wrong impl or rejects a well-typed call, and is a compiler bug. Every rule is admissible only because it over-approximates.

Two corollaries the design leans on:

Synthesis rules

Receiver, operand and branch sub-expressions are synthesized recursively; a rule applies only when its premises produce Exact / Head, and yields Opaque(Inference) otherwise. A type mentioning an unsubstituted type parameter yields Head when its head is known and Opaque(Inference) when it is not.

Expression Class
integer / byte literal IntLit
float-only literal FloatLit
string, template string, #include_str StrLit
#file, #function, #data StrLit; #line → IntLit
char / bool / unit literal Exact
null NullLit
bytes literal, #include_bytes Exact(List<u8>) — the elaborator ignores the expected type here
identifier — local, parameter, global, const Exact of its declared type
identifier — enum / flags / variant case Exact of the declaring type
identifier — named function Opaque(Inference) — a function reference is instantiated by the expected type
identifier — resolves to nothing Opaque(Unresolved)
&e / &mut e the reference over e's class
*e the referent of e's class
-e, !e, ~e primitive: e's class; otherwise the operator impl's Output
e as T Exact / Head of T, including generic T
comparison, logical, matches, chain Exact(bool)
arithmetic / bitwise primitive: the operand classes met; otherwise the impl's Output
shift the left operand's class
assignment, compound assignment Exact(unit)
call — named function, variant constructor the declared return type
call — fn-typed value the function type's return type
method call the resolved method's return type
static method call Opaque(Inference) — a generic receiver head takes its arguments from the expected type
field access, tuple field the field's type
e[i] the IndexValue / IndexRef Output for the key's type
a..<b, a..=b Exact when both endpoints agree; otherwise Head(Range*)
named struct literal Exact when non-generic, else Head
anonymous struct / tuple literal / spread Opaque(CompoundLiteral)
block, with … do the trailing statement's class; no value there → Exact(unit)
labeled block Opaque(Inference) — it yields through break label:, not through its tail
if, match the join of the branch classes
closure Opaque(Closure)
e?, resume, tuple comprehension Opaque(Inference)
parse-error placeholder Opaque(Unresolved)

A subscript is the one position read differently from an argument: its key is resolved with no expected type before the indexing impl is selected, so a literal key takes its default type there and synthesis must read it the same way.

Two rules are about reading a name in the right scope. An identifier is looked up in the call site's scope, so a binding the argument introduces for itself — a block's let, a match arm's pattern, an if let — shadows what synthesis would otherwise find: those names are Opaque(Inference), because typing them would mean running the binder's own inference. A block whose leading statements declare a local struct / impl / type is opaque as a whole, for the same reason one step up. And a block's value is its trailing statement's, by the same rule the elaborator reads it back with: a tail expression, a tail if with an else, or a tail match — a trailing if chain is the shape an else if parses to, so reading only tail expressions would answer unit for it.

Branches widen and operands sharpen, so the two composite rules move opposite ways through the lattice. The join for if / match is the least upper bound: when one side's set contains the other's the wider wins (an integer literal beside an i32 is an integer literal), two exact types sharing a head widen to that Head, and anything else widens to the top. The meet for a primitive binary operator is the greatest lower bound, admissible because both operands and the result share one type there: an Exact operand pins the result, two literals keep the literal class.

Every rule delegates its hard question to a query that already exists — lookup_function_return_type, lookup_method_info, find_trait_method_for_type, find_indexing_trait_impl, find_arithmetic_trait_impl, the struct-field tables, resolve_type. Synthesis composes those answers; it does not re-derive them. That is what keeps it a judgement rather than a second type checker.

A literal class admits candidates; it never selects one. f.take(42) against Take<i32> beside Take<i64> is an ambiguity error — the fix is 42 as i64, a binding with a type annotation, or a qualified call — because letting the literal's default type decide would make adding an impl Take<i32> silently retarget every existing call that meant Take<i64>. The common cases are unaffected: l[0] resolves, because an integer literal does not admit the range-typed candidates; f.take(B { v: 1 }) resolves, because a named struct literal has a context-free type.

Classes exist only to filter. After selection the winning signature drives ordinary argument elaboration, so a literal still coerces to the chosen parameter type, and value-range checks run there — selection never depends on a literal's value.

Side-effect discipline

Synthesis runs during lookup, before any decision is committed, so it must leave no trace but interning. It never calls resolve_expr, never takes &mut FunctionContext, never builds TIR, and never records a dispatch or desugar fact.

A speculative resolve_expr against a scratch ModuleSemantics is not an option, now or later. It would corrupt three things living outside the AstId-keyed annotation maps: the FunctionContext local-index walk that reify replays in lockstep (a discarded synthetic local — $ref_*, $qm_*, $b — desyncs every later index into silently wrong code), the anonymous-struct dedup guard in the shared TypeTable (a speculative literal would satisfy it and the real resolve would register nothing), and record_bound_driven_synth_request (no removal API).

The lookup queries synthesis does call are not free of writes, and each is closed:

The discipline is guarded, not merely documented: in debug builds a synthesis scope snapshots the lengths of the sem annotation maps on entry and asserts them unchanged on exit. Interning and the TypeTable's synthesis requests are outside the snapshot by design.

Cost

Synthesis is consulted only where selection needs it: after the candidate set is collected, and only when it holds several concrete candidates of one trait declaration at different argument lists. The classes of one call's arguments are computed at most once and memoized across the (up to three) lookup attempts a method call makes.

Classification must not be eager and universal: a single-candidate call — which is effectively all of them — pays nothing, and that is what pays for the depth that makes the multi-candidate case resolve.

Qualified calls

One form names a candidate explicitly: trait-qualified (UFCS) Trait::method(receiver, args…). The self parameter becomes the first argument, spelled to match its mode — &x for &self, &mut x for &mut self, the value for self. A mismatched mode is an error, not a coercion: passing a value where the method takes &mut self would mutate a copy and silently drop the change. The one exception is the language's one reference coercion — &mut x also answers a &self method:

Display::fmt(&p, f);          // p implements two traits declaring `fmt`
Base::name(&x);               // supertrait diamond inside a generic body
Take::<A>::take(f, B { … });  // one trait's argument list, pinned

The receiver argument supplies Self, so the trait's own type arguments are all a turbofish needs to carry. That covers both collision shapes: across traits, the trait name discriminates; within one trait, the turbofish pins the argument list. The head and the turbofish arguments resolve to identities (declaration key, resolved types) before filtering, so aliases and same-named foreign traits behave like everywhere else — and only the named declaration may answer: the auto-derived Eq / Ord bodies answer a qualified call only when it names them, never a user trait that happens to share the method name. This closes the escape gap Super Traits documents. If the named trait still has several argument lists for the receiver and no turbofish is written, argument-directed selection applies within it.

No new grammar: Take::<A>::take(f, x) is the static-path shape List::<i32>::with_capacity(10) already uses. Only resolution differs — for a static path Head::name(args), Head resolves in the type namespace (type → associated function; interface → effect operation), and a trait head makes it a UFCS call. The reflect intrinsics (ReflectStruct::<T>::members()) keep their existing spelling, where the turbofish names the reflected type instead — they are compiler intrinsics whose traits declare no type parameters, so the two readings never meet.

A qualified call is filed as a static dispatch, not a method dispatch: it spells its receiver's mode itself, so no receiver adjustment is owed and the call is an ordinary one whose first argument happens to be the receiver — the shape reify's Call arm already replays. Two record-keeping consequences are load-bearing: the static record carries the resolved signature's real facts (defaults, is_mut, parameter types, with the receiver prepended as slot 0), never fabricated ones; and every downstream pass that positions a Call's arguments against its callee's parameters must account for the receiver in args[0], keying on value-argument indices rather than parameter slots so both call spellings produce one key.

The uncovered case

An associated function with no self has no receiver argument, so UFCS cannot supply Self. When one type implements two traits that each declare an associated function of the same name, the call is ambiguous with no escape:

trait Loadable   { fn load(p: String) -> Self; }
trait Restorable { fn load(p: String) -> Self; }
impl Loadable for Config { … }
impl Restorable for Config { … }

Config::load(p);    // ERROR: ambiguous, and unspellable — rename one

This is left open. The shape is narrow — From / TryFrom keep their own path, and the prelude's Default::default / FromStr::from_str do not collide with each other — so renaming is an acceptable answer for a case that has not arisen. If it does, the follow-up must name Self without a receiver and without a leading < (JSX owns that position): either binding Self from the expected type (let c: Config = Loadable::load(p), which contradicts this WEP's forward-inference non-goal and so needs its own decision), or a turbofish naming Self on the trait head.

Conversions

from is a trait static, so Type::from(x) takes the static path: the receiver's impls are surveyed and the argument weighed against them before the argument is elaborated, which is what removes the circular ordering at its root. Admissibility is the same table selection uses, applied to each impl's source type resolved in the impl's own frame (static_arg_survey) — so an integer newtype admits an integer literal here exactly as it does there, and From<i64> beside From<Meters> is ambiguous rather than silently primitive. One admitted impl supplies the argument's expected type; several report AmbiguousStaticArgument, whose fix is the cast — from has no self, so the trait-turbofish escape cannot apply.

An argument that synthesizes a type selects through it. The matcher compares the head (un-aliased in the impl's module) and, for a generic argument spelling, the full spelling with whitespace ignored, so same-head impls (From<List<i32>> beside From<List<String>>) are told apart; nested aliasing that changes the rendered arguments is the name-based mechanism's remaining ceiling, with full TypeId matching the eventual replacement.

An argument known only by its head does not preselect at all. Head denotes a family — Pair { a: 5 } is a Pair of something — so several same-head impls answering it is the expected reading, not a tie, and reporting one would fail a call that elaborating the argument resolves. Only a class denoting a single type may call two candidates ambiguous.

Two shapes stay carved out at the gate rather than guessed at: an inherent static from beside From impls answers on the trait-less path, and a conversion reachable only through a blanket generic in its source type (impl<T: Display> From<T> for Wrapper) is rejected with its own diagnostic — it has never compiled, and selecting its instantiation needs generic-impl monomorphization, not name matching.

?'s conversion stays target-type-directed by design and is unaffected.

Diagnostics

Both ambiguity errors follow Diagnostic Reason Chains: the error names every surviving candidate in symbol notation, states per candidate why it survived or was filtered — "argument 1 is an integer literal, admitted by both Take<i32> and Take<i64>"; "argument 2 has type String, parameter is i32"; "argument 1 is a closure, so it admits every candidate" — and carries a fix-it: a qualified call for cross-trait collisions, an as cast or annotation for literal-only distinctions. An Opaque argument reports its reason, which is what tells the user whether the call can be fixed by annotating it. The no-survivor case (candidates exist, all filtered) reports each candidate with the position that rejected it instead of a bare "method not found".

No survivor is a distinct error from ambiguity, not a degenerate case of it. A class only ever over-approximates, so no candidate admitting it means no candidate can accept the argument: the message says the arguments are not accepted and lists what the overload set does take, rather than asking for an annotation on an argument that is already pinned.

Interactions

Feature Interaction
Operators Indexing selects by the operand's type, which is the same rule read through a different entry point; the two must agree for every index expression. Arithmetic and bitwise operators select over Add<Rhs> and friends by the right operand's class, and report the same unique-or-error at the operator. Eq / Ord take no trait arguments — unaffected.
From / ? See Conversions. ? stays target-type-directed.
Default arguments Owned by the trait declaration, identical across an overload set — no interaction with selection.
Effects Never considered by selection; the chosen method's with clause is checked afterwards.
Coherence Untouched. Selection picks an argument list among impls coherence already accepts; overlapping impls of one instantiation remain errors.
Newtypes Inherited impls are candidates on the newtype receiver; the same grouping and selection apply.
Monomorphization Unaffected. The chosen trait's spelling lands in the mangled name, which InstantiationKey already discriminates on.
LSP / tooling Hover and go-to-definition read the recorded dispatch fact; synthesis leaves no persistent state.

Non-goals

Rejected alternatives

A fallback that picks a candidate when every argument is Opaque. The pick would be made by candidate order, which is the silent wrong dispatch this design exists to remove; on List it would compile l.index_value(x) to a range index or an element index depending on impl order, and adding an impl would retarget existing calls. What the pick lands on is already visible: report-and-continue hands the arguments to the first candidate, so an ambiguous l.index_value(0..<2) is followed by expected 'i32', found 'RangeExclusive<i32>' — the fallback would keep that selection and drop the error. An overload set that cannot be told apart is an error with two escapes (as, the trait turbofish), not a coin flip.

Speculative resolve_expr with rollback. Neither the TypeTable nor FunctionContext is transactional, and making them so to serve classification inverts the cost.

Elaborate-then-select — elaborating the arguments for real before selection and keeping the results. It cannot type the fragment where selection matters most (literals must not select, compound literals and closures cannot be typed without a parameter), so it would not replace synthesis; it reorders local-index allocation against reify's replay; and it would have to thread pre-elaborated arguments through every call path. Indexing does exactly this for its single argument, including a second resolve_expr of the same node when the selected key type differs — the direction of travel is for that path to adopt synthesis, not the reverse.

Joint constraint solving over selection and inference (Swift, C#). It is the principled answer to "select and infer at once", and it contradicts Wado's forward-inference non-goal.

<Type as Trait<Args>>::method(args…) as the qualified form. Not a cost-benefit call — the syntactic position is taken. A leading < in expression position belongs to JSX, reserved by Reactive Signals (return <button onclick={…}>), so Rust's spelling is unavailable to Wado at any price. It would also pin the receiver type, which is redundant wherever a receiver argument exists. Only the trailing turbofish position (::<, after ::) stays free, which is what the adopted form uses.

Type^Trait::method in source. ^ is the xor operator, so A ^ B::m(x) already parses as an expression; the caret form stays what it is — the internal mangled-name and symbol-notation grammar for tooling, docs, and diagnostics.

Implementation

Where the pieces live:

The bounds counterpart of an overload set (T: Take<A> + Take<B>) cannot arise yet — positional trait arguments do not parse in bound position.

Test surface: the ufcs_*, trait_argument_*, from_*, operator_rhs_* and cross_module_same_name_* fixture families in wado-compiler/tests/fixtures/ — one fixture per rule above, plus error_* fixtures pinning every diagnostic's message. Argument synthesis has one fixture per class of rule (trait_argument_synthesized_shapes, _index, _scopes), negative fixtures for what stays ambiguous and why, and a pair fixture asserting that l[e] and l.index_value(e) select the same impl for every e.

Consequences

Benefits:

Trade-offs:

Known gaps

See Also