brink_analyzer/ufcs.rs
1//! B3a — UFCS (uniform function call syntax) resolution: `recv.name(args)`
2//! (issue #1482; D1–D5 RULED 2026-07-26, `docs/decision-log.md` "UFCS
3//! resolution pass designed: type-directed, in the analyzer, five rulings").
4//!
5//! ## Why this lives in `brink-analyzer`
6//!
7//! UFCS resolution is **type-directed name resolution**: field-access-wins
8//! is unanswerable without the receiver's type, so the verdict cannot be
9//! reached in the frontend or in HIR lowering. The native lowering already
10//! produces `Expr::Call(Path, args)` for a dotted callee unchanged (see
11//! `brink-ir`'s `hir::lower_native::expr::lower_call`) — this module is the
12//! pass that decides what that shape *means*.
13//!
14//! ## The algorithm, per call site
15//!
16//! For `recv.name(args)` — an `Expr::Call` whose callee `Path` has more than
17//! one segment and whose head names a *value* in scope:
18//!
19//! 1. Infer the receiver's type (`recv` = every segment but the last).
20//! 2. The type declares a field `name` → **field access wins**. The field
21//! must be function-typed; the call is a call *through the field's
22//! value* ([`UfcsVerdict::FieldCall`], rows per #872).
23//! **D1**: a matching but non-callable field is a **hard error**
24//! ([`DiagnosticCode::E140`]) — never a fall-through to a free function,
25//! so a call's meaning never hinges on a field's type.
26//! 3. Else resolve `name` as a free function in **ordinary lexical scope
27//! only** (D4 — no method sets, no inherent impls: any in-scope free
28//! function is method-callable) — file `use` + the T1b/NS stdlib
29//! prelude (`len`, `push`, `sort_by`, …) — and record the desugar to
30//! `name(recv, args)` ([`UfcsVerdict::FreeFnDesugar`] for an index
31//! symbol, [`UfcsVerdict::PreludeDesugar`] for a prelude verb, which has
32//! no index symbol to point at).
33//! 4. Neither → one diagnostic naming **both** attempts
34//! ([`DiagnosticCode::E141`]).
35//!
36//! **D3**: an unknown receiver type at the resolution point is an error
37//! demanding an annotation ([`DiagnosticCode::E142`]), *not* a deferral —
38//! there is deliberately no deferral machinery here. The improvement
39//! (smarter inference ordering) is tracked separately and is additive.
40//!
41//! **D5 — auto-ref** (issue #1462, landed on top of this pass): the desugar
42//! is by value *unless* the resolved free function's first parameter is
43//! declared `ref`. Then the receiver is passed by reference
44//! ([`UfcsVerdict::FreeFnAutoRef`]) and the desugar spells the projection
45//! explicitly — `party.members.heal(5)` → `heal(ref party.members, 5)` —
46//! riding the T1e ref-argument/projection machinery
47//! (`brink_ir::lir::lower::expr::lower_call_args`) for a **durable** root,
48//! or (**RULED 2026-07-27**, issue #1531) a frame-local read/call/
49//! write-back RMW expansion for a **frame-local** root one field deep
50//! (`brink_ir::lir::lower::blocks::try_lower_frame_local_auto_ref_stmt`) —
51//! never a parallel path for the durable case. A receiver that cannot be
52//! written through is refused with [`DiagnosticCode::E143`] rather than
53//! silently desugared by value, which would drop the mutation: see
54//! [`UfcsVisitor::auto_ref_fault`] for exactly which receivers those are. A
55//! non-`ref` first parameter is unaffected — plain by-value desugar, with
56//! no lvalue requirement on the receiver.
57//!
58//! ## Scope fences
59//!
60//! - Only the final pre-`(` segment gets this treatment; a bare `a.b` (an
61//! `Expr::FieldAccess`, or a dotted `Expr::Path`) is untouched.
62//! - Each call in `a.b().c()` resolves independently — this pass keys
63//! verdicts by call-site range, never by chain.
64//! - **The ink dialect is untouched by construction.** ink's own
65//! `FunctionCall` lowering always builds a *single-segment* callee path
66//! (`brink-ir`'s `hir::lower::expr::references`), and its computed-callee
67//! `CallExpr` is a structural `E104`. A multi-segment `Expr::Call` path
68//! can therefore only originate in the native frontend, so no dialect
69//! flag is needed to keep this pass off the ink corpus.
70//! - The explicit free-call spelling (`name(recv, args)`) is unaffected.
71//!
72//! ## The side table (D2)
73//!
74//! The verdict is recorded in a **side table** keyed by node
75//! ([`SideTable`]), not written back into the HIR — HIR stays immutable,
76//! matching the analyzer's existing "inference results travel beside the
77//! tree" posture (`infer::InferenceResult`).
78//!
79//! The table is published as the seam (`brink_analyzer::ufcs_resolution`)
80//! the two ruled consumers read. **LIR lowering is wired** (issue #1506,
81//! `brink-db`'s `ufcs_resolution_query` translates this table into
82//! `brink-ir`'s own lowering-facing mirror at the query boundary) — it now
83//! emits either a call through the field's value or the desugared free
84//! call for real. A resolved site LIR lowering cannot find a verdict for
85//! (a caller that never ran this pass) still refuses with
86//! [`DiagnosticCode::E144`] rather than lowering against the receiver's own
87//! id, which would be a silently wrong program — but that is a defensive
88//! fallback now, not the unconditional behavior. IDE hover/go-to-def
89//! (issue #1507, `brink-ide`'s `ufcs_hover` module) is wired too — it reads
90//! the same memoized table (`brink_db::ProjectDb::ufcs_verdict`) to name the
91//! real target rather than the receiver the [`ResolutionMap`] records for
92//! the callee path.
93//!
94//! [`SideTable`] is deliberately generic over its payload: it is
95//! `(node → verdict)` plumbing, so a second payload kind can ride the same
96//! keying and the same lookup without a parallel structure being invented.
97//! Issue #1492 did exactly that — `crate::coalesce`'s [`CoalesceTable`]
98//! is a `SideTable<CoalesceChain>` carrying `or`-coalescing's recorded
99//! operand/result types to the same LIR-lowering consumer, on this keying,
100//! with no second mechanism.
101//!
102//! [`CoalesceTable`]: crate::CoalesceTable
103//! [`CoalesceChain`]: crate::CoalesceChain
104
105use std::collections::BTreeMap;
106
107use brink_format::DefinitionId;
108use brink_ir::hir::visit::{self, HirVisitor};
109use brink_ir::{
110 Diagnostic, DiagnosticCode, Expr, FileId, HirFile, Knot, Path as HirPath, ResolutionMap,
111 Stitch, SymbolIndex, SymbolKind,
112};
113use rowan::TextRange;
114
115use crate::annotations;
116use crate::infer::{InferenceResult, InferredSig, Ty, assignable, ref_assignable};
117use crate::resolve::ImportScope;
118use crate::structs::{ShapeTable, declared_shapes};
119
120// ─── The side table (D2) ─────────────────────────────────────────────
121
122/// Identity of one HIR node for side-table purposes: the file it lives in
123/// plus its source range.
124///
125/// `TextRange` has no `Ord` impl (ranges have no single natural total
126/// order), so the range travels as a `(start, end)` `u32` pair — the same
127/// `range_key` convention `infer`, `strict`, and `structs` each already use
128/// for their own range-keyed maps. A range is only unique *within* a file,
129/// hence the [`FileId`] half: side-table entries must never be merged
130/// across files.
131#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
132pub struct NodeKey {
133 /// The file the node was lowered from.
134 pub file: FileId,
135 /// The node's source range, as `(start, end)`.
136 pub range: (u32, u32),
137}
138
139impl NodeKey {
140 /// The key for a node at `range` in `file`.
141 #[must_use]
142 pub fn new(file: FileId, range: TextRange) -> Self {
143 Self {
144 file,
145 range: (range.start().into(), range.end().into()),
146 }
147 }
148}
149
150/// A `(node → payload)` side channel: analysis verdicts recorded *beside*
151/// the HIR rather than written into it (D2 — the HIR stays immutable).
152///
153/// Generic over the payload so a second kind of verdict can ride the same
154/// plumbing instead of a parallel structure being invented for it. Backed by
155/// a `BTreeMap` so iteration order is deterministic (house rule — never
156/// iterate a `HashMap` where order affects output); [`Self::iter`] is what a
157/// consumer that wants *every* verdict (e.g. an IDE building an overlay)
158/// walks.
159#[derive(Debug, Clone, PartialEq, Eq)]
160pub struct SideTable<V> {
161 entries: BTreeMap<NodeKey, V>,
162}
163
164impl<V> Default for SideTable<V> {
165 fn default() -> Self {
166 Self {
167 entries: BTreeMap::new(),
168 }
169 }
170}
171
172impl<V> SideTable<V> {
173 /// An empty table.
174 #[must_use]
175 pub fn new() -> Self {
176 Self::default()
177 }
178
179 /// Record `value` for the node at `key`, returning any previous entry.
180 pub fn insert(&mut self, key: NodeKey, value: V) -> Option<V> {
181 self.entries.insert(key, value)
182 }
183
184 /// The payload recorded for the node at `key`, if any.
185 #[must_use]
186 pub fn get(&self, key: NodeKey) -> Option<&V> {
187 self.entries.get(&key)
188 }
189
190 /// The payload recorded for the node at `range` in `file`, if any — the
191 /// convenience spelling for a consumer holding an HIR node rather than a
192 /// pre-built [`NodeKey`].
193 #[must_use]
194 pub fn at(&self, file: FileId, range: TextRange) -> Option<&V> {
195 self.get(NodeKey::new(file, range))
196 }
197
198 /// Every recorded entry, in deterministic `(file, range)` order.
199 pub fn iter(&self) -> impl Iterator<Item = (NodeKey, &V)> {
200 self.entries.iter().map(|(k, v)| (*k, v))
201 }
202
203 /// How many nodes carry a payload.
204 #[must_use]
205 pub fn len(&self) -> usize {
206 self.entries.len()
207 }
208
209 /// Whether no node carries a payload.
210 #[must_use]
211 pub fn is_empty(&self) -> bool {
212 self.entries.is_empty()
213 }
214}
215
216/// What one `recv.name(args)` call site resolved to (D2's "node → resolved
217/// target"). Consumed by LIR lowering — which of the two code shapes to
218/// emit — and by IDE hover/go-to-def, which needs the *real* target rather
219/// than the receiver the [`ResolutionMap`] records for the callee path.
220#[derive(Debug, Clone, PartialEq, Eq)]
221pub enum UfcsVerdict {
222 /// Field access won (step 2): the receiver's type declares a
223 /// function-typed field with the called name, so the call is a call
224 /// *through that field's value*.
225 FieldCall {
226 /// The receiver's inferred type.
227 receiver: Ty,
228 /// The field name — the call's final pre-`(` path segment.
229 field: String,
230 /// The field's declared type. Always a [`Ty::Fn`] — a
231 /// non-callable match is `E140`, never a verdict.
232 field_ty: Ty,
233 /// Issue #1918: this verdict's own arity fact. Unlike
234 /// [`Self::FreeFnDesugar`]/[`Self::FreeFnAutoRef`], a field call has
235 /// no receiver-prepending desugar — `npc.on_greet(3)` lowers
236 /// straight to `lir::ExprKind::CallValue { callee, args }` calling the
237 /// field's own `fn(...)` value with the *written* arguments only
238 /// (`brink_ir::lir::lower::expr::lower_ufcs_call`'s `FieldCall`
239 /// arm), so a mismatch here is a plain expected/got pair, not a
240 /// per-argument [`UfcsArgMismatch`]. Computed unconditionally
241 /// alongside this verdict, like every other verdict's own arg-check
242 /// fields; reported only by strict mode ([`check_strict`], `E063`)
243 /// — this verdict is structurally `strict::check_value_calls`'s T1c
244 /// "call through a function value" domain, just reached via field
245 /// access, and this reuses that check's own
246 /// `ValueCallKind::ArityMismatch` wording verbatim. Gradual mode
247 /// relies on the runtime `FunctionValueArity` fault
248 /// `Opcode::CallValue` already raises for every call through a
249 /// function value — the same bytecode shape this verdict lowers
250 /// to, so arity is enforced there regardless of static policy.
251 arity_mismatch: Option<UfcsArityMismatch>,
252 /// Issue #1918: this verdict's own per-argument type mismatches —
253 /// the `FieldCall` sibling of [`Self::FreeFnDesugar`]'s own
254 /// `arg_mismatches`. **Differs from that sibling's index
255 /// convention**: a field call passes no receiver argument, so
256 /// `index` here is 0-based over the *written* arguments only —
257 /// matching `strict::check_value_calls`'s own
258 /// `ValueCallKind::ArgMismatch` convention, not
259 /// [`UfcsArgMismatch::index`]'s "receiver counts as 0" default (see
260 /// that field's own doc for the exception this carves out).
261 /// Reported only by strict mode (`E063`), same gate as
262 /// `arity_mismatch` above.
263 arg_mismatches: Vec<UfcsArgMismatch>,
264 },
265 /// **D5 auto-ref** (issue #1462): a free function won (step 3) *and* its
266 /// first parameter is declared `ref`, so the call desugars to
267 /// `name(ref recv, args)` — the receiver spelled as an explicit T1e
268 /// ref-argument/projection, so the callee's writes land in the
269 /// receiver's own cell instead of in a copy.
270 ///
271 /// Only ever recorded for a receiver that can actually be written
272 /// through ([`UfcsVisitor::auto_ref_fault`]); anything else is `E143`.
273 FreeFnAutoRef {
274 /// The receiver's inferred type.
275 receiver: Ty,
276 /// The free function's name, as written.
277 name: String,
278 /// The definition the desugared call targets.
279 target: DefinitionId,
280 /// Issue #1881: statically-checkable argument-type mismatches
281 /// between the desugared call `name(recv, args)` and `target`'s
282 /// already-known declared param types, computed unconditionally
283 /// alongside this verdict — see [`UfcsArgMismatch`]'s own doc.
284 /// Reported only by strict mode ([`check_strict`], `E063`).
285 arg_mismatches: Vec<UfcsArgMismatch>,
286 },
287 /// A free function won (step 3): the call desugars to
288 /// `name(recv, args)`, by value.
289 FreeFnDesugar {
290 /// The receiver's inferred type.
291 receiver: Ty,
292 /// The free function's name, as written.
293 name: String,
294 /// The definition the desugared call targets.
295 target: DefinitionId,
296 /// Issue #1881: identical posture to [`Self::FreeFnAutoRef`]'s own
297 /// `arg_mismatches` field — the by-value desugar shape.
298 arg_mismatches: Vec<UfcsArgMismatch>,
299 },
300 /// A T1b/NS stdlib prelude name won (step 3, D4's "file `use` + prelude"
301 /// candidate set): the call desugars to `name(recv, args)` exactly like
302 /// [`Self::FreeFnDesugar`], but the target is a VM-native intrinsic
303 /// (`resolve::is_t1b_stdlib_name`/`resolve::is_builtin_function`), not an
304 /// index symbol — there is no [`DefinitionId`] to record. `xs.len()`,
305 /// `inventory.push(sword)`, `a.sort_by(c)` all land here.
306 PreludeDesugar {
307 /// The receiver's inferred type.
308 receiver: Ty,
309 /// The prelude function's name, as written.
310 name: String,
311 /// Issue #1919: statically-checkable argument-domain mismatches
312 /// between the desugared call `name(recv, args)` and the verb's
313 /// own container-projected domain (the receiver's element/key/
314 /// value type — a prelude verb has no [`DefinitionId`] and so no
315 /// declared param list to compare against), computed
316 /// unconditionally alongside this verdict — see
317 /// [`UfcsVisitor::check_ufcs_prelude_arg_types`]'s own doc.
318 /// Reported only by strict mode ([`check_strict`], `E063`), the
319 /// same code [`Self::FreeFnDesugar`]'s own `arg_mismatches` uses.
320 arg_mismatches: Vec<UfcsArgMismatch>,
321 },
322}
323
324/// One statically-checkable argument-type mismatch at a UFCS-desugared free
325/// function call (`recv.name(args)` → `name(recv, args)`) — issue #1881,
326/// the UFCS sibling of `infer::DirectCallArgMismatch` (#1864/PR #1875,
327/// direct calls) and `infer::TypedAssignMismatch` (#1877/PR #1899,
328/// declaration initializers and assignments). Reported by [`check_strict`]
329/// as `E063`, the same code the other two siblings use — no new code minted
330/// for this position (docs/t1c-spec.md §8's "existing TM-3 machinery"
331/// posture, extended here rather than a parallel checker).
332#[derive(Debug, Clone, PartialEq, Eq)]
333pub struct UfcsArgMismatch {
334 /// The mismatched argument's 0-based position in the **desugared** call
335 /// `name(recv, args)` — `0` names the receiver itself (the desugar's
336 /// first positional slot); `i` for `i >= 1` names the `(i - 1)`-th
337 /// *written* argument. Matches the "receiver counts as the first
338 /// argument" convention this call site's own arity-mismatch diagnostic
339 /// already uses (see [`UfcsVisitor::try_free_fn_desugar`]).
340 ///
341 /// **Exception (issue #1918):** a [`UfcsVerdict::FieldCall`]'s own
342 /// `arg_mismatches` does not follow this convention — a field call has
343 /// no receiver-prepending desugar, so `index` there is 0-based over the
344 /// *written* arguments only (`0` names the first written argument, not
345 /// the receiver); see that variant's own field doc.
346 pub index: usize,
347 /// The desugared target's declared parameter type at `index`.
348 pub expected: Ty,
349 /// The receiver's (`index == 0`) or written argument's statically
350 /// classified type.
351 pub found: Ty,
352}
353
354/// A [`UfcsVerdict::FieldCall`]'s own arity fact (issue #1918) — a call
355/// through a struct's fn-typed field expects/supplies a plain count, not a
356/// per-argument type, so this is a separate fact from [`UfcsArgMismatch`]
357/// rather than a shoehorned entry in that list. See that verdict's own
358/// `arity_mismatch` field doc for the full rationale (why this is checked
359/// at all, and why it's strict-mode-only).
360#[derive(Debug, Clone, Copy, PartialEq, Eq)]
361pub struct UfcsArityMismatch {
362 /// The field's declared `fn(T…): R` row's own parameter count.
363 pub expected: usize,
364 /// The call site's own written argument count.
365 pub got: usize,
366}
367
368/// Every UFCS call site's verdict for one project.
369pub type UfcsTable = SideTable<UfcsVerdict>;
370
371/// Translate a [`UfcsTable`] into `brink-ir`'s own lowering-facing mirror
372/// (`brink_ir::lir::UfcsLookup`/`UfcsVerdict`) — issue #1506's one
373/// conversion point, so the `UfcsVerdict` → `brink_ir::lir::UfcsVerdict`
374/// mapping lives in exactly one place rather than once per caller.
375/// `brink-ir` sits below this crate in the crate graph (this crate depends
376/// on `brink-ir`, never the reverse), so it cannot provide this itself —
377/// see `brink_ir::lir::UfcsVerdict`'s own doc. Every LIR-lowering caller
378/// shares this: `brink-db`'s `ufcs_resolution_query` (the production path)
379/// and [`assemble_analyzer_tables`](crate::assemble_analyzer_tables) — the
380/// salsa-free path used by `brink-test-harness`
381/// (`corpus::compile_and_explore_from_brink_native`) and any other caller
382/// with no salsa layer of its own to memoize the table in.
383#[must_use]
384pub fn to_lir_lookup(table: &UfcsTable) -> brink_ir::lir::UfcsLookup {
385 let entries = table
386 .iter()
387 .map(|(key, verdict)| {
388 let range = TextRange::new(key.range.0.into(), key.range.1.into());
389 let mirrored = match verdict {
390 UfcsVerdict::FieldCall { .. } => brink_ir::lir::UfcsVerdict::FieldCall,
391 UfcsVerdict::FreeFnAutoRef { target, .. } => {
392 brink_ir::lir::UfcsVerdict::FreeFnAutoRef { target: *target }
393 }
394 UfcsVerdict::FreeFnDesugar { target, .. } => {
395 brink_ir::lir::UfcsVerdict::FreeFnDesugar { target: *target }
396 }
397 UfcsVerdict::PreludeDesugar { name, .. } => {
398 brink_ir::lir::UfcsVerdict::PreludeDesugar { name: name.clone() }
399 }
400 };
401 (key.file, range, mirrored)
402 })
403 .collect();
404 brink_ir::lir::UfcsLookup::from_entries(entries)
405}
406
407// ─── The pass ────────────────────────────────────────────────────────
408
409/// Resolve every UFCS-shaped call in the project, returning the verdict
410/// side table plus the diagnostics the four outcomes above produce.
411///
412/// `inference` supplies the receiver types (the pass is type-directed by
413/// construction); `resolutions` identifies which dotted callee paths are
414/// UFCS-shaped at all — a path already resolving to a knot/stitch/external
415/// is an ordinary qualified call and is left completely alone.
416///
417/// Callers gate this on [`project_has_ufcs_call`] so a project without a
418/// single dotted-callee call never pays for whole-project inference on this
419/// pass's account.
420///
421/// **Issue #2096** (the `ufcs.rs` half of #1774's re-verification
422/// remainder — `comparator_contract.rs`'s own copy of the same gap was
423/// fixed by #2085): this used to drive [`UfcsVisitor`] with plain
424/// [`visit::visit`], which never reaches a file-level `VAR`/`CONST`
425/// initializer — so a UFCS-shaped call inside a decl-default lambda's own
426/// body (`const callGreet = |g| g.greet(3)`, legal since #1774's ruling)
427/// was never visited by this pass at all, and fell through to LIR
428/// lowering's defensive `E144` fallback (`brink_ir::lir::lower::expr`'s own
429/// doc). **The shared-visitor question (issue #1571/#2098), re-asked for
430/// this pass's own shape**: unlike `comparator_contract`'s hand-rolled
431/// `collect_sites`/`collect_expr` walk (which is not `HirVisitor`-driven at
432/// all, and so could not adopt the shared entry point without a larger
433/// refactor), [`UfcsVisitor`] already *is* a [`HirVisitor`] driven by
434/// `visit::visit` — the exact shape [`visit::visit_with_decl_initializers`]
435/// was built to extend. Switching costs one line and needs no new
436/// `enter_var_decl`/`enter_const_decl` hooks: `current_knot_name`/
437/// `knot_body`/`stitch_body` are already reset to `None` by every
438/// `exit_knot`/`exit_stitch`, and `lambda_locals` is empty once every lambda
439/// pushed during the block-tree walk has been popped — so by the time the
440/// walk reaches the file-level declarations (which
441/// `visit_with_decl_initializers` visits *after* the block tree), this
442/// visitor's state is already exactly what it was before any knot ran, the
443/// same "no state needs resetting" case `structs::check`'s own #2098 switch
444/// documents. See `structs::check`'s identical switch for the precedent.
445#[must_use]
446pub fn resolve(
447 files: &[(FileId, &HirFile)],
448 index: &SymbolIndex,
449 resolutions: &ResolutionMap,
450 inference: &InferenceResult,
451) -> (UfcsTable, Vec<Diagnostic>) {
452 let shapes = declared_shapes(files, index);
453 let globals = crate::infer::collect_globals(files, index, None);
454 let mut table = UfcsTable::new();
455 let mut diagnostics = Vec::new();
456
457 for &(file, hir) in files {
458 let resolution_by_range = resolution_index(resolutions, file);
459 let scope = ImportScope::new(hir.module.as_ref().map(|m| m.name.clone()), &hir.imports);
460 let mut v = UfcsVisitor {
461 file,
462 index,
463 scope: &scope,
464 shapes: &shapes,
465 globals: &globals,
466 bodies: &inference.bodies,
467 signatures: &inference.signatures,
468 resolution_by_range: &resolution_by_range,
469 current_knot_name: None,
470 knot_body: None,
471 stitch_body: None,
472 lambda_locals: Vec::new(),
473 table: &mut table,
474 diagnostics: &mut diagnostics,
475 };
476 visit::visit_with_decl_initializers(hir, &mut v);
477 }
478
479 (table, diagnostics)
480}
481
482/// The **strict-mode-only** diagnostics that fall out of the verdict table:
483///
484/// - Issue #1540 (second symptom): a typed check keyed on an intrinsic's
485/// receiver must see the UFCS spelling of that intrinsic too.
486///
487/// `infer::body::infer_call` deliberately branches away for a
488/// multi-segment callee *before* `infer_intrinsic` runs (a UFCS receiver
489/// is not the thing being called, so classifying it as a
490/// call-through-a-value would be a false `E066` on every legal method
491/// call — see that function's own note). Issue #1909 later gave that
492/// branch a result type for the *free-function* desugar
493/// (`infer::body::InferPass::infer_ufcs_free_fn_result`), but
494/// deliberately not for a prelude verb, precisely because routing one
495/// through `infer_intrinsic` would record the `array_remove_calls` fact
496/// below a second time and double-report this very `E149`. The
497/// consequence stands: `arr.remove(0)` records none of the facts
498/// `remove(arr, 0)` records, so
499/// every intrinsic-receiver diagnostic silently stopped at the free-call
500/// spelling. This pass is where the UFCS spelling gets them back: the
501/// verdict table already carries the receiver's resolved `Ty` next to the
502/// verb's name, which is exactly the `(receiver type, verb)` pair those
503/// checks key on — no second inference, and no `TypePolicy` threaded into
504/// [`resolve`] (which stays policy-independent, as LIR lowering and the
505/// IDE need it to be).
506///
507/// - Issue #1881: a `FreeFnDesugar`/`FreeFnAutoRef` verdict's own
508/// `arg_mismatches` (computed unconditionally alongside the verdict by
509/// [`UfcsVisitor::try_free_fn_desugar`]) — the UFCS sibling of
510/// `strict::check_direct_call_args` (#1864/PR #1875) and
511/// `strict::check_typed_assign_mismatches` (#1877/PR #1899): a UFCS
512/// receiver resolves to a *value*, so `InferenceResult::signatures` has
513/// no entry for it the way a direct call's callee does, which is exactly
514/// why this class of mismatch couldn't be checked by extending either of
515/// those two passes — the resolved free-function *target*'s signature is
516/// only ever available here, where this pass has already resolved it.
517///
518/// - Issue #1919: a `PreludeDesugar` verdict's own `arg_mismatches`
519/// ([`UfcsVisitor::check_ufcs_prelude_arg_types`]) — the prelude sibling
520/// of the `FreeFnDesugar` bullet above, for the collection verbs whose
521/// domain is a plain container projection (`xs.push(v)`, `m.get(k)`, and
522/// the rest of that family). `remove`'s array leg stays the hand-written
523/// `E149` check just below rather than folding into this fact: the two
524/// are disjoint diagnostic families keyed on the same `(receiver, name)`
525/// pair, not a double-report risk.
526///
527/// - Issue #1918: a `FieldCall` verdict's own `arity_mismatch`/
528/// `arg_mismatches` ([`UfcsVisitor::check_field_call_args`]) — left
529/// uncovered by #1881/PR #1914, which deliberately scoped to the
530/// `FreeFnDesugar`/`FreeFnAutoRef` bullet above and flagged this gap in
531/// review rather than filing it (issue comment on #1881). Structurally
532/// `strict::check_value_calls`'s T1c "call through a function value"
533/// domain, reached via field access instead of a bare name — a call
534/// through the field's `Ty::Fn` value directly, with no receiver-
535/// prepending desugar, so it gets its own arity fact
536/// ([`UfcsArityMismatch`]) rather than folding into a per-argument
537/// `UfcsArgMismatch` at index `0`.
538///
539/// Strict-mode-only **by convention, not by construction**, exactly like
540/// `coalesce::resolve`'s `E066` half: production reaches this only from
541/// `strict::check`, after `strict::config_error` has confirmed
542/// `types = strict` + `dialect = brink`. A caller that surfaces these
543/// without that gate would emit strict-only codes under `types = gradual`.
544///
545/// Gated on [`project_has_ufcs_call`] internally so a project with no
546/// dotted-callee call anywhere pays nothing — the same laziness
547/// `whole_project_diagnostics` applies to [`resolve`]'s own diagnostics.
548#[must_use]
549pub fn check_strict(
550 files: &[(FileId, &HirFile)],
551 index: &SymbolIndex,
552 resolutions: &ResolutionMap,
553 inference: &InferenceResult,
554) -> Vec<Diagnostic> {
555 if !files.iter().any(|&(_, hir)| project_has_ufcs_call(hir)) {
556 return Vec::new();
557 }
558 // The unconditional `E140`–`E144` half is discarded here: it is already
559 // reported by `whole_project_diagnostics`' own call to `resolve`, and
560 // double-reporting it under strict would be a regression.
561 let (table, _unconditional) = resolve(files, index, resolutions, inference);
562 table
563 .iter()
564 .flat_map(|(key, verdict)| strict_verdict_diagnostics(key, verdict))
565 .collect()
566}
567
568/// One verdict's strict-mode diagnostics, if it has any.
569///
570/// `E149` (issue #1540) — `remove` went map-only in issue #1484 with no
571/// compatibility shim, so an array receiver means the site wants
572/// `remove_at`. The free-call spelling of this exact check lives in
573/// `strict::check_array_remove_calls`, reading the fact
574/// `infer::body`'s `remove` arm records; the two spellings must agree, so
575/// the receiver test here (`Ty::Array`) is deliberately the same one.
576///
577/// `E063` (issue #1881, widened to `PreludeDesugar` by issue #1919, and to
578/// `FieldCall` by issue #1918) — every recorded [`UfcsArgMismatch`] on a
579/// `FreeFnDesugar`/`FreeFnAutoRef`/`PreludeDesugar`/`FieldCall` verdict,
580/// reported the same way `strict::check_direct_call_args` reports
581/// `DirectCallArgMismatch` — plus, for `FieldCall` alone, its own
582/// [`UfcsArityMismatch`] (that verdict has no receiver-prepending desugar to
583/// fold an arity fact into `UfcsArgMismatch`'s index-`0` slot the way the
584/// other three verdicts do), phrased identically to
585/// `strict::check_value_calls`'s own `ValueCallKind::ArityMismatch` — the
586/// T1c "call through a value" domain this verdict structurally is.
587///
588/// Every future collection-typed check that keys on `(receiver type, verb)`
589/// belongs in this match rather than in a parallel walk — that is the point
590/// of routing through the verdict table at all.
591fn strict_verdict_diagnostics(key: NodeKey, verdict: &UfcsVerdict) -> Vec<Diagnostic> {
592 match verdict {
593 UfcsVerdict::PreludeDesugar {
594 receiver,
595 name,
596 arg_mismatches,
597 } => {
598 let mut out: Vec<Diagnostic> = arg_mismatches
599 .iter()
600 .map(|mismatch| ufcs_arg_mismatch_diagnostic(key, name, mismatch))
601 .collect();
602 if let ("remove", Ty::Array(_)) = (name.as_str(), receiver) {
603 out.push(Diagnostic {
604 file: key.file,
605 range: TextRange::new(key.range.0.into(), key.range.1.into()),
606 message: DiagnosticCode::E149.title().to_owned(),
607 code: DiagnosticCode::E149,
608 });
609 }
610 out
611 }
612 UfcsVerdict::FreeFnDesugar {
613 name,
614 arg_mismatches,
615 ..
616 }
617 | UfcsVerdict::FreeFnAutoRef {
618 name,
619 arg_mismatches,
620 ..
621 } => arg_mismatches
622 .iter()
623 .map(|mismatch| ufcs_arg_mismatch_diagnostic(key, name, mismatch))
624 .collect(),
625 UfcsVerdict::FieldCall {
626 field,
627 arity_mismatch,
628 arg_mismatches,
629 ..
630 } => {
631 let mut out: Vec<Diagnostic> = Vec::new();
632 if let Some(arity) = arity_mismatch {
633 out.push(field_call_arity_diagnostic(key, field, *arity));
634 }
635 out.extend(
636 arg_mismatches
637 .iter()
638 .map(|mismatch| field_call_arg_mismatch_diagnostic(key, field, mismatch)),
639 );
640 out
641 }
642 }
643}
644
645/// A [`UfcsVerdict::FieldCall`]'s own [`UfcsArgMismatch`] as a diagnostic
646/// — issue #1918. Like [`field_call_arity_diagnostic`] just below, the
647/// wording matches `strict::check_value_calls`'s own
648/// `ValueCallKind::ArgMismatch` phrasing exactly ("call **through**", the
649/// T1c domain this verdict structurally is) — deliberately NOT
650/// [`ufcs_arg_mismatch_diagnostic`]'s desugared-call "call to" phrasing,
651/// so both halves of a `FieldCall` verdict (arity and argument type) speak
652/// with one voice. `mismatch.index` here is 0-based over the *written*
653/// arguments (no receiver prepend — see [`UfcsArgMismatch::index`]'s
654/// Exception paragraph), so `+ 1` yields the same 1-based "argument N"
655/// numbering `check_value_calls` reports.
656fn field_call_arg_mismatch_diagnostic(
657 key: NodeKey,
658 field: &str,
659 mismatch: &UfcsArgMismatch,
660) -> Diagnostic {
661 Diagnostic {
662 file: key.file,
663 range: TextRange::new(key.range.0.into(), key.range.1.into()),
664 message: format!(
665 "argument {} of call through `{field}` has type `{}` but its known type expects `{}`",
666 mismatch.index + 1,
667 mismatch.found.display(),
668 mismatch.expected.display(),
669 ),
670 code: DiagnosticCode::E063,
671 }
672}
673
674/// A [`UfcsVerdict::FieldCall`]'s own [`UfcsArityMismatch`] as a diagnostic
675/// — issue #1918. The message wording matches
676/// `strict::check_value_calls`'s own `ValueCallKind::ArityMismatch`
677/// phrasing exactly (the T1c "call through a value" sibling this verdict
678/// structurally is), naming the field rather than a bare callee name.
679fn field_call_arity_diagnostic(
680 key: NodeKey,
681 field: &str,
682 mismatch: UfcsArityMismatch,
683) -> Diagnostic {
684 Diagnostic {
685 file: key.file,
686 range: TextRange::new(key.range.0.into(), key.range.1.into()),
687 message: format!(
688 "call through `{field}` supplies {got} argument(s) but its known type expects \
689 {expected}",
690 got = mismatch.got,
691 expected = mismatch.expected,
692 ),
693 code: DiagnosticCode::E063,
694 }
695}
696
697/// One [`UfcsArgMismatch`] as a diagnostic — the message wording matches
698/// `strict::check_direct_call_args`'s own `E063` phrasing exactly (the
699/// direct-call sibling this parallels), just against the *desugared* call's
700/// own argument numbering (`index` `0` is the receiver).
701fn ufcs_arg_mismatch_diagnostic(
702 key: NodeKey,
703 name: &str,
704 mismatch: &UfcsArgMismatch,
705) -> Diagnostic {
706 Diagnostic {
707 file: key.file,
708 range: TextRange::new(key.range.0.into(), key.range.1.into()),
709 message: format!(
710 "argument {} of call to `{name}` has type `{}` but its known type expects `{}`",
711 mismatch.index + 1,
712 mismatch.found.display(),
713 mismatch.expected.display(),
714 ),
715 code: DiagnosticCode::E063,
716 }
717}
718
719/// Cheap structural scan: does any call in `hir` have a multi-segment
720/// callee path? The laziness gate for [`resolve`]'s caller — a project
721/// (every ink project, by construction; see the module doc) with no
722/// dotted-callee call never triggers whole-project inference on this pass's
723/// account, mirroring `whole_project_diagnostics`' own `needs_effects`
724/// gate.
725///
726/// Issue #2096: must see a decl-default lambda's own body too, or the
727/// laziness gate itself would skip [`resolve`] entirely for a project whose
728/// only UFCS-shaped call sits inside one — the exact fix [`resolve`]'s own
729/// walk just got would never run. `visit::visit_with_decl_initializers`
730/// (not plain `visit::visit`), same reasoning as that doc.
731#[must_use]
732pub fn project_has_ufcs_call(hir: &HirFile) -> bool {
733 struct Scan {
734 found: bool,
735 }
736 impl HirVisitor for Scan {
737 fn visit_exprs(&self) -> bool {
738 true
739 }
740 fn enter_expr(&mut self, expr: &Expr) {
741 if let Expr::Call(path, _) = expr
742 && path.segments.len() > 1
743 {
744 self.found = true;
745 }
746 }
747 }
748 let mut scan = Scan { found: false };
749 visit::visit_with_decl_initializers(hir, &mut scan);
750 scan.found
751}
752
753/// This file's own reference resolutions, projected to a range-keyed lookup
754/// — mirrors `structs::resolution_index` (a `Path`'s range is only unique
755/// within its own file).
756fn resolution_index(
757 resolutions: &ResolutionMap,
758 file: FileId,
759) -> BTreeMap<(u32, u32), DefinitionId> {
760 resolutions
761 .iter()
762 .filter(|r| r.file == file)
763 .map(|r| ((r.range.start().into(), r.range.end().into()), r.target))
764 .collect()
765}
766
767/// Walks one file's knot/stitch bodies, tracking the enclosing def's
768/// finalized locals so a receiver's head segment can be typed. Structurally
769/// a twin of `structs::ConstructionVisitor` — same `enter_knot`/
770/// `enter_stitch` locals bookkeeping, for the same reason (`BodyTypes` is
771/// keyed by def, `locals` by name).
772struct UfcsVisitor<'a> {
773 file: FileId,
774 index: &'a SymbolIndex,
775 scope: &'a ImportScope,
776 shapes: &'a ShapeTable,
777 globals: &'a BTreeMap<DefinitionId, Ty>,
778 bodies: &'a BTreeMap<DefinitionId, crate::infer::BodyTypes>,
779 /// Every inferable def's finalized signature (issue #1881) — the UFCS
780 /// desugar's *target* (a knot/stitch, never the receiver) has its
781 /// declared param types here, the same firewall-facing projection a
782 /// direct call's `known_sigs` lookup reads. See
783 /// [`UfcsVisitor::try_free_fn_desugar`]'s own argument-type check.
784 signatures: &'a BTreeMap<DefinitionId, InferredSig>,
785 resolution_by_range: &'a BTreeMap<(u32, u32), DefinitionId>,
786 current_knot_name: Option<String>,
787 /// The enclosing knot's own `BodyTypes` (issue #1881 widened this from
788 /// `locals` alone to the whole `BodyTypes`, so [`Self::current_body`]
789 /// can also read back the enclosing def's own recorded
790 /// `ufcs_call_args` — see [`Self::current_locals`] for the `locals`
791 /// projection every earlier call site still wants).
792 knot_body: Option<&'a crate::infer::BodyTypes>,
793 stitch_body: Option<&'a crate::infer::BodyTypes>,
794 /// Issue #2773: a stack of pruned-locals frames, one per currently-open
795 /// lambda literal (innermost last). Mirrors
796 /// `structs::ConstructionVisitor`'s identical field/hook pair exactly —
797 /// see that field's own doc. Composes with the same
798 /// `structs::pruned_locals_for_lambda` helper even though this visitor
799 /// has no `MistypeCtx` of its own — the helper takes the raw
800 /// `index`/`outer_locals` pair, not a `MistypeCtx`, for exactly this
801 /// reason.
802 lambda_locals: Vec<BTreeMap<String, Ty>>,
803 table: &'a mut UfcsTable,
804 diagnostics: &'a mut Vec<Diagnostic>,
805}
806
807impl HirVisitor for UfcsVisitor<'_> {
808 fn visit_exprs(&self) -> bool {
809 true
810 }
811
812 fn enter_knot(&mut self, knot: &Knot) {
813 self.current_knot_name = Some(knot.name.text.clone());
814 self.knot_body =
815 annotations::def_id_for(self.index, self.file, knot.symbol_kind(), &knot.name.text)
816 .and_then(|id| self.bodies.get(&id));
817 }
818
819 fn exit_knot(&mut self, _knot: &Knot) {
820 self.current_knot_name = None;
821 self.knot_body = None;
822 }
823
824 fn enter_stitch(&mut self, stitch: &Stitch) {
825 self.stitch_body = self.current_knot_name.as_ref().and_then(|knot_name| {
826 let qualified = format!("{knot_name}.{}", stitch.name.text);
827 annotations::def_id_for(self.index, self.file, SymbolKind::Stitch, &qualified)
828 .and_then(|id| self.bodies.get(&id))
829 });
830 }
831
832 fn exit_stitch(&mut self, _stitch: &Stitch) {
833 self.stitch_body = None;
834 }
835
836 fn enter_expr(&mut self, expr: &Expr) {
837 if let Expr::Call(path, args) = expr {
838 self.resolve_call(path, args.len());
839 }
840 }
841
842 fn enter_lambda(&mut self, l: &brink_ir::LambdaExpr) {
843 let pruned = crate::structs::pruned_locals_for_lambda(l, self.index, self.current_locals());
844 self.lambda_locals.push(pruned);
845 }
846
847 fn exit_lambda(&mut self, _l: &brink_ir::LambdaExpr) {
848 self.lambda_locals.pop();
849 }
850}
851
852/// The receiver half of one UFCS call site, resolved: everything the two
853/// resolution steps and D5's auto-ref gate need to know about `recv` in
854/// `recv.name(args)`.
855struct Receiver<'a> {
856 /// The definition the head segment resolved to (a param/temp/`VAR`/
857 /// `CONST` — [`UfcsVisitor::value_receiver_def`]).
858 def: DefinitionId,
859 /// Every segment before the final pre-`(` one, head first.
860 segments: &'a [brink_ir::Name],
861 /// The receiver as written (`party.members`), for diagnostics.
862 text: String,
863 /// The receiver's inferred type ([`UfcsVisitor::receiver_ty`]) — never
864 /// `Unknown`/`Conflicted`, which is `E142` one step earlier.
865 ty: Ty,
866}
867
868impl UfcsVisitor<'_> {
869 /// The innermost enclosing def's own `BodyTypes` — a stitch's own body
870 /// wins over its enclosing knot's, exactly like [`Self::current_locals`]
871 /// already preferred.
872 fn current_body(&self) -> Option<&crate::infer::BodyTypes> {
873 self.stitch_body.or(self.knot_body)
874 }
875
876 fn current_locals(&self) -> Option<&BTreeMap<String, Ty>> {
877 self.lambda_locals
878 .last()
879 .or_else(|| self.current_body().map(|b| &b.locals))
880 }
881
882 /// The single call-site decision. Returns without touching the table or
883 /// the diagnostics for any call that is not UFCS-shaped.
884 fn resolve_call(&mut self, path: &HirPath, arg_count: usize) {
885 let Some((method, receiver_segs)) = path.segments.split_last() else {
886 return;
887 };
888 if receiver_segs.is_empty() {
889 // A bare `name(args)` — ordinary direct call, never UFCS.
890 return;
891 }
892 let Some(head_def) = self.value_receiver_def(path) else {
893 // The callee path resolves to a real callable (a
894 // module-qualified free call, an ink `knot.stitch()` visit) —
895 // an ordinary qualified call, not method-call syntax.
896 return;
897 };
898
899 let receiver_text = receiver_segs
900 .iter()
901 .map(|s| s.text.as_str())
902 .collect::<Vec<_>>()
903 .join(".");
904
905 let Some(receiver_ty) = self.receiver_ty(head_def, receiver_segs) else {
906 // D3: no deferral machinery — demand an annotation.
907 self.push(
908 path.range,
909 DiagnosticCode::E142,
910 &format!(
911 "cannot resolve `{receiver_text}.{method}(…)`: the type of `{receiver_text}` \
912 is not known here, so it is undecidable whether `{method}` is one of its \
913 fields — annotate the receiver",
914 method = method.text,
915 ),
916 );
917 return;
918 };
919
920 let receiver = Receiver {
921 def: head_def,
922 segments: receiver_segs,
923 text: receiver_text,
924 ty: receiver_ty,
925 };
926
927 // Step 2 — field access wins outright (D1).
928 if self.try_field_call(path, method, &receiver, arg_count) {
929 return;
930 }
931
932 // Step 3 — a free function in ordinary lexical scope (D4), by value
933 // or auto-ref'd (D5).
934 if self.try_free_fn_desugar(path, method, &receiver, arg_count) {
935 return;
936 }
937
938 // Step 4 — neither; one diagnostic naming both attempts.
939 self.push(
940 path.range,
941 DiagnosticCode::E141,
942 &format!(
943 "cannot resolve `{receiver_text}.{method}(…)`: `{recv_ty}` declares no field \
944 `{method}`, and no function `{method}` is in scope here",
945 method = method.text,
946 receiver_text = receiver.text,
947 recv_ty = receiver.ty.display(),
948 ),
949 );
950 }
951
952 /// Step 2 (D1). Returns `true` when the receiver's type declares a field
953 /// of the called name — the call is settled either way, as a
954 /// [`UfcsVerdict::FieldCall`] or as the `E140` hard error, and never
955 /// falls through to step 3.
956 fn try_field_call(
957 &mut self,
958 path: &HirPath,
959 method: &brink_ir::Name,
960 receiver: &Receiver<'_>,
961 arg_count: usize,
962 ) -> bool {
963 let receiver_ty = &receiver.ty;
964 let receiver_text = &receiver.text;
965 let Ty::Struct(shape_name) = receiver_ty else {
966 return false;
967 };
968 let Some(field_ty) = self
969 .shapes
970 .resolve(shape_name, self.scope, self.index)
971 .and_then(|shape| shape.field_ty(&method.text))
972 else {
973 return false;
974 };
975 if matches!(field_ty, Ty::Fn(..)) {
976 // Issue #1918: this verdict's own argument checking — computed
977 // here (unconditionally, like every other verdict's own
978 // arg-check fields) and carried on the verdict for
979 // `check_strict` to report as `E063`. See
980 // `Self::check_field_call_args`'s own doc.
981 let (arity_mismatch, arg_mismatches) =
982 self.check_field_call_args(path.range, field_ty, arg_count);
983 let verdict = UfcsVerdict::FieldCall {
984 receiver: receiver_ty.clone(),
985 field: method.text.clone(),
986 field_ty: field_ty.clone(),
987 arity_mismatch,
988 arg_mismatches,
989 };
990 self.table
991 .insert(NodeKey::new(self.file, path.range), verdict);
992 } else {
993 let message = format!(
994 "field `{field}` on `{shape_name}` is not callable (its type is `{found}`) — \
995 field access wins over a free function of the same name, so this is never \
996 re-read as `{field}({receiver_text}, …)`",
997 field = method.text,
998 found = field_ty.display(),
999 );
1000 self.push(path.range, DiagnosticCode::E140, &message);
1001 }
1002 true
1003 }
1004
1005 /// Step 3 (D4/D5). Returns `true` when a free function of the called
1006 /// name is in ordinary lexical scope, or the name is a T1b/NS stdlib
1007 /// prelude verb (D4's candidate set is "ordinary lexical scope only
1008 /// (file `use` + prelude)" — `resolve::is_t1b_stdlib_name`/
1009 /// `resolve::is_builtin_function`, e.g. `len`/`push`/`sort_by`, are not
1010 /// index symbols and would otherwise fall through to the `E141` "no
1011 /// function in scope" diagnostic, which is false: `push(xs, v)` compiles
1012 /// today).
1013 ///
1014 /// **D5** picks the desugar's shape from the target's *first declared
1015 /// parameter*: `ref` → [`UfcsVerdict::FreeFnAutoRef`] (the receiver is
1016 /// passed by reference, provided it can be written through — otherwise
1017 /// `E143`, see [`Self::auto_ref_fault`]); anything else → the plain
1018 /// by-value [`UfcsVerdict::FreeFnDesugar`], with no lvalue requirement on
1019 /// the receiver at all. The prelude verbs have no user-declared params to
1020 /// read, so they are always the by-value shape here — the collection
1021 /// mutators' own lvalue discipline is LIR lowering's ruled RMW expansion
1022 /// (`brink_ir::lir::lower::blocks::try_lower_mutator_stmt`), unchanged.
1023 fn try_free_fn_desugar(
1024 &mut self,
1025 path: &HirPath,
1026 method: &brink_ir::Name,
1027 receiver: &Receiver<'_>,
1028 arg_count: usize,
1029 ) -> bool {
1030 let Some(target) = crate::resolve::lookup_by_name(
1031 self.index,
1032 self.scope,
1033 &method.text,
1034 &[SymbolKind::Knot, SymbolKind::External],
1035 ) else {
1036 // No index symbol of this name — the T1b/NS stdlib prelude is
1037 // the other half of D4's candidate set. It has no `DefinitionId`
1038 // (VM-native, resolved at LIR lowering) and so no arity to check
1039 // here.
1040 if crate::resolve::is_t1b_stdlib_name(&method.text)
1041 || crate::resolve::is_builtin_function(&method.text)
1042 {
1043 // Issue #1919: the prelude sibling of the `arg_mismatches`
1044 // computed below for the free-fn desugar — see
1045 // `check_ufcs_prelude_arg_types`'s own doc for why this is
1046 // safe to compute here (D1's field-access-wins check has
1047 // already run by the time this verdict is reached) and why
1048 // it cannot double-report `E149` (a disjoint diagnostic
1049 // family from the domain mismatches this checks).
1050 let arg_mismatches =
1051 self.check_ufcs_prelude_arg_types(path.range, &receiver.ty, &method.text);
1052 let verdict = UfcsVerdict::PreludeDesugar {
1053 receiver: receiver.ty.clone(),
1054 name: method.text.clone(),
1055 arg_mismatches,
1056 };
1057 self.table
1058 .insert(NodeKey::new(self.file, path.range), verdict);
1059 return true;
1060 }
1061 return false;
1062 };
1063 let first_param_is_ref = self
1064 .index
1065 .symbols
1066 .get(&target)
1067 .and_then(|info| info.params.first())
1068 .is_some_and(|p| p.is_ref);
1069 if first_param_is_ref && let Some(cause) = self.auto_ref_fault(receiver) {
1070 let message = format!(
1071 "cannot mutate `{receiver_text}` through `{name}`: `{name}`'s first parameter is \
1072 `ref`, so `{receiver_text}.{name}(…)` auto-refs its receiver (D5) — but {cause}. \
1073 Bind the receiver to a durable cell, or call a by-value function on it",
1074 name = method.text,
1075 receiver_text = receiver.text,
1076 );
1077 self.push(path.range, DiagnosticCode::E143, &message);
1078 return true;
1079 }
1080 // Every other resolved call gets an arity check (`resolve::
1081 // check_arity`) before it is declared resolved; this desugar owes
1082 // the same — the receiver counts as the first argument.
1083 let expected = self
1084 .index
1085 .symbols
1086 .get(&target)
1087 .map(|info| info.params.len());
1088 let actual = arg_count + 1;
1089 if let Some(expected) = expected
1090 && expected != actual
1091 {
1092 let message = format!(
1093 "`{name}` expects {expected} argument(s), got {actual} \
1094 (`{receiver_text}.{name}(…)` desugars to `{name}({receiver_text}, …)`, counting \
1095 the receiver as the first argument)",
1096 name = method.text,
1097 receiver_text = receiver.text,
1098 );
1099 self.push(path.range, DiagnosticCode::E031, &message);
1100 }
1101 // Issue #1881: the argument-type half of this call site's check —
1102 // computed here (unconditionally, like everything else in this
1103 // resolution pass) and carried on the verdict for `check_strict` to
1104 // report as `E063`.
1105 let arg_mismatches = self.check_ufcs_arg_types(path.range, target, receiver);
1106 let verdict = if first_param_is_ref {
1107 UfcsVerdict::FreeFnAutoRef {
1108 receiver: receiver.ty.clone(),
1109 name: method.text.clone(),
1110 target,
1111 arg_mismatches,
1112 }
1113 } else {
1114 UfcsVerdict::FreeFnDesugar {
1115 receiver: receiver.ty.clone(),
1116 name: method.text.clone(),
1117 target,
1118 arg_mismatches,
1119 }
1120 };
1121 self.table
1122 .insert(NodeKey::new(self.file, path.range), verdict);
1123 true
1124 }
1125
1126 /// Issue #1881: the desugared call's argument-type check — `target`'s
1127 /// already-known declared param types (`self.signatures`, this pass's
1128 /// own `InferenceResult::signatures` projection) against the receiver
1129 /// (param `0`) and every *written* argument (param `1..`, read back
1130 /// from `infer::body`'s own recorded [`super::UfcsCallArgs`] fact for
1131 /// this exact call-site `range` — this pass has no expression-type
1132 /// inference of its own, see that struct's own doc for why the split
1133 /// lives here).
1134 ///
1135 /// Mirrors `infer::body::InferPass::infer_call`'s own direct-call check
1136 /// (`assignable`, skipping whenever either side is `Unknown`/
1137 /// `Conflicted`) with one simplification: unlike a direct call's
1138 /// argument, nothing in `infer::body`'s own walk ever `observe`s a
1139 /// UFCS receiver or written argument against `target`'s declared param
1140 /// type — the multi-segment branch in `infer_call` runs no `observe`
1141 /// call at all (issue #1909's `infer_ufcs_free_fn_result` gave that
1142 /// branch a *result type*, deliberately read-only in the receiver and
1143 /// silent on the arguments, exactly so this stays true). So there
1144 /// is no `arg_is_observed_local`-style double-report risk against
1145 /// `E066` to guard against here, unlike `DirectCallArgMismatch`'s own
1146 /// exclusion.
1147 ///
1148 /// **The D5 auto-ref interaction** (both call sites — `first_param_is_ref`
1149 /// or not — share this one check): a `ref` first param's entry in
1150 /// `self.signatures` is *not* a special "reference" `Ty` — `InferredSig`
1151 /// carries no such variant. `body::infer_def_body` derives every
1152 /// param's row (`ref` included) from `pass.locals`, the type the body
1153 /// itself observed that parameter holding — i.e. the **referent's**
1154 /// own type, exactly what `receiver.ty` (a value type, never wrapped)
1155 /// already is. So comparing `receiver.ty` against `sig.params[0]` with
1156 /// the same plain `assignable` this whole function otherwise uses is
1157 /// correct for `FreeFnAutoRef` too, not just `FreeFnDesugar` — no
1158 /// auto-ref-specific unwrapping needed, and none of the false positives
1159 /// #1895 shipped by assuming a receiver's call-site type must literally
1160 /// equal a param's declared spelling.
1161 fn check_ufcs_arg_types(
1162 &self,
1163 range: TextRange,
1164 target: DefinitionId,
1165 receiver: &Receiver<'_>,
1166 ) -> Vec<UfcsArgMismatch> {
1167 let Some(sig) = self.signatures.get(&target) else {
1168 return Vec::new();
1169 };
1170 let empty: Vec<Ty> = Vec::new();
1171 let written: &[Ty] = self
1172 .current_body()
1173 .and_then(|b| b.ufcs_call_args.iter().find(|f| f.range == range))
1174 .map_or(empty.as_slice(), |f| f.args.as_slice());
1175 // Issue #1995/#1920: which positions are declared `ref` lives on
1176 // the symbol index's own `params` — `InferredSig` (`sig`, above)
1177 // carries no `is_ref` bit, exactly like the direct-call sibling in
1178 // `infer::body::InferPass::infer_call`.
1179 let ref_positions = self.index.symbols.get(&target);
1180 let is_ref_param = |i: usize| {
1181 ref_positions
1182 .and_then(|info| info.params.get(i))
1183 .is_some_and(|p| p.is_ref)
1184 };
1185
1186 let mut mismatches = Vec::new();
1187 // Index 0: the receiver itself — `name(recv, args)`'s first
1188 // positional slot, same "receiver counts as the first argument"
1189 // convention this call site's own arity-mismatch diagnostic uses.
1190 // D5's auto-ref desugar makes this slot `ref` whenever
1191 // `first_param_is_ref` selected `FreeFnAutoRef` — the exact write-
1192 // back-through-the-caller's-cell case the invariant check exists
1193 // for.
1194 if let Some(param_ty) = sig.params.first()
1195 && !param_ty.is_unresolved()
1196 && if is_ref_param(0) {
1197 !ref_assignable(param_ty, &receiver.ty)
1198 } else {
1199 !assignable(param_ty, &receiver.ty)
1200 }
1201 {
1202 mismatches.push(UfcsArgMismatch {
1203 index: 0,
1204 expected: param_ty.clone(),
1205 found: receiver.ty.clone(),
1206 });
1207 }
1208 for (i, arg_ty) in written.iter().enumerate() {
1209 if arg_ty.is_unresolved() {
1210 continue;
1211 }
1212 let Some(param_ty) = sig.params.get(i + 1) else {
1213 continue;
1214 };
1215 let ty_disagrees = if is_ref_param(i + 1) {
1216 !ref_assignable(param_ty, arg_ty)
1217 } else {
1218 !assignable(param_ty, arg_ty)
1219 };
1220 if !param_ty.is_unresolved() && ty_disagrees {
1221 mismatches.push(UfcsArgMismatch {
1222 index: i + 1,
1223 expected: param_ty.clone(),
1224 found: arg_ty.clone(),
1225 });
1226 }
1227 }
1228 mismatches
1229 }
1230
1231 /// Issue #1919: `PreludeDesugar`'s own argument-domain check — the T1b/
1232 /// NS-A1 stdlib-verb sibling of [`Self::check_ufcs_arg_types`] (issue
1233 /// #1881, `FreeFnDesugar`/`FreeFnAutoRef`). A prelude verb has no
1234 /// [`DefinitionId`] and no declared parameter list
1235 /// ([`UfcsVerdict::PreludeDesugar`]'s own doc) — its "signature" instead
1236 /// lives as `infer::body::InferPass::infer_intrinsic`'s own per-verb
1237 /// domain rules, keyed off the receiver's *inferred container type*
1238 /// (an array's element type, a map's key/value types).
1239 ///
1240 /// This mirrors that domain knowledge declaratively, for the verbs
1241 /// whose domain is a plain container projection, rather than calling
1242 /// `infer_intrinsic` itself: that method needs a live `Expr` for its
1243 /// receiver-write arms (`push`/`insert`/… call
1244 /// `record_write(args.first())`, resolving an `Expr::Path` back through
1245 /// the `ResolutionMap` — keyed at the *whole call's* own range, so a
1246 /// synthetic receiver-only `Expr` would resolve to nothing there) and
1247 /// mutates the body-inference walk's own `self.locals`/
1248 /// `self.array_remove_calls` state, neither of which this post-hoc,
1249 /// already-fully-resolved verdict pass has access to (see
1250 /// `check_strict`'s own module doc for why the `E149` half stays a
1251 /// hand-written twin rather than an `infer_intrinsic` call, for the
1252 /// same reason).
1253 ///
1254 /// Unlike [`infer::body::InferPass::infer_ufcs_free_fn_result`]
1255 /// (issue #1909), which has to decline a `Ty::Struct` receiver because
1256 /// D1's field-access-wins rule has not run yet at body-inference time,
1257 /// this check runs no such risk: [`Self::resolve_call`] always tries
1258 /// [`Self::try_field_call`] (D1) before [`Self::try_free_fn_desugar`]
1259 /// (D3/D4), so a `PreludeDesugar` verdict is only ever constructed once
1260 /// field access has already lost.
1261 ///
1262 /// `remove`'s *array* leg is deliberately excluded from the domain
1263 /// table below: that shape is `E149` (issue #1540), already reported
1264 /// unconditionally for this exact verdict by
1265 /// [`strict_verdict_diagnostics`] — a disjoint diagnostic family from
1266 /// the domain mismatches this method reports, so there is no risk of
1267 /// double-reporting between the two.
1268 fn check_ufcs_prelude_arg_types(
1269 &self,
1270 range: TextRange,
1271 receiver: &Ty,
1272 name: &str,
1273 ) -> Vec<UfcsArgMismatch> {
1274 let expected: Vec<Ty> = match (name, receiver) {
1275 ("push" | "heap_push" | "index_of" | "contains", Ty::Array(elem)) => {
1276 vec![(**elem).clone()]
1277 }
1278 ("contains" | "get" | "remove", Ty::Map(k, _)) => vec![(**k).clone()],
1279 ("contains_value", Ty::Map(_, v)) => vec![(**v).clone()],
1280 ("insert", Ty::Map(k, v)) => vec![(**k).clone(), (**v).clone()],
1281 _ => return Vec::new(),
1282 };
1283 let empty: Vec<Ty> = Vec::new();
1284 let written: &[Ty] = self
1285 .current_body()
1286 .and_then(|b| b.ufcs_call_args.iter().find(|f| f.range == range))
1287 .map_or(empty.as_slice(), |f| f.args.as_slice());
1288
1289 let mut mismatches = Vec::new();
1290 for (i, expected_ty) in expected.iter().enumerate() {
1291 let Some(arg_ty) = written.get(i) else {
1292 continue;
1293 };
1294 if arg_ty.is_unresolved() {
1295 continue;
1296 }
1297 if !assignable(expected_ty, arg_ty) {
1298 mismatches.push(UfcsArgMismatch {
1299 index: i + 1,
1300 expected: expected_ty.clone(),
1301 found: arg_ty.clone(),
1302 });
1303 }
1304 }
1305 mismatches
1306 }
1307
1308 /// Issue #1918: `FieldCall`'s own argument checking — structurally
1309 /// `strict::check_value_calls`'s T1c "call through a function value"
1310 /// domain (the issue's own framing: `recv.name(args)` resolving through
1311 /// a struct's fn-typed field *is* a call through a value, just reached
1312 /// via field access instead of a bare name), reached here rather than
1313 /// through that pass because a UFCS receiver is deliberately invisible
1314 /// to `infer::body::infer_call`'s own T1c branch (see
1315 /// [`Self::check_ufcs_arg_types`]'s own doc for why — the same reason
1316 /// applies here) — this pass has already resolved the field's own
1317 /// `Ty::Fn` row by the time a `FieldCall` verdict is being built, which
1318 /// `infer::body` never does for a multi-segment callee.
1319 ///
1320 /// **No receiver-prepending desugar, unlike [`Self::check_ufcs_arg_types`]
1321 /// (`FreeFnDesugar`/`FreeFnAutoRef`).** Those two desugar
1322 /// `recv.name(args)` into `name(recv, args)`, so the receiver becomes
1323 /// the desugared call's own first argument and gets checked at index
1324 /// `0`. A field call has no such rewrite: `npc.on_greet(3)` calls the
1325 /// field's own `fn(...)` value directly with the *written* arguments
1326 /// only (`brink_ir::lir::lower::expr::lower_ufcs_call`'s `FieldCall`
1327 /// arm lowers straight to `lir::ExprKind::CallValue { callee, args }`, no
1328 /// synthetic receiver argument) — the receiver's own type already did
1329 /// its only job selecting this field via `try_field_call`'s
1330 /// `Ty::Struct` match, so it is never checked as an argument here. Every
1331 /// [`UfcsArgMismatch`] this returns is therefore `index`-0-based over
1332 /// the written arguments alone, matching
1333 /// `strict::check_value_calls`'s own `ValueCallKind::ArgMismatch`
1334 /// convention rather than [`UfcsArgMismatch::index`]'s "receiver counts
1335 /// as 0" default.
1336 ///
1337 /// **The arity half reads `arg_count`** — the call site's own
1338 /// AST-derived written-argument count (`resolve_call`'s own
1339 /// `args.len()`), always available — rather than `current_body()`'s
1340 /// best-effort `ufcs_call_args` projection, exactly like
1341 /// [`Self::try_free_fn_desugar`]'s own `E031` arity check does for the
1342 /// same reason: a missing `BodyTypes` (global-initializer position, see
1343 /// [`Self::check_ufcs_arg_types`]'s own doc) must degrade only the
1344 /// per-argument *type* half, never arity — an arity fact this cheap to
1345 /// derive structurally has no excuse to go missing alongside a body
1346 /// lookup failure it doesn't actually depend on.
1347 fn check_field_call_args(
1348 &self,
1349 range: TextRange,
1350 field_ty: &Ty,
1351 arg_count: usize,
1352 ) -> (Option<UfcsArityMismatch>, Vec<UfcsArgMismatch>) {
1353 let Ty::Fn(params, _ret, _) = field_ty else {
1354 // `try_field_call` only ever calls this once `field_ty` has
1355 // already matched `Ty::Fn(..)` — kept defensive (no panic
1356 // outside a test helper; house rule) rather than assuming the
1357 // caller's own invariant holds.
1358 return (None, Vec::new());
1359 };
1360 let arity_mismatch = (arg_count != params.len()).then_some(UfcsArityMismatch {
1361 expected: params.len(),
1362 got: arg_count,
1363 });
1364
1365 let empty: Vec<Ty> = Vec::new();
1366 let written: &[Ty] = self
1367 .current_body()
1368 .and_then(|b| b.ufcs_call_args.iter().find(|f| f.range == range))
1369 .map_or(empty.as_slice(), |f| f.args.as_slice());
1370
1371 let mut mismatches = Vec::new();
1372 for (i, param_ty) in params.iter().enumerate() {
1373 let Some(arg_ty) = written.get(i) else {
1374 continue;
1375 };
1376 if arg_ty.is_unresolved() || param_ty.is_unresolved() {
1377 continue;
1378 }
1379 if !assignable(param_ty, arg_ty) {
1380 mismatches.push(UfcsArgMismatch {
1381 index: i,
1382 expected: param_ty.clone(),
1383 found: arg_ty.clone(),
1384 });
1385 }
1386 }
1387 (arity_mismatch, mismatches)
1388 }
1389
1390 /// **D5's receiver gate.** `Some(cause)` when auto-ref cannot write
1391 /// through this receiver, phrased as the tail of the `E143` message;
1392 /// `None` when it can.
1393 ///
1394 /// The desugar rides the T1e ref-argument machinery verbatim
1395 /// (`brink_ir::lir::lower::expr::lower_call_args`), so it inherits that
1396 /// machinery's own rules rather than inventing a second set:
1397 ///
1398 /// - A **bare** receiver (`gold.bump(1)`) binds like any unmarked
1399 /// ref-argument: a frame slot (param/temp) or a global `VAR` both work
1400 /// — `lower_ref_path_call_arg`'s `RefTemp`/`RefGlobal` pair.
1401 /// - A **projection off a durable cell** (`party.leader.heal(5)` where
1402 /// `party` is a `VAR`) becomes a real `lir::CallArg::RefProjection`,
1403 /// whose root must be durable (`docs/t1e-spec.md` §2, the `E080` rule
1404 /// `ref_projection::check_durable_root` enforces for the explicitly
1405 /// spelled form) — that requirement is unchanged by this gate.
1406 /// - A **projection off a frame-local** (`g.hp.heal(5)` where `g` is a
1407 /// `let`/param) is legal too, **RULED 2026-07-27** (issue #1531,
1408 /// `docs/decision-log.md`): a frame-local cell is a valid projection
1409 /// root, and the mutation needs no effect row because it is
1410 /// unobservable outside the frame. `RefProjection`'s own root stays
1411 /// durable-only (`docs/format-v4-rfc.md` §1), so LIR lowering does not
1412 /// reuse that machinery for this case — it splices a read/call/
1413 /// write-back RMW sequence instead (`brink_ir::lir::lower::blocks::
1414 /// try_lower_frame_local_auto_ref_stmt`), the same discipline plain
1415 /// assignment (`g.hp = 5`) already uses. That lowering only has a
1416 /// statement-shaped expansion, so it covers a **single field level**
1417 /// only — the same boundary `try_lower_field_assignment` draws
1418 /// (`E074` for a deeper chain); this gate mirrors that boundary by
1419 /// only clearing a two-segment receiver (root + one field).
1420 /// - A `CONST` is never writable at any depth.
1421 ///
1422 /// The ruled rvalue receivers (`[1,2].push(3)`, `a.sorted().push(x)` —
1423 /// "mutating a temporary loses the mutation") reach this gate as soon as
1424 /// they are spellable: today's native grammar admits only a dotted path
1425 /// as a call's callee (`brink-syntax-native`'s `parser::expr::
1426 /// path_or_call`), so a literal or a call cannot yet sit in receiver
1427 /// position at all.
1428 fn auto_ref_fault(&self, receiver: &Receiver<'_>) -> Option<String> {
1429 let head = receiver.segments.first().map_or("", |s| s.text.as_str());
1430 // A frame-local projection root is legal (issue #1531) only one
1431 // field level deep — `head.field`, i.e. exactly two receiver
1432 // segments. Anything deeper has no lowering (LIR's RMW expansion is
1433 // single-level, matching `try_lower_field_assignment`'s own `E074`
1434 // boundary), so it still faults here.
1435 let frame_local = || {
1436 (receiver.segments.len() > 2).then(|| {
1437 format!(
1438 "`{head}` is a temp/param — a frame-local projection can only reach one \
1439 field level (`{head}.field`); this receiver goes deeper than that"
1440 )
1441 })
1442 };
1443 match self.index.symbols.get(&receiver.def) {
1444 Some(info) => match info.kind {
1445 SymbolKind::Variable => None,
1446 SymbolKind::Constant => Some(format!("`{head}` is a CONST, not a mutable cell")),
1447 SymbolKind::Param | SymbolKind::Temp => frame_local(),
1448 // `value_receiver_def` admits no other kind as a receiver.
1449 _ => Some(format!(
1450 "`{head}` is not a value that can be written through"
1451 )),
1452 },
1453 // Absent from `brink-db`'s narrowed index projection: a local
1454 // temp/param, exactly as `value_receiver_def`/`head_ty` already
1455 // treat it (and `ref_projection::check_durable_root`'s own
1456 // `LocalVar` fallback).
1457 None => frame_local(),
1458 }
1459 }
1460
1461 /// The resolved definition of `path`'s head when `path` is a
1462 /// *method-call-shaped* callee: the resolver recorded the head value (a
1463 /// param/temp/VAR/CONST) as the callee's target rather than a callable
1464 /// definition. `None` for an ordinary qualified call (a module-qualified
1465 /// free call, an ink `knot.stitch()` visit).
1466 ///
1467 /// This is the mirror of `resolve::resolve_function`'s own UFCS-shaped
1468 /// fallback — the two must agree, or a call would either be diagnosed
1469 /// twice or not at all. `resolve_function`'s lookup is project-wide
1470 /// (`resolve::lookup_by_name`), not file-scoped, so this returns the
1471 /// same project-wide [`DefinitionId`] rather than re-deriving one from
1472 /// the head's name alone — [`Self::head_ty`] types it from exactly that
1473 /// id, the same way `structs::resolved_symbol_ty` types any other
1474 /// resolved reference.
1475 fn value_receiver_def(&self, path: &HirPath) -> Option<DefinitionId> {
1476 // `path.range` here is the callee `Path`'s whole span — this lookup
1477 // is one of the four consumers keyed on the call-path
1478 // `ResolvedRef::range` contract (issue #1561); see that field's doc.
1479 let key = (path.range.start().into(), path.range.end().into());
1480 let &target = self.resolution_by_range.get(&key)?;
1481 match self.index.symbols.get(&target) {
1482 Some(info)
1483 if matches!(
1484 info.kind,
1485 SymbolKind::Param
1486 | SymbolKind::Temp
1487 | SymbolKind::Variable
1488 | SymbolKind::Constant
1489 ) =>
1490 {
1491 Some(target)
1492 }
1493 // brink-db's narrowed index projection can strip locals; the
1494 // definition tag still identifies them (mirrors
1495 // `infer::body::infer_call`'s own `is_value_callee`).
1496 None if target.tag() == brink_format::DefinitionTag::LocalVar => Some(target),
1497 Some(_) | None => None,
1498 }
1499 }
1500
1501 /// The receiver's type: the head segment's own type (typed from
1502 /// `head_def`, the definition `resolve::resolve_function` actually
1503 /// bound the head to), then each further segment walked through the
1504 /// declared shape table. `None` whenever any step lands on an unknown or
1505 /// conflicted type — the D3 case.
1506 fn receiver_ty(&self, head_def: DefinitionId, segments: &[brink_ir::Name]) -> Option<Ty> {
1507 let (head, rest) = segments.split_first()?;
1508 let mut ty = self.head_ty(head_def, head)?;
1509 for seg in rest {
1510 let Ty::Struct(shape_name) = &ty else {
1511 return None;
1512 };
1513 let field = self
1514 .shapes
1515 .resolve(shape_name, self.scope, self.index)?
1516 .field_ty(&seg.text)?
1517 .clone();
1518 ty = field;
1519 }
1520 (!ty.is_unknown() && ty != Ty::Conflicted).then_some(ty)
1521 }
1522
1523 /// The head segment's type, read from `def` — the *resolved* definition,
1524 /// exactly as `structs::resolved_symbol_ty` reads any other resolved
1525 /// reference: a param/temp reads the enclosing def's finalized local *by
1526 /// name* (`def`'s own name — locals are keyed by name, not id); a global
1527 /// `VAR`/`CONST` reads `infer::collect_globals`'s declaration-derived
1528 /// type *by id*, project-wide, never file-scoped. Dispatching on `def`'s
1529 /// own kind (rather than trying `current_locals()` by `head.text` first,
1530 /// unconditionally) also means a body-local shadowing a same-named
1531 /// global after the call site can never be mistaken for the global the
1532 /// resolver actually bound.
1533 fn head_ty(&self, def: DefinitionId, head: &brink_ir::Name) -> Option<Ty> {
1534 match self.index.symbols.get(&def) {
1535 Some(info) => match info.kind {
1536 SymbolKind::Param | SymbolKind::Temp => {
1537 self.current_locals()?.get(&info.name).cloned()
1538 }
1539 SymbolKind::Variable | SymbolKind::Constant => self.globals.get(&def).cloned(),
1540 _ => None,
1541 },
1542 // brink-db's narrowed index projection can strip locals (see
1543 // `value_receiver_def`'s own fallback); the enclosing body's
1544 // finalized locals are keyed by name and unaffected by that
1545 // projection, so fall back to `head.text`.
1546 None => self.current_locals()?.get(&head.text).cloned(),
1547 }
1548 }
1549
1550 fn push(&mut self, range: TextRange, code: DiagnosticCode, detail: &str) {
1551 self.diagnostics.push(Diagnostic {
1552 file: self.file,
1553 range,
1554 message: format!("{}: {detail}", code.title()),
1555 code,
1556 });
1557 }
1558}