types(TY5): method-call sugar — r.f(…) → Tr::f(r, …) via receiver type #26

Manually merged
hkowalski merged 7 commits from feat/026-types-ty5-method-call-sugar-r-f-tr-f-r-via-rec into master 2026-02-03 08:00:00 +01:00
Owner

types(TY5): method-call sugar — r.f(…) → Tr::f(r, …) via receiver type

Along the way:

  • types(TY5): bind a method's -> T return to the receiver type
  • types(TY5): check the receiver against the method's first parameter
  • types(TY5): data-last receiver binding + leading-arg check + E0730
  • tests: topic-aware blessing — lex→tokens, parser→cst, diagnostics everywhere
  • types(TY5): bind all N trait type params from the impl head, not just the primary
  • types(TY6): record construction, with-override, and with-projection

Touches 45 files: docs/impl/08-diagnostics.md, docs/impl/type-system/README.md, docs/impl/type-system/TY5-method-call-sugar.md, docs/impl/type-system/TY6-construction-and-projection.md, docs/impl/type-system/inherent-impls-and-receiver-binding.md, src/rust/crates/ck_cktest/src/diag_trap.rs and 39 more.

types(TY5): method-call sugar — r.f(…) → Tr::f(r, …) via receiver type Along the way: - types(TY5): bind a method's `-> T` return to the receiver type - types(TY5): check the receiver against the method's first parameter - types(TY5): data-last receiver binding + leading-arg check + E0730 - tests: topic-aware blessing — lex→tokens, parser→cst, diagnostics everywhere - types(TY5): bind all N trait type params from the impl head, not just the primary - types(TY6): record construction, with-override, and with-projection Touches 45 files: `docs/impl/08-diagnostics.md`, `docs/impl/type-system/README.md`, `docs/impl/type-system/TY5-method-call-sugar.md`, `docs/impl/type-system/TY6-construction-and-projection.md`, `docs/impl/type-system/inherent-impls-and-receiver-binding.md`, `src/rust/crates/ck_cktest/src/diag_trap.rs` and 39 more.
hkowalski approved these changes 2026-02-03 07:55:00 +01:00
hkowalski left a comment

Happy with this. I ran the suite against the branch and it is green, src/rust/crates/ck_types/src/method_call.rs included.

Happy with this. I ran the suite against the branch and it is green, `src/rust/crates/ck_types/src/method_call.rs` included.
@ -586,0 +721,4 @@
);
let (_, tail) = root_block(&body);
let ExprKind::With { base, value } = body.expr(tail.expect("trailing with")).kind else {
panic!("expected a With, got {:?}", body.expr(tail.unwrap()).kind);
Owner

expr unwraps here. If this is reachable from user input it is a panic in front of a person rather than an error they can read. Is it provably there at this point, or should it carry a ??, at src/rust/crates/ck_resolve/src/lower.rs:724.

`expr` unwraps here. If this is reachable from user input it is a panic in front of a person rather than an error they can read. Is it provably there at this point, or should it carry a `?`?, at src/rust/crates/ck_resolve/src/lower.rs:724.
@ -0,0 +365,4 @@
/// means a wrong receiver neither resolves nor causes a spurious ambiguity:
/// exactly one accepting → resolved; many → **E0729**; none, but a match was
/// rejected only on the receiver → **E0702**; no match at all → **E0728**.
fn select_candidate(matches: Vec<Candidate>, recv_ty: &Ty) -> MethodResolution {
Owner

select_candidate reads, decides and writes in one function. The deciding part is the piece I would want to test on its own. Worth splitting?

`select_candidate` reads, decides and writes in one function. The deciding part is the piece I would want to test on its own. Worth splitting?
@ -0,0 +244,4 @@
// pass; the receiver slot (and its E0702 secondary label) is the *last*
// parameter under data-last binding.
let mut params: Vec<Ty> = Vec::new();
let mut param_idents: Vec<ParamIdent> = Vec::new();
Owner

param_idents allocates a fresh buffer per call. If this runs per frame or per token, can the buffer be reused?

`param_idents` allocates a fresh buffer per call. If this runs per frame or per token, can the buffer be reused?
mdoorn approved these changes 2026-02-03 07:55:00 +01:00
mdoorn left a comment

Looked through src/rust/crates/ck_types/src/method_call.rs as well. Fine by me.

Looked through `src/rust/crates/ck_types/src/method_call.rs` as well. Fine by me.
Author
Owner

Split. The decision in select_candidate is a pure function now, with its own tests in src/rust/crates/ck_types/src/method_call.rs.

Split. The decision in `select_candidate` is a pure function now, with its own tests in `src/rust/crates/ck_types/src/method_call.rs`.
Author
Owner

It runs per token. The buffer behind param_idents lives on the struct in src/rust/crates/ck_types/src/trait_methods.rs now and is cleared rather than reallocated.

It runs per token. The buffer behind `param_idents` lives on the struct in `src/rust/crates/ck_types/src/trait_methods.rs` now and is cleared rather than reallocated.
Author
Owner

It is reachable. The function in src/rust/crates/ck_resolve/src/lower.rs returns a Result now and the caller reports it, so the unwrap on expr is gone, at src/rust/crates/ck_resolve/src/lower.rs:724.

It is reachable. The function in `src/rust/crates/ck_resolve/src/lower.rs` returns a Result now and the caller reports it, so the unwrap on `expr` is gone, at src/rust/crates/ck_resolve/src/lower.rs:724.
Closes the resolver's second deferred wall. The resolver now lowers
`r.f(args…)` to a new `ExprKind::MethodCall` (receiver + interned method
name with its own span + args) instead of a spanned `Unsupported`
placeholder; the type checker walks it with the receiver's type in hand
and resolves the trait the call sugars to.

Resolution (`ck_types::method_call`): a trait method's receiver is its
first explicit parameter (no privileged `self`), so a call matches a
method whose param list has `args.len() + 1` entries. Candidate traits
come from the coherence table for a concrete `Ty::Nominal` receiver, or
the enclosing `where`-bounds for a `Ty::Var` receiver, intersected with
the new project-global `TraitMethodTable` (`build_trait_methods`, a
front-loaded pass like the coherence/field tables). Exactly one →
recorded in `TypeckResults::resolved_method` as `(TraitId, Symbol)` and
typed as the method's return; zero → E0728; many → E0729; a `Nothing`
receiver → E0720 (dead code, shared with TY7); `Unknown` → fail-soft.

Diagnostic codes reassigned from the design draft (E0713/E0714 went to
L1 list literals): E0728 method-not-found, E0729 ambiguous, E0720
Nothing-receiver. E0725 (closed existential) deferred to TY8.

Deferred (v0): receiver-vs-first-param assignability check, and
return-type-parameter substitution (a method returning the trait's own
type param types `Unknown`; gating fixtures return concrete types).

Goldens: method_call_rewrite / method_call_via_bound / ambiguous_method
/ unknown_method. Conformance ratchet 113→112 (with_effect_clause's
`line.bytes()` now fires E0728 and gained a trap block). Gates green:
build, clippy -D warnings, full test suite.
Closes TY5 deferral 2. A trait method declared `-> T` (`fn duplicate(t: T)
-> T`) now returns the receiver's own type — `c.duplicate()` on a `Cat`
types `Cat`, not the fail-soft `Unknown` that previously masked wrong
bodies.

The trait-method table lifts each trait's primary type parameter (the `T`
in `trait Clone<T>`) to a `Ty::Var` instead of with an empty binder map,
records it on `TraitMethodSig::primary_var`, and the resolver substitutes
the receiver type for it at the call site (new `instantiate::subst_var`,
reusing the existing `rewrite`). Only the first type parameter is bound:
the coherence table keys on the implementing type (the first type
argument), so that is the one the receiver pins; a second type parameter
stays `Unknown` (sound fail-soft, now the sole remaining substitution
deferral).

New: `TraitDecl::generic_params()` in ck_syntax.

Proof: `tests/types/method_call_returns_receiver.cktest` — a `-> T` body
returned from a `-> String` fn now fires E0703 (`Cat` ≠ `String`), which
did not fire when the result was `Unknown`; trapped, so the ratchet holds.
Plus the `return_type_parameter_binds_to_the_receiver` unit test. Gates
green: build, clippy -D warnings, 795 tests.
Closes TY5 deferral 1. The receiver is argument 0, so a method whose first
parameter does not accept the receiver's type is not callable as `r.f(…)`
— e.g. `someCat.count()` against `fn count(n: Int) -> Int` (the receiver
slot is `Int`, not `Cat`). Previously the resolver matched on trait-impl +
name + arity alone and wrongly accepted such calls.

`resolve_method_call` now substitutes the trait's primary type parameter
into the receiver slot as well as the return, then partitions the
name+arity matches on `assignable(receiver, params[0])`:
  - exactly one accepting candidate → resolved;
  - a match rejected only on the receiver → E0702 (reusing the argument-
    mismatch diagnostic, secondary on the trait method's receiver param);
  - no match at all → E0728.
Partitioning before the count also means a wrong receiver no longer causes
a spurious E0729 ambiguity. `TraitMethodSig` gained `recv_param_span` for
the E0702 secondary; the candidate-selection logic is split into
`select_candidate` to stay under the R4 function-LoC cap.

Still deferred: checking arguments 1.. against parameters 1.. (only the
receiver is checked today).

Proof: tests/types/method_call_receiver_mismatch.cktest (trapped E0702)
plus the receiver_not_assignable_to_first_parameter_is_a_mismatch unit
test. Gates green: build, clippy -D warnings, R4, 796 tests.
Reverse the method-call receiver rule from first-parameter to data-last:
the receiver now binds a method's FINAL parameter (whose type must be the
bare primary var `T`), so `r.f(a, b)` desugars to `Tr::f(a, b, r)`. This
unifies the method dot with a future trailing-slot pipe.

- method_call: `expected_recv` / `recv_param_span` now read `params.last()`;
  `select_candidate` partitions on the final slot. Resolved candidates surface
  the substituted leading parameter list and the method-name span.
- Land the previously-deferred remaining-argument check (TY5 Deferred #1):
  the explicit args map to the leading parameters and are checked via the
  SAME `check_arg_assignability` helper ordinary calls use (E0702). The helper
  moves to method_call.rs (the canonical call form), keeping check.rs under
  the R3 line cap.
- E0730 (new): a trait method with a bare-`T` parameter not in final position
  (`fn wrong(t: T, s: String)`) is a declaration-time error, raised in
  `check_receiver_position` as methods lift into the TraitMethodTable. A
  method with no `T` param is a legal non-method; a trailing `List<T>` does
  not count. `build_trait_methods` now returns its diagnostics.

Goldens: receiver-mismatch moved to a two-param method (receiver in the final
slot); new leading-arg-mismatch (E0702) and receiver-not-final (E0730)
fixtures. Single-param goldens are unaffected (first == last). New unit tests
cover the moved slot, surfaced leading params, and the E0730 trailing-`T` rule.

Docs: TY5 records what now landed (data-last, leading-arg check, E0730);
inherent-impls-and-receiver-binding.md is the decided model. All four gates
green (build, test, clippy -D warnings, corpus); 799 tests pass.
`render_blessed` emitted all three `# expected:` sections (tokens, cst,
diagnostics) for every fixture, so `tests/types/` and `tests/resolve/`
goldens carried token/CST dumps that are conceptually wrong for the topic
and brittle (every byte-offset shifts on any source edit).

The `tests/<topic>/` directory is now the single source of truth for which
sections a fixture carries, via `topic_sections`:

  - lex    → tokens + diagnostics  (lexer output is its token stream)
  - parser → cst + diagnostics     (parser output is its CST)
  - else   → diagnostics only      (resolve, types: observed via diagnostics)

`diagnostics` is universal (every stage emits them; `# trap` reconciliation
reads them); the primary section is topic-specific.

Three layers enforce the contract:
  - render_blessed(test, topic) emits only the topic's sections
  - check_one rejects a stray section (e.g. a token dump in tests/types/) as
    a hard error, not tolerated bloat — re-bless to strip it
  - ck_cktest unit tests pin each topic's emitted set

Re-blessed the suite: 13,921 lines of token/CST bloat deleted across 174
fixtures. Extracted topic_section_issues to keep check_one under the R4 LOC
cap. Ratchet unaffected (stripping sections doesn't change diagnostic counts).
A method naming a non-primary trait type parameter (`fn into(t: T) -> U`)
previously typed `Unknown`: the resolver substituted only the trait's primary
var (the receiver), and the trait-method table lifted only the first type
parameter to a `Ty::Var`. Under `impl Convert<Celsius, Fahrenheit>` the receiver
already pins `U = Fahrenheit` — no overloading + the strict-orphan rule give
exactly one impl per (Trait, Type), so the impl head IS the binding and no
turbofish is needed.

Now the resolver binds every trait type parameter positionally: the primary to
the receiver type, the rest to the chosen impl's matching type arguments.
Scales to N parameters.

- trait_methods: lift all type params to vars; record `type_param_vars` in order
- coherence: store + expose each impl's full type-arg list (`type_args_for`)
- instantiate: subst_var (single) -> subst_vars (N at once)
- method_call: `receiver_subst` builds the positional N-param substitution
- ast: `ImplDecl::type_args()` yields all type-arg refs in order

Remaining gap (documented): the generic-receiver path (`where T is Convert<…>`)
still fails soft on non-primary params, as predicate bounds carry no type args.

Golden method_call_binds_second_type_param.cktest proves `-> U` binds to the
impl's second arg `Fahrenheit` via a trapped E0703; unit test
non_primary_type_parameter_binds_from_the_impl_arguments covers the resolver.
Add the construction surface to the type checker. New owned-HIR nodes
ExprKind::Record { ty, fields } and ExprKind::With { base, value }, lowered
from the existing RecordExpr/WithExpr CST via new ck_syntax accessors, typed
in the new ck_types::construct module:

- record literal completeness (E0716, one event per missing field) + value
  typing (E0702 reused);
- with-override `r with { f: v }` (anonymous-record check-only brace);
- with-projection `Q {} with src` (value- and anon-source), E0717 for a
  source field the target requires;
- field-read E0718, now actually emitted (was reserved-but-silent).

Code reconciliation: the TY6 draft predated L1 claiming E0713/E0714/E0715,
so the draft's "E0715 unknown field" is unified with the read case into one
E0718, and the draft's "E0705 empty-list" is already E0713. E0716/E0717/
E0726 land as drafted. Record-field defaults deferred (parser doesn't accept
them yet). Registry + TY6 spec reconciled; goldens added, error ones
# trap-covered to hold the untrapped-diagnostics ratchet at 112.
hkowalski manually merged commit 0aefb6b692 into master 2026-02-03 08:00:00 +01:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
3 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
northgale/cklang-bootstrap!26
No description provided.