brink_analyzer/structs.rs
1//! TM-4b struct construction-literal semantic checks (docs/typed-mode-spec.md
2//! §6).
3//!
4//! Strict-mode-only (`types = strict`): "missing/extra fields at
5//! construction: compile error (strict) / construction fault (gradual)" —
6//! under `types = gradual` a project never runs [`check`] at all (mirrors
7//! `strict::check`'s own gating), deferring entirely to the runtime fault
8//! PR #664 already built (`RecordGetDyn`'s missing-field fault). Wired into
9//! `strict::check` alongside E065/E066/E067, behind the same
10//! `TypePolicy::Strict` + `dialect = brink` guard `strict::config_error`
11//! already enforces.
12//!
13//! Three checks, each naming the offending field, all strict-only per the
14//! spec's own wording ("missing/extra fields at construction: compile error
15//! (strict) / construction fault (gradual)"):
16//! - **Missing** (`E069`): a declared field with no initializer in the
17//! literal.
18//! - **Extra** (`E070`): an initializer for a field the shape doesn't
19//! declare.
20//! - **Mistyped** (`E071`): an initializer whose *statically
21//! classifiable* type disagrees with the field's declared type.
22//! Literal-shaped initializers (int/float/bool/string/array/map/nested
23//! struct literals) classify from their own shape alone. A variable-,
24//! call-, or index-valued initializer (issue #670) instead consults the
25//! inference substrate already threaded into `strict::check`: a `Path`
26//! resolving to a param/temp reads its finalized type from that def's own
27//! `BodyTypes::locals`; a `Path` resolving to a global `VAR`/`CONST` reads
28//! its declaration-derived type (`infer::collect_globals`, the same
29//! source `infer::body` itself reads through the firewall); a call reads
30//! the resolved callee's `InferredSig::return_ty`; an index expression
31//! recurses into its base's classified type and takes the
32//! array-element/map-value type. Whenever that resolution lands on
33//! `Unknown` or `Conflicted` (unresolved, unannotated, or genuinely
34//! contradictory), the field stays silently unchecked — same "Unknown
35//! never disagrees" spirit as `annotations::mismatches`.
36//!
37//! An unresolved shape name (`E068`, already reported by
38//! `resolve::resolve_struct_ref`) is not re-reported here — a construction
39//! against a shape that doesn't exist has no declared fields to check
40//! against.
41//!
42//! [`check_duplicates`] (`E084`, issue #675) is a fourth, *policy-
43//! independent* check: a construction literal supplying the same field
44//! name more than once is flagged under both `types = gradual` and
45//! `types = strict` — it doesn't need the shape to resolve, so it's wired
46//! into `per_file_diagnostics` unconditionally within a file (no
47//! `TypePolicy` gate) rather than behind `strict::config_error` the way
48//! [`check`] is. Its `dialect`/`is_native` gating is wider than `check`'s
49//! own brink-only block, though: B5 (issue #1464, #1103 cascade ruling
50//! (A)) made `TypeName { … }` construction reach `StructLiteral` from the
51//! native surface (`Point { x: 1 }`) as well as the brink dialect's own
52//! `#{…}` spelling, so the caller runs this under `dialect = Brink ||
53//! is_native` — same reasoning `map_keys::check_duplicate_keys`'s own doc
54//! gives for `E138`.
55
56use std::collections::{BTreeMap, BTreeSet};
57
58use brink_format::DefinitionId;
59use brink_ir::hir::visit::{self, HirVisitor};
60use brink_ir::{
61 AssignOp, BlockStmt, Diagnostic, DiagnosticCode, Expr, FileId, HirFile, Knot, ResolutionMap,
62 Stitch, StructLiteral, SymbolIndex, SymbolKind,
63};
64use rowan::TextRange;
65
66use crate::annotations;
67use crate::infer::{
68 FieldAssignMismatch, InferenceResult, InferredSig, Ty, is_string_numeric_concat,
69};
70use crate::resolve::ImportScope;
71
72/// One declared struct shape: fields in declaration order, name -> declared
73/// type (`Ty::Unknown` if the field's own annotation doesn't resolve —
74/// e.g. an unrecognized type name, already flagged elsewhere by
75/// `annotations::check`'s `E061`).
76///
77/// Originally `pub(crate)` (issue #831) so `ref_projection`'s strict-mode
78/// path-segment check could reuse this exact shape table for `ref
79/// lvalue-path` field segments — "reuse existing machinery"
80/// (docs/t1e-spec.md §6) rather than building a second one. Promoted to a
81/// crate-public API (issue #858) so out-of-crate tooling (e.g. `brink-ide`
82/// struct-field ref-path completion, T1e-3's deferred "path continuations
83/// after a `.`/`[`" item) can query declared shapes without duplicating this
84/// table.
85pub struct ShapeInfo {
86 fields: Vec<(String, Ty)>,
87}
88
89impl ShapeInfo {
90 /// The declared type of `name`, or `None` if the shape has no such
91 /// field.
92 #[must_use]
93 pub fn field_ty(&self, name: &str) -> Option<&Ty> {
94 self.fields.iter().find(|(n, _)| n == name).map(|(_, t)| t)
95 }
96
97 /// Whether the shape declares a field named `name`.
98 #[must_use]
99 pub fn has_field(&self, name: &str) -> bool {
100 self.fields.iter().any(|(n, _)| n == name)
101 }
102}
103
104/// Every declared `STRUCT` shape in the project — a referrer-scoped lookup
105/// table (issue #2241).
106///
107/// A bare struct name is **not** a unique key: the stdlib mount (#2080) lets
108/// a project's own `struct Cue { … }` coexist with a same-named
109/// `struct Cue { … }` a mounted std preset declares (M-2d module
110/// coexistence, `manifest::is_cross_declared_module_collision`) — both are
111/// genuinely distinct `Struct` symbols with distinct `DefinitionId`s, the
112/// same shape `brink_ir::lir::lower::structs::ShapeTable` already handles one
113/// layer down (issue #2238). This table used to be a flat
114/// `BTreeMap<String, ShapeInfo>` populated by plain last-`insert`-wins —
115/// whichever file's declaration was iterated last silently overwrote every
116/// earlier same-named one, with no per-caller scope to break the tie.
117/// [`ShapeTable::resolve`] is the fix: every caller with an [`ImportScope`]
118/// in hand resolves the *right* candidate through the same
119/// `Candidacy`-based module scoping [`crate::resolve::lookup_by_name`]
120/// already applies to every other symbol kind — instead of a global winner
121/// or a second, diverging std-exclusion policy (2026-08-04 peer-root
122/// ruling, `docs/decision-log.md`). [`ShapeTable::get_by_def`] is for
123/// callers that already hold an exact `DefinitionId` (e.g. a construction
124/// literal's shape name, resolved with full module-scope `Candidacy` by
125/// `resolve::resolve_struct_ref` and recorded in the project's
126/// `ResolutionMap` — see [`check`]).
127///
128/// Public (issue #858) so tooling outside `brink-analyzer` can resolve a
129/// `STRUCT`'s declared fields — e.g. offering field-name completions after
130/// `npc.` in a `ref lvalue-path` — without re-deriving the shape table this
131/// crate already builds for its own construction-literal checks
132/// ([`check`]) and `ref`-projection path-segment validation.
133#[must_use]
134pub fn declared_shapes(files: &[(FileId, &HirFile)], index: &SymbolIndex) -> ShapeTable {
135 // No manifest access at this call site (`structs::check` isn't
136 // threaded a `HostManifest` — struct field types aren't in T1d-2's
137 // scope), so `Handle<K>` field types resolve `None` here, same as any
138 // other name `TypeNames` doesn't recognize — consistent with
139 // `annotations::resolve`'s documented "unresolved -> silent" contract.
140 let names = annotations::TypeNames::new(index, None);
141 let mut by_def = BTreeMap::new();
142 for &(file, hir) in files {
143 for s in &hir.structs {
144 // NOT actually an invariant (review finding on #2240/#2258):
145 // `annotations::def_id_for` is exact-file-only, with no
146 // fallback arm at all — unlike `lir::lower::structs`'
147 // `decls::lookup_global`, which at least rescues a surviving
148 // non-std sibling before giving up. So this lookup misses on
149 // *every* true intra-module duplicate this file's own
150 // declaration lost to (`E023` dropped its symbol-index entry),
151 // not only the narrower std-declared-survivor case `E181`
152 // reports one layer down. When it misses, this decl silently
153 // contributes nothing to `by_def` — a fourth, still-undiagnosed
154 // silent-drop site of the exact class `E181` exists to make
155 // loud (see that code's own doc and `build_shape_table`'s),
156 // just with no diagnostic sink wired here yet.
157 let Some(def_id) =
158 annotations::def_id_for(index, file, SymbolKind::Struct, &s.name.text)
159 else {
160 continue;
161 };
162 if by_def.contains_key(&def_id) {
163 continue;
164 }
165 let fields = s
166 .fields
167 .iter()
168 .map(|f| {
169 let ty = annotations::resolve(&f.ty, &names).unwrap_or(Ty::Unknown);
170 (f.name.text.clone(), ty)
171 })
172 .collect();
173 by_def.insert(def_id, ShapeInfo { fields });
174 }
175 }
176 ShapeTable { by_def }
177}
178
179/// [`declared_shapes`]' referrer-scoped lookup table — see that function's
180/// doc for the coexistence story this exists to resolve correctly.
181#[derive(Default)]
182pub struct ShapeTable {
183 /// Every shape by its own symbol-index identity — the canonical store,
184 /// unambiguous by construction.
185 by_def: BTreeMap<DefinitionId, ShapeInfo>,
186}
187
188impl ShapeTable {
189 /// Number of declared shapes in the project — referrer-free, since a
190 /// count needs no disambiguation.
191 #[must_use]
192 pub fn len(&self) -> usize {
193 self.by_def.len()
194 }
195
196 /// Whether the project declares no `STRUCT` shapes at all.
197 #[must_use]
198 pub fn is_empty(&self) -> bool {
199 self.by_def.is_empty()
200 }
201
202 /// Resolve a shape already pinned to an exact `DefinitionId` — no
203 /// referrer ambiguity possible, since the identity was already resolved
204 /// once, correctly, by whatever recorded it (e.g. a construction
205 /// literal's `RefKind::Struct` resolution, `resolve::resolve_struct_ref`).
206 #[must_use]
207 pub fn get_by_def(&self, id: DefinitionId) -> Option<&ShapeInfo> {
208 self.by_def.get(&id)
209 }
210
211 /// Scope-aware lookup (issue #2241, corrected per #2245/#2246's
212 /// peer-root ruling — `docs/decision-log.md`, 2026-08-04): when more
213 /// than one declared `STRUCT` shares `name`, resolve through the same
214 /// `Candidacy`-based module scoping every other symbol kind uses —
215 /// [`crate::resolve::lookup_by_name`], the exact function
216 /// `resolve::resolve_struct_ref` already calls for `SymbolKind::Struct`.
217 /// This used to hand-roll its own `find(info.file == referrer)
218 /// .or_else(find(!is_reserved_root_module))` fallback — a bolt-on std
219 /// gate the ruling calls out by name as one of the five symptom gates
220 /// to unwind, not a second, diverging implementation of the same
221 /// policy. Returns
222 /// `None` when `name` names no declared `STRUCT` at all, or
223 /// [`crate::resolve::lookup_by_name`] itself resolves to none (e.g.
224 /// every candidate sharing the name is std-declared and out of
225 /// `scope`).
226 #[must_use]
227 pub fn resolve(
228 &self,
229 name: &str,
230 scope: &ImportScope,
231 index: &SymbolIndex,
232 ) -> Option<&ShapeInfo> {
233 let def_id = crate::resolve::lookup_by_name(index, scope, name, &[SymbolKind::Struct])?;
234 self.by_def.get(&def_id)
235 }
236}
237
238/// Strict-mode construction checks over every struct literal in the
239/// project. Callers only reach this once `strict::config_error` has
240/// confirmed `types = strict` + `dialect = brink` (mirrors
241/// `strict::check`'s own entry condition).
242///
243/// `inference`/`resolutions` (issue #670): the same whole-project
244/// `InferenceResult`/`ResolutionMap` `strict::check` already computes for
245/// its own escape/mismatch checks — this is what lets the mistyped-field
246/// check (`E071`) classify a variable/call/index-valued initializer instead
247/// of only literal-shaped ones (see the module doc).
248#[must_use]
249pub fn check(
250 files: &[(FileId, &HirFile)],
251 index: &SymbolIndex,
252 inference: &InferenceResult,
253 resolutions: &ResolutionMap,
254) -> Vec<Diagnostic> {
255 let shapes = declared_shapes(files, index);
256 // No manifest access at this call site, same as `declared_shapes` above
257 // — a global's own annotation resolving against `Handle<K>` isn't in
258 // this check's scope any more than a struct field's is.
259 let globals = crate::infer::collect_globals(files, index, None);
260 let mut out = Vec::new();
261 for &(file, hir) in files {
262 let resolution_by_range = resolution_index(resolutions, file);
263 let mut v = ConstructionVisitor {
264 file,
265 shapes: &shapes,
266 index,
267 globals: &globals,
268 signatures: &inference.signatures,
269 bodies: &inference.bodies,
270 resolution_by_range: &resolution_by_range,
271 current_knot_name: None,
272 knot_locals: None,
273 stitch_locals: None,
274 lambda_locals: Vec::new(),
275 diagnostics: &mut out,
276 };
277 // Issue #2098: `ConstructionVisitor::enter_expr` has no state that
278 // needs resetting between the block tree and a file-level
279 // declaration's own initializer (`locals` is already `None` at this
280 // scope) — so the shared entry point covers both in one drive, and
281 // the hand-rolled `check_expr`/`expr_children` mirror of
282 // `visit::visit`'s own descent this used to need is gone.
283 visit::visit_with_decl_initializers(hir, &mut v);
284 }
285 out
286}
287
288// ─── Issue #1900: plain struct-field assignment target checking ──────
289
290/// Strict-mode-only: every [`crate::infer::FieldAssignMismatch`] fact body
291/// inference recorded (`~ p.x = expr`, a dotted assignment target — see that
292/// type's own doc for why the field chain is left unresolved until now),
293/// walked against [`declared_shapes`]/[`ShapeInfo`] to resolve the specific
294/// field's declared type and reported as `E063` — the same code
295/// `strict::check_typed_assign_mismatches` reports for a *bare* assignment
296/// target (issue #1877); this is that check's dotted sibling, split into
297/// its own issue (#1900) because the root's declared type is not the
298/// field's, so the bare-name comparison doesn't apply as-is. Callers only
299/// reach this once `strict::config_error` has confirmed `types = strict` +
300/// `dialect = brink` (mirrors [`check`]'s own entry condition).
301///
302/// Walks `inference.bodies` directly (keyed by `DefinitionId`, itself
303/// `Ord`) rather than re-deriving a per-file `def_ids` list the way
304/// `strict::check_typed_assign_mismatches` does — every fact already
305/// carries its own diagnostic range, so grouping by file first buys nothing
306/// extra here. Correction (issue #1900 review finding): the caller
307/// (`strict::check`) does *not* sort this aggregate — `strict::check` and
308/// `strict_diagnostics` only concatenate each check's output in a fixed
309/// call order, with no sort in either. The only downstream ordering is
310/// `brink_db::queries::mod::partition_diagnostics` grouping by `FileId` for
311/// the salsa query path; the pure `analyze_with_options` path has no
312/// ordering step at all. Iterating `inference.bodies` (`Ord`-keyed by
313/// `DefinitionId`) still makes this function's own output deterministic —
314/// just not because anything downstream re-sorts it.
315#[must_use]
316pub fn check_assignments(
317 files: &[(FileId, &HirFile)],
318 index: &SymbolIndex,
319 inference: &InferenceResult,
320) -> Vec<Diagnostic> {
321 let shapes = declared_shapes(files, index);
322 // Per-file scope, keyed the same way `resolve::resolve` and `ufcs::resolve`
323 // build one per file — `check_field_assign_mismatch` doesn't loop `files`
324 // itself (it's driven by `inference.bodies`, keyed by `DefinitionId`), so
325 // the scope for the fact's own declaring file is looked up here instead.
326 let scopes: BTreeMap<FileId, ImportScope> = files
327 .iter()
328 .map(|&(file, hir)| {
329 let scope = ImportScope::new(hir.module.as_ref().map(|m| m.name.clone()), &hir.imports);
330 (file, scope)
331 })
332 .collect();
333 let mut out = Vec::new();
334 for (def, body) in &inference.bodies {
335 let Some(info) = index.symbols.get(def) else {
336 continue;
337 };
338 let Some(scope) = scopes.get(&info.file) else {
339 continue;
340 };
341 for fact in &body.field_assign_mismatches {
342 check_field_assign_mismatch(fact, info.file, scope, index, &shapes, &mut out);
343 }
344 }
345 out
346}
347
348/// Walk one [`FieldAssignMismatch`]'s field chain from its already-resolved
349/// root type down to the specific field being assigned, comparing the
350/// result against the RHS's own type. Silently unclassifiable (no
351/// diagnostic) whenever the walk hits a non-`Struct` type or an unresolved
352/// shape name (`E068` already covers that separately) — matching
353/// [`check_literal`]'s own "unresolved -> silent" contract for the same
354/// reason: with no resolved shape to check the name against, "Unknown never
355/// disagrees" still holds.
356///
357/// Issue #1944: a field name the *resolved* shape doesn't declare is a
358/// different case — the shape itself is known, so the name can actually be
359/// checked, and now is: `E185`, the plain-assignment-target mirror of
360/// [`check_literal`]'s own `E070` (construction-literal unknown field).
361/// Fired only from inside this already-`Some(shape)` branch — an Unknown/
362/// untyped root never reaches here at all (the loop returns above, on the
363/// first segment, the moment `current` isn't a resolved `Ty::Struct`), so
364/// the "Unknown never disagrees" posture for an *unresolved* receiver is
365/// untouched. A chained target (`o.i.a = v`, 3+ segments) never reaches this
366/// function in the first place — `check_declared_field_assign_target`'s own
367/// `segments.len() == 2` fence means no [`FieldAssignMismatch`] fact is ever
368/// recorded for one; LIR's `try_lower_field_assignment` already rejects it
369/// outright with `E074`.
370///
371/// BLOCKING review finding (issue #1900): the `+=` string-numeric
372/// display-concat carve-out (issue #1911, `body::is_string_numeric_concat`)
373/// applies to a dotted target exactly like it applies to a bare one — `~
374/// v.s += 5` on a `string`-declared field desugars to the identical runtime
375/// `String`/`Int`|`Float` `Add` arm as `~ v.s = v.s + 5` — but body
376/// inference can't decide that carve-out itself: it only ever resolves the
377/// *root's* type (`Ty::Struct("S")`, never `string`) when it records the
378/// fact, well before the field's own declared type is known. So the
379/// carve-out has to be re-applied here, once `current` has been walked all
380/// the way down to the field's actual declared type.
381fn check_field_assign_mismatch(
382 fact: &FieldAssignMismatch,
383 file: FileId,
384 scope: &ImportScope,
385 index: &SymbolIndex,
386 shapes: &ShapeTable,
387 out: &mut Vec<Diagnostic>,
388) {
389 let mut current = fact.root_ty.clone();
390 for segment in &fact.path {
391 let Ty::Struct(shape_name) = ¤t else {
392 return;
393 };
394 let Some(shape) = shapes.resolve(shape_name, scope, index) else {
395 return;
396 };
397 let Some(field_ty) = shape.field_ty(&segment.text) else {
398 // Issue #1944: the receiver's shape resolved, but it declares
399 // no field with this name — the E070 mirror for a plain
400 // assignment target. Stop the walk here (there is no further
401 // field type to compare the RHS against, and continuing would
402 // only risk a confusing second diagnostic on the same target).
403 out.push(Diagnostic {
404 file,
405 range: segment.range,
406 message: format!(
407 "{}: `{}` has no field `{}`",
408 DiagnosticCode::E185.title(),
409 shape_name,
410 segment.text
411 ),
412 code: DiagnosticCode::E185,
413 });
414 return;
415 };
416 current = field_ty.clone();
417 }
418 // BLOCKING review finding (issue #1944, PR #2901): `found` can now reach
419 // here unresolved (an `EXTERNAL` call with no declared return type,
420 // e.g.) since `check_declared_field_assign_target` no longer bails out
421 // on an unresolved RHS before recording the fact — that early return was
422 // exactly what kept E185 (below) unreachable for such an RHS. Guarded
423 // explicitly rather than folded into the `assignable` check that
424 // follows: `assignable(T, Unknown)` is `true` (unify, infer/ty.rs:477),
425 // but `assignable(T, Conflicted)` is not, so relying on `assignable`
426 // alone would start false-firing E063 for an unresolved RHS instead of
427 // staying silent on it.
428 if fact.found.is_unresolved() {
429 return;
430 }
431 if current.is_unresolved() || crate::infer::assignable(¤t, &fact.found) {
432 return;
433 }
434 // Issue #1911's carve-out, re-applied here (BLOCKING review finding,
435 // issue #1900) now that `current` is the field's own resolved declared
436 // type, not the root's — see this function's own doc.
437 if fact.op == AssignOp::Add && is_string_numeric_concat(¤t, &fact.found) {
438 return;
439 }
440 // `path` is never empty by construction (the recording site only ever
441 // records a fact for a multi-segment target — `segments[1..]` is
442 // therefore non-empty), but a defensive `None` here (rather than
443 // `expect`, denied in production code) just silently skips the
444 // diagnostic instead of panicking if that invariant ever changes.
445 let Some(last) = fact.path.last() else {
446 return;
447 };
448 let dotted: Vec<&str> = std::iter::once(fact.root.as_str())
449 .chain(fact.path.iter().map(|n| n.text.as_str()))
450 .collect();
451 out.push(Diagnostic {
452 file,
453 range: last.range,
454 message: format!(
455 "`{}` has type `{}` but its declared type is `{}`",
456 dotted.join("."),
457 fact.found.display(),
458 current.display()
459 ),
460 code: DiagnosticCode::E063,
461 });
462}
463
464/// `TextRange` has no `Ord` impl, so a `Path`/`Call` reference's range keys
465/// this file-local `BTreeMap` as a `(start, end)` `u32` pair — mirrors
466/// `infer::mod`'s and `strict`'s own identically-named helper (each module
467/// owns its own copy rather than centralizing; the codebase's established
468/// convention for this exact utility).
469fn range_key(range: TextRange) -> (u32, u32) {
470 (range.start().into(), range.end().into())
471}
472
473/// This file's own reference resolutions, projected to a range-keyed lookup
474/// — mirrors `strict::resolution_index`, narrowed to one file at a time (a
475/// `Path`'s range is only unique within its own file).
476fn resolution_index(
477 resolutions: &ResolutionMap,
478 file: FileId,
479) -> BTreeMap<(u32, u32), DefinitionId> {
480 resolutions
481 .iter()
482 .filter(|r| r.file == file)
483 .map(|r| (range_key(r.range), r.target))
484 .collect()
485}
486
487/// Everything [`classify_expr_ty`] needs to resolve a non-literal
488/// initializer's type: the project symbol index, declaration-derived
489/// global types, every inferable def's finalized signature (for a
490/// call-valued initializer's return type), this file's range→`DefinitionId`
491/// resolutions, and — when the struct literal sits inside a knot/stitch
492/// body — that def's own finalized `BodyTypes::locals` (`None` at file
493/// scope, where only globals are in play).
494///
495/// `pub(crate)` (issue #983) so `conversions::check`'s own non-literal
496/// `int()`/`float()` argument classification can reuse this exact
497/// inference-substrate plumbing instead of re-deriving it — same "reuse
498/// existing machinery" precedent `ref_projection` follows for
499/// [`ShapeInfo`].
500pub(crate) struct MistypeCtx<'a> {
501 pub(crate) index: &'a SymbolIndex,
502 pub(crate) globals: &'a BTreeMap<DefinitionId, Ty>,
503 pub(crate) signatures: &'a BTreeMap<DefinitionId, InferredSig>,
504 pub(crate) resolution_by_range: &'a BTreeMap<(u32, u32), DefinitionId>,
505 pub(crate) locals: Option<&'a BTreeMap<String, Ty>>,
506}
507
508/// Issue #2773's shared lambda-frame helper: the name set a lambda literal
509/// itself binds — its own param row, plus (for a block body) every name the
510/// block's own statements introduce (`TempDecl`, a `for` loop's var/val, an
511/// `if`/`while` `as` binding, recursed through nested `if`/`while`/`for` via
512/// [`crate::infer::lambda_own_bindings`]) — pruned out of `outer_locals`
513/// (a bare-name-keyed `BodyTypes::locals`-shaped map), with the lambda's own
514/// explicitly `: T`-annotated param types seeded back in under their own
515/// names.
516///
517/// This is the fix for the hazard class issue #2773 tracks: `resolved_symbol_ty`
518/// (below) resolves a `Param`/`Temp` `Path` by **bare name** out of
519/// `ctx.locals`, with no shadowing frame of its own — so a lambda-own
520/// binding that shares a name with a *different-typed* outer local gets
521/// silently attributed the outer binding's type for any expression inside
522/// the lambda's own body. Before this helper existed, every consumer of
523/// [`MistypeCtx::locals`]/`BodyTypes::locals` that reads it from
524/// [`brink_ir::hir::visit::HirVisitor::enter_expr`] — `conversions.rs`,
525/// `coalesce.rs`, this file's own [`ConstructionVisitor`], `ufcs.rs`,
526/// `range_refinement.rs`, `contains_domain.rs` — inherited this hazard
527/// automatically the moment `hir::visit::walk_expr`'s pre-existing
528/// `Expr::Lambda` descent (issue #1685) reached an expression inside a
529/// lambda body, because nothing signaled that a new scope had opened. Every
530/// one of those `HirVisitor` impls now pushes a pruned frame (built by this
531/// function) in [`brink_ir::hir::visit::HirVisitor::enter_lambda`] and pops
532/// it in `exit_lambda`.
533///
534/// `option_conditions.rs`'s condition-position walk (issue #2764/#2768)
535/// composes with this same function instead of keeping its own private copy
536/// — it cannot use the `enter_lambda`/`exit_lambda` hooks directly (its walk
537/// is hand-rolled, not `HirVisitor`-driven, because it needs to distinguish
538/// "this is a condition position" from an arbitrary expression — see that
539/// module's own doc), but the pruning logic itself is identical, so it is
540/// not re-implemented a second time.
541///
542/// Falls back to an empty pruned base when `outer_locals` is `None` (a
543/// file-scope lambda, e.g. a `var f = |x: Option<int>| { … }` initializer)
544/// rather than staying `None` itself: an annotated param must still be
545/// classifiable there. `outer_locals` and the returned map both use "absent
546/// name" identically for `MistypeCtx::locals`'s own `Option`-wrapped
547/// `ctx.locals?` reads, so this is not a behavior change for the
548/// pre-existing pruning path.
549#[must_use]
550pub(crate) fn pruned_locals_for_lambda(
551 l: &brink_ir::LambdaExpr,
552 index: &SymbolIndex,
553 outer_locals: Option<&BTreeMap<String, Ty>>,
554) -> BTreeMap<String, Ty> {
555 let stmts: &[BlockStmt] = match &l.body {
556 brink_ir::LambdaBody::Block { stmts, .. } => stmts,
557 brink_ir::LambdaBody::Expr(_) => &[],
558 };
559 let mut body_names: BTreeMap<String, (TextRange, Option<brink_ir::TypeExpr>)> = BTreeMap::new();
560 crate::infer::lambda_own_bindings(stmts, &mut body_names);
561 let body_bound_names: BTreeSet<String> = body_names.keys().cloned().collect();
562
563 let mut own_names = body_names;
564 for p in &l.params {
565 own_names
566 .entry(p.name.text.clone())
567 .or_insert((p.name.range, None));
568 }
569
570 let mut pruned: BTreeMap<String, Ty> = outer_locals.map_or_else(BTreeMap::new, |locals| {
571 locals
572 .iter()
573 .filter(|(name, _)| !own_names.contains_key(*name))
574 .map(|(name, ty)| (name.clone(), ty.clone()))
575 .collect()
576 });
577
578 let type_names = annotations::TypeNames::new(index, None);
579 for p in &l.params {
580 if body_bound_names.contains(&p.name.text) {
581 continue;
582 }
583 if let Some(te) = &p.annotation
584 && let Some(ty) = annotations::resolve(te, &type_names)
585 {
586 pruned.insert(p.name.text.clone(), ty);
587 }
588 }
589
590 pruned
591}
592
593struct ConstructionVisitor<'a> {
594 file: FileId,
595 shapes: &'a ShapeTable,
596 index: &'a SymbolIndex,
597 globals: &'a BTreeMap<DefinitionId, Ty>,
598 signatures: &'a BTreeMap<DefinitionId, InferredSig>,
599 bodies: &'a BTreeMap<DefinitionId, crate::infer::BodyTypes>,
600 resolution_by_range: &'a BTreeMap<(u32, u32), DefinitionId>,
601 /// The currently-open knot's own name — `enter_stitch` needs it to
602 /// reconstruct the qualified `knot.stitch` name a stitch is indexed
603 /// under (mirrors `strict::check_value_calls`' own lookup).
604 current_knot_name: Option<String>,
605 /// The enclosing knot's own finalized locals, set for the duration of
606 /// its body (and every stitch nested inside it — `visit::visit` walks a
607 /// knot's own body before descending into its stitches, so a stitch's
608 /// `enter_stitch` overrides this with its *own* locals rather than
609 /// inheriting the parent knot's).
610 knot_locals: Option<&'a BTreeMap<String, Ty>>,
611 /// The currently-open stitch's own finalized locals, if any — takes
612 /// priority over `knot_locals` while set.
613 stitch_locals: Option<&'a BTreeMap<String, Ty>>,
614 /// Issue #2773: a stack of pruned-locals frames, one per currently-open
615 /// lambda literal (innermost last) — pushed in `enter_lambda`, popped in
616 /// `exit_lambda`. Takes priority over `stitch_locals`/`knot_locals`
617 /// while non-empty, so an expression inside a lambda's own body sees its
618 /// own bindings' types (or "unclassifiable") instead of a same-named
619 /// outer binding's.
620 lambda_locals: Vec<BTreeMap<String, Ty>>,
621 diagnostics: &'a mut Vec<Diagnostic>,
622}
623
624impl ConstructionVisitor<'_> {
625 fn current_locals(&self) -> Option<&BTreeMap<String, Ty>> {
626 self.lambda_locals
627 .last()
628 .or_else(|| self.stitch_locals.or(self.knot_locals))
629 }
630
631 /// The `DefinitionId` a knot/stitch's own name resolves to, mirroring
632 /// `strict::check_escapes`'/`check_value_calls`' own lookup — a top-level
633 /// stitch promoted to knot status is indexed under `SymbolKind::Stitch`
634 /// (#626), hence the `knot.ptr`-derived `kind`.
635 fn knot_def_id(&self, knot: &Knot) -> Option<DefinitionId> {
636 let kind = knot.symbol_kind();
637 annotations::def_id_for(self.index, self.file, kind, &knot.name.text)
638 }
639}
640
641impl HirVisitor for ConstructionVisitor<'_> {
642 fn visit_exprs(&self) -> bool {
643 true
644 }
645
646 fn enter_knot(&mut self, knot: &Knot) {
647 self.current_knot_name = Some(knot.name.text.clone());
648 self.knot_locals = self
649 .knot_def_id(knot)
650 .and_then(|id| self.bodies.get(&id))
651 .map(|b| &b.locals);
652 }
653
654 fn exit_knot(&mut self, _knot: &Knot) {
655 self.current_knot_name = None;
656 self.knot_locals = None;
657 }
658
659 fn enter_stitch(&mut self, stitch: &Stitch) {
660 // Stitches are indexed by qualified `knot.stitch` name (mirrors
661 // `strict::check_escapes`'/`check_value_calls`' own lookup).
662 // `visit::visit` only ever calls `enter_stitch` nested inside an
663 // `enter_knot`/`exit_knot` pair, so `current_knot_name` is always
664 // set here.
665 self.stitch_locals = self.current_knot_name.as_ref().and_then(|knot_name| {
666 let qualified = format!("{knot_name}.{}", stitch.name.text);
667 annotations::def_id_for(self.index, self.file, SymbolKind::Stitch, &qualified)
668 .and_then(|id| self.bodies.get(&id))
669 .map(|b| &b.locals)
670 });
671 }
672
673 fn exit_stitch(&mut self, _stitch: &Stitch) {
674 self.stitch_locals = None;
675 }
676
677 fn enter_expr(&mut self, expr: &Expr) {
678 if let Expr::StructLiteral(sl) = expr {
679 // Built from direct field projections (not `self.ctx()`/
680 // `self.current_locals()`) so the borrow checker sees this only
681 // borrows `index`/`globals`/`signatures`/`resolution_by_range`/
682 // the three locals fields, disjoint from the `self.diagnostics`
683 // reborrow below — a method call opaquely borrows the whole
684 // `&self` receiver for as long as its return value lives, which
685 // would conflict with `self.diagnostics` inside the same call.
686 let ctx = MistypeCtx {
687 index: self.index,
688 globals: self.globals,
689 signatures: self.signatures,
690 resolution_by_range: self.resolution_by_range,
691 locals: self
692 .lambda_locals
693 .last()
694 .or_else(|| self.stitch_locals.or(self.knot_locals)),
695 };
696 check_literal(sl, self.file, self.shapes, &ctx, self.diagnostics);
697 }
698 }
699
700 fn enter_lambda(&mut self, l: &brink_ir::LambdaExpr) {
701 let pruned = pruned_locals_for_lambda(l, self.index, self.current_locals());
702 self.lambda_locals.push(pruned);
703 }
704
705 fn exit_lambda(&mut self, _l: &brink_ir::LambdaExpr) {
706 self.lambda_locals.pop();
707 }
708}
709
710/// Duplicate-field diagnostic (`E084`, issue #675) — unlike [`check`]'s
711/// missing/extra/mistyped diagnostics above, this doesn't need the shape to
712/// resolve (a repeated field name is detectable from the literal's own
713/// field list alone) and runs under *both* `types` policies: a duplicate
714/// field is a structural authoring mistake, not a type-checking concern.
715/// Callers wire this in under `dialect = Brink || is_native` (wider than
716/// every other TM-4c construction-literal check, matching `E138`'s own
717/// wiring — see the module doc) rather than gating it behind
718/// `strict::config_error` the way [`check`] is.
719#[must_use]
720pub fn check_duplicates(files: &[(FileId, &HirFile)]) -> Vec<Diagnostic> {
721 let mut out = Vec::new();
722 for &(file, hir) in files {
723 let mut v = DuplicateFieldVisitor {
724 file,
725 diagnostics: &mut out,
726 };
727 // Issue #2098: `DuplicateFieldVisitor::enter_expr` carries no
728 // per-position state at all, so the shared entry point covers the
729 // block tree and every file-level declaration's own initializer in
730 // one drive — the hand-rolled `check_duplicates_expr`/`expr_children`
731 // mirror of `visit::visit`'s own descent this used to need is gone.
732 visit::visit_with_decl_initializers(hir, &mut v);
733 }
734 out
735}
736
737struct DuplicateFieldVisitor<'a> {
738 file: FileId,
739 diagnostics: &'a mut Vec<Diagnostic>,
740}
741
742impl HirVisitor for DuplicateFieldVisitor<'_> {
743 fn visit_exprs(&self) -> bool {
744 true
745 }
746
747 fn enter_expr(&mut self, expr: &Expr) {
748 if let Expr::StructLiteral(sl) = expr {
749 check_literal_duplicates(sl, self.file, self.diagnostics);
750 }
751 }
752}
753
754/// Flag every field-name occurrence in `sl` beyond its first — one
755/// diagnostic per repeated initializer, naming the field and pointing at
756/// the *repeated* occurrence (not the first, so authors see exactly which
757/// initializer is the redundant one).
758fn check_literal_duplicates(sl: &StructLiteral, file: FileId, out: &mut Vec<Diagnostic>) {
759 let mut seen: crate::determinism::LookupSet<&str> = crate::determinism::LookupSet::new();
760 for (name, _value) in &sl.fields {
761 if !seen.insert(name.text.as_str()) {
762 out.push(Diagnostic {
763 file,
764 range: name.range,
765 message: format!(
766 "{}: field `{}` is initialized more than once",
767 DiagnosticCode::E084.title(),
768 name.text
769 ),
770 code: DiagnosticCode::E084,
771 });
772 }
773 }
774}
775
776/// Check one struct literal against its declared shape (if resolvable — an
777/// unresolved shape name has nothing to check against, and is already
778/// diagnosed separately by `resolve::resolve_struct_ref`'s `E068`).
779///
780/// Issue #2241: `sl.shape`'s own name is a `RefKind::Struct` reference the
781/// analyzer already resolved with full module-scope `Candidacy`
782/// (`resolve::resolve_struct_ref`, walked into the `ResolutionMap` every
783/// construction literal's shape name gets — `symbols::project`'s `walk_expr`
784/// registers one for every `Expr::StructLiteral`, unconditionally). Consuming
785/// that recorded resolution by range (`ctx.resolution_by_range`) rather than
786/// re-deriving the shape from `sl.shape.text` by bare name is exactly the
787/// "lowering consumes analyzer types" fix PR #2248 already applied on the LIR
788/// side for this same reference kind — this is its analyzer-side twin. A
789/// missing entry here means `resolve_struct_ref` itself couldn't resolve the
790/// name (already reported as `E068`), so there is nothing to check against,
791/// same as before.
792fn check_literal(
793 sl: &StructLiteral,
794 file: FileId,
795 shapes: &ShapeTable,
796 ctx: &MistypeCtx<'_>,
797 out: &mut Vec<Diagnostic>,
798) {
799 let Some(shape) = ctx
800 .resolution_by_range
801 .get(&range_key(sl.shape.range))
802 .and_then(|def_id| shapes.get_by_def(*def_id))
803 else {
804 return;
805 };
806
807 // Extra fields (strict-only, since `check` only ever runs under strict
808 // per its own doc — the module's `structs::check` is only reached from
809 // `strict::check`).
810 for (name, _value) in &sl.fields {
811 if !shape.has_field(&name.text) {
812 out.push(Diagnostic {
813 file,
814 range: name.range,
815 message: format!(
816 "{}: `{}` has no field `{}`",
817 DiagnosticCode::E070.title(),
818 sl.shape.text,
819 name.text
820 ),
821 code: DiagnosticCode::E070,
822 });
823 }
824 }
825
826 // Missing fields (strict-only, since `check` only ever runs under
827 // strict per its own doc).
828 for (field_name, _ty) in &shape.fields {
829 if !sl.fields.iter().any(|(n, _)| &n.text == field_name) {
830 out.push(Diagnostic {
831 file,
832 range: sl.ptr.text_range(),
833 message: format!(
834 "{}: `{}` is missing field `{field_name}`",
835 DiagnosticCode::E069.title(),
836 sl.shape.text
837 ),
838 code: DiagnosticCode::E069,
839 });
840 }
841 }
842
843 // Mistyped fields — only for classifiable initializers (see module doc:
844 // literal-shaped classify from their own shape; variable/call/index-
845 // valued ones consult `ctx`'s inference substrate).
846 for (name, value) in &sl.fields {
847 let Some(declared_ty) = shape.field_ty(&name.text) else {
848 continue; // already flagged as an extra field above
849 };
850 if declared_ty.is_unresolved() {
851 continue; // the field's own annotation didn't resolve (E061)
852 }
853 let Some(actual_ty) = classify_expr_ty(value, ctx) else {
854 continue; // not classifiable — see module doc
855 };
856 // Row-insensitive (issue #1680): a `fn`-typed field's declared type
857 // carries the top effect row and the initializer's carries its real
858 // creation target, so rows must not decide this comparison — see
859 // `infer::assignable`.
860 if !crate::infer::assignable(declared_ty, &actual_ty) {
861 out.push(Diagnostic {
862 file,
863 range: name.range,
864 message: format!(
865 "{}: field `{}` declared `{}` but initialized with `{}`",
866 DiagnosticCode::E071.title(),
867 name.text,
868 declared_ty.display(),
869 actual_ty.display()
870 ),
871 code: DiagnosticCode::E071,
872 });
873 }
874 }
875}
876
877/// Classify a struct-field initializer's type when it's statically obvious
878/// from its own shape — literals, and (recursively) array/map/struct
879/// literals. Anything else (a variable/call/index/…) returns `None` here;
880/// [`classify_expr_ty`] is the entry point [`check_literal`] actually calls,
881/// falling back to the inference-substrate classification for those forms
882/// (issue #670) before finally treating an unclassifiable expression as
883/// silently clean — the same "Unknown never disagrees" posture
884/// `annotations::mismatches` takes.
885fn literal_ty(expr: &Expr) -> Option<Ty> {
886 match expr {
887 Expr::Int(_) => Some(Ty::Int),
888 Expr::Float(_) => Some(Ty::Float),
889 Expr::Bool(_) => Some(Ty::Bool),
890 Expr::String(s) => match s.parts.as_slice() {
891 [] | [brink_ir::StringPart::Literal(_)] => Some(Ty::String),
892 _ => None, // interpolated — not purely a literal
893 },
894 Expr::ArrayLiteral(a) => {
895 let elems: Vec<Ty> = a.elements.iter().map(literal_ty).collect::<Option<_>>()?;
896 Some(Ty::Array(Box::new(crate::infer::unify_all(elems))))
897 }
898 Expr::MapLiteral(m) => {
899 let mut keys = Vec::with_capacity(m.entries.len());
900 let mut vals = Vec::with_capacity(m.entries.len());
901 for (k, v) in &m.entries {
902 keys.push(literal_ty(k)?);
903 vals.push(literal_ty(v)?);
904 }
905 Some(Ty::Map(
906 Box::new(crate::infer::unify_all(keys)),
907 Box::new(crate::infer::unify_all(vals)),
908 ))
909 }
910 Expr::StructLiteral(sl) => Some(Ty::Struct(sl.shape.text.clone())),
911 _ => None,
912 }
913}
914
915/// Classify a struct-field initializer's type — [`literal_ty`]'s
916/// literal-shaped classification first, falling back to the non-literal
917/// forms issue #670 adds: a `Path` (variable), a `Call` (function), or an
918/// `Index` expression, each resolved through `ctx`'s inference substrate.
919/// `None` — "not classifiable" — whenever the resolved type is itself
920/// `Unknown`/`Conflicted`, or the expression shape isn't handled at all
921/// (e.g. a field access, an infix expression): the same "Unknown never
922/// disagrees" posture [`literal_ty`] and `annotations::mismatches` both take.
923///
924/// `pub(crate)` (issue #983) — see [`MistypeCtx`]'s doc for why.
925pub(crate) fn classify_expr_ty(expr: &Expr, ctx: &MistypeCtx<'_>) -> Option<Ty> {
926 if let Some(ty) = literal_ty(expr) {
927 return Some(ty);
928 }
929 match expr {
930 Expr::Path(p) => resolved_symbol_ty(p.range, ctx),
931 Expr::Call(path, _args) => {
932 // Only a direct call to a known inferable knot/stitch is
933 // classified here — a call through a function *value* is T1c's
934 // own domain (`strict::check_value_calls`'s `ValueCallFact`s),
935 // not this diagnostic's.
936 let def = ctx.resolution_by_range.get(&range_key(path.range))?;
937 let sig = ctx.signatures.get(def)?;
938 (!sig.return_ty.is_unresolved()).then(|| sig.return_ty.clone())
939 }
940 Expr::Index(idx) => {
941 let base_ty = classify_expr_ty(&idx.base, ctx)?;
942 match base_ty {
943 Ty::Array(elem) if !elem.is_unresolved() => Some(*elem),
944 Ty::Map(_key, val) if !val.is_unresolved() => Some(*val),
945 _ => None,
946 }
947 }
948 _ => None,
949 }
950}
951
952/// Resolve a `Path` expression's own range to a concrete [`Ty`]: a
953/// param/temp reads the enclosing def's finalized `BodyTypes::locals`
954/// (`ctx.locals`, `None` at file scope — see [`MistypeCtx`]'s doc); a
955/// global `VAR`/`CONST` reads `infer::collect_globals`'s declaration-derived
956/// type; a `LIST`/list-item name is nominally `List<L>`. Mirrors
957/// `infer::body::InferPass::ty_of_def`'s own dispatch exactly (the same
958/// firewall a body's own inference already enforces), just read post hoc
959/// from the finalized results instead of live during a body solve.
960fn resolved_symbol_ty(range: TextRange, ctx: &MistypeCtx<'_>) -> Option<Ty> {
961 let def = *ctx.resolution_by_range.get(&range_key(range))?;
962 let info = ctx.index.symbols.get(&def)?;
963 let ty = match info.kind {
964 SymbolKind::Param | SymbolKind::Temp => ctx.locals?.get(&info.name)?.clone(),
965 SymbolKind::Variable | SymbolKind::Constant => ctx.globals.get(&def)?.clone(),
966 SymbolKind::List => Ty::List(info.name.clone()),
967 SymbolKind::ListItem => {
968 let (list, _item) = info.name.split_once('.')?;
969 Ty::List(list.to_string())
970 }
971 SymbolKind::Knot
972 | SymbolKind::Stitch
973 | SymbolKind::External
974 | SymbolKind::Struct
975 | SymbolKind::Label => {
976 return None;
977 }
978 };
979 if ty.is_unresolved() { None } else { Some(ty) }
980}
981
982#[cfg(test)]
983mod tests {
984 use super::*;
985 use brink_ir::hir::lower;
986
987 fn build(src: &str) -> (HirFile, SymbolIndex) {
988 let parsed = brink_syntax::parse(src);
989 let (hir, manifest, _diag) = lower(FileId(0), &parsed.tree());
990 let (index, _diag) = crate::symbol_index(&[(FileId(0), &manifest)]);
991 (hir, (*index).clone())
992 }
993
994 /// Like [`build`], but also computes real resolutions and a whole-project
995 /// [`InferenceResult`] — needed by every test exercising the non-literal
996 /// (variable/call/index) classification issue #670 adds, since that path
997 /// consults exactly this substrate.
998 fn build_with_inference(src: &str) -> (HirFile, SymbolIndex, ResolutionMap, InferenceResult) {
999 let parsed = brink_syntax::parse(src);
1000 let (hir, manifest, _diag) = lower(FileId(0), &parsed.tree());
1001 let (index, _diag) = crate::symbol_index(&[(FileId(0), &manifest)]);
1002 let (resolutions, _diag) =
1003 crate::resolve(FileId(0), &manifest, &index, &crate::ImportScope::default());
1004 let inference = crate::infer_project(
1005 &[(FileId(0), &hir)],
1006 &index,
1007 &resolutions,
1008 None,
1009 &BTreeMap::new(),
1010 );
1011 (hir, (*index).clone(), (*resolutions).clone(), inference)
1012 }
1013
1014 /// [`check`] driven by [`build_with_inference`]'s output — the harness
1015 /// every non-literal-classification test below shares.
1016 fn check_all(src: &str) -> Vec<Diagnostic> {
1017 let (hir, index, resolutions, inference) = build_with_inference(src);
1018 check(&[(FileId(0), &hir)], &index, &inference, &resolutions)
1019 }
1020
1021 /// [`build_with_inference`]'s native-surface twin. Lambdas exist only on
1022 /// the native surface, so the #1764 fixtures below must go through
1023 /// `lower_native` (the same reason `coalesce`'s `build_native` exists).
1024 fn build_native(src: &str) -> (HirFile, SymbolIndex, ResolutionMap, InferenceResult) {
1025 let parsed = brink_syntax_native::parse(src);
1026 assert!(parsed.errors().is_empty(), "{:?}", parsed.errors());
1027 let (hir, manifest, _diag) = brink_ir::hir::lower_native::lower(FileId(0), &parsed.tree());
1028 let (index, _diag) = crate::symbol_index(&[(FileId(0), &manifest)]);
1029 let (resolutions, _diag) =
1030 crate::resolve(FileId(0), &manifest, &index, &crate::ImportScope::default());
1031 let inference = crate::infer_project(
1032 &[(FileId(0), &hir)],
1033 &index,
1034 &resolutions,
1035 None,
1036 &BTreeMap::new(),
1037 );
1038 (hir, (*index).clone(), (*resolutions).clone(), inference)
1039 }
1040
1041 /// [`check`] over native source — [`check_all`]'s native twin.
1042 fn check_all_native(src: &str) -> Vec<Diagnostic> {
1043 let (hir, index, resolutions, inference) = build_native(src);
1044 check(&[(FileId(0), &hir)], &index, &inference, &resolutions)
1045 }
1046
1047 /// [`check_assignments_all`]'s native-surface twin — [`build_native`]'s
1048 /// output run through [`check_assignments`] rather than [`check`]. Only
1049 /// the native surface has lambdas, so the lambda-frame regression test
1050 /// below (issue #2906 BLOCKING review finding) needs this rather than
1051 /// `check_assignments_all`.
1052 fn check_assignments_all_native(src: &str) -> Vec<Diagnostic> {
1053 let (hir, index, _resolutions, inference) = build_native(src);
1054 check_assignments(&[(FileId(0), &hir)], &index, &inference)
1055 }
1056
1057 #[test]
1058 fn clean_construction_produces_no_diagnostics() {
1059 let diags = check_all(
1060 "STRUCT Point = #{x: float, y: float}\n\
1061 === main ===\n~ p = Point#{x: 1.0, y: 2.0}\n-> DONE\n",
1062 );
1063 assert!(diags.is_empty(), "{diags:?}");
1064 }
1065
1066 #[test]
1067 fn missing_field_is_e069_naming_the_field() {
1068 let diags = check_all(
1069 "STRUCT Point = #{x: float, y: float}\n\
1070 === main ===\n~ p = Point#{x: 1.0}\n-> DONE\n",
1071 );
1072 assert_eq!(diags.len(), 1, "{diags:?}");
1073 assert_eq!(diags[0].code, DiagnosticCode::E069);
1074 assert!(diags[0].message.contains('y'), "{:?}", diags[0].message);
1075 }
1076
1077 #[test]
1078 fn extra_field_is_e070_naming_the_field() {
1079 let diags = check_all(
1080 "STRUCT Point = #{x: float}\n\
1081 === main ===\n~ p = Point#{x: 1.0, z: 2.0}\n-> DONE\n",
1082 );
1083 assert_eq!(diags.len(), 1, "{diags:?}");
1084 assert_eq!(diags[0].code, DiagnosticCode::E070);
1085 assert!(diags[0].message.contains('z'), "{:?}", diags[0].message);
1086 }
1087
1088 #[test]
1089 fn mistyped_field_is_e071_naming_the_field() {
1090 let diags = check_all(
1091 "STRUCT Point = #{x: float}\n\
1092 === main ===\n~ p = Point#{x: \"hi\"}\n-> DONE\n",
1093 );
1094 assert_eq!(diags.len(), 1, "{diags:?}");
1095 assert_eq!(diags[0].code, DiagnosticCode::E071);
1096 assert!(diags[0].message.contains('x'), "{:?}", diags[0].message);
1097 }
1098
1099 #[test]
1100 fn int_initializer_for_a_float_field_is_the_legal_coercion() {
1101 // §4's directional int -> float coercion applies here too.
1102 let diags = check_all(
1103 "STRUCT Point = #{x: float}\n\
1104 === main ===\n~ p = Point#{x: 1}\n-> DONE\n",
1105 );
1106 assert!(diags.is_empty(), "{diags:?}");
1107 }
1108
1109 // ── issue #670: variable/call/index-valued initializers ────────────
1110
1111 #[test]
1112 fn global_variable_valued_initializer_fires_when_provably_mistyped() {
1113 // `v`'s declaration-derived type is a concrete `string` (its own
1114 // literal initializer) — disagrees with `Point.x`'s declared
1115 // `float`, so this now fires exactly like a literal `"hi"` would.
1116 let diags = check_all(
1117 "STRUCT Point = #{x: float}\n\
1118 VAR v = \"hi\"\n=== main ===\n~ p = Point#{x: v}\n-> DONE\n",
1119 );
1120 assert_eq!(diags.len(), 1, "{diags:?}");
1121 assert_eq!(diags[0].code, DiagnosticCode::E071);
1122 assert!(diags[0].message.contains('x'), "{:?}", diags[0].message);
1123 }
1124
1125 #[test]
1126 fn global_variable_valued_initializer_of_the_right_type_is_clean() {
1127 let diags = check_all(
1128 "STRUCT Point = #{x: float}\n\
1129 VAR v = 1.0\n=== main ===\n~ p = Point#{x: v}\n-> DONE\n",
1130 );
1131 assert!(diags.is_empty(), "{diags:?}");
1132 }
1133
1134 #[test]
1135 fn param_variable_valued_initializer_fires_when_provably_mistyped() {
1136 // `n`'s only use in the body is compared against a string literal,
1137 // so its inferred `BodyTypes::locals` type is a concrete `string` —
1138 // disagrees with `Point.x`'s declared `float`.
1139 let diags = check_all(
1140 "STRUCT Point = #{x: float}\n\
1141 === main(n) ===\n\
1142 {n == \"a\":\n yes\n}\n~ p = Point#{x: n}\n-> DONE\n",
1143 );
1144 assert_eq!(diags.len(), 1, "{diags:?}");
1145 assert_eq!(diags[0].code, DiagnosticCode::E071);
1146 }
1147
1148 // ─── issue #2793: the ordinary (non-lambda) fn/knot annotated-param
1149 // half of #2786's `BodyTypes::locals` visibility fix ─────────────────
1150
1151 /// #2786 overlaid an *ordinary* `fn`/knot param's own written annotation
1152 /// onto `pass.locals` whenever the body walk left it absent — the exact
1153 /// same mechanism `option_conditions.rs`'s
1154 /// `annotated_fn_param_option_condition_is_e116` pins for E116, here for
1155 /// this file's E071 field-mismatch check instead. `n`'s only other
1156 /// appearance is the `Point#{x: n}` field initializer itself — no other
1157 /// statement observes it (mirrors
1158 /// `unused_param_variable_valued_initializer_stays_silent_when_unknown`
1159 /// just below, minus the annotation), so pre-#2786 this param stayed
1160 /// `Unknown` in `pass.locals`. Unlike `coalesce.rs`'s `or` chains and
1161 /// `contains_domain.rs`'s `contains` needle (both #2793 findings where a
1162 /// sibling `infer_intrinsic`/`infer_infix` arm's own `observe` call
1163 /// forces the param's locals entry to something else *before* the
1164 /// annotation overlay runs), a struct literal's field values are never
1165 /// `observe`d against their declared field type during the main walk
1166 /// (`infer::body::InferPass::infer_expr`'s own `Expr::StructLiteral` arm
1167 /// doc: "Field-type propagation through a struct's declared shape is
1168 /// out of scope for this slice") — so this position has no such
1169 /// pre-emption, and the annotation overlay is what finally supplies the
1170 /// classification: `n: string` disagrees with `Point.x: float`, so this
1171 /// must now fire — the new true positive #2793 asks each consumer to
1172 /// confirm.
1173 #[test]
1174 fn annotated_fn_param_field_mismatch_is_e071() {
1175 let diags = check_all(
1176 "STRUCT Point = #{x: float}\n\
1177 === main(n: string) ===\n~ p = Point#{x: n}\n-> DONE\n",
1178 );
1179 assert_eq!(diags.len(), 1, "{diags:?}");
1180 assert_eq!(diags[0].code, DiagnosticCode::E071);
1181 assert!(diags[0].message.contains('x'), "{:?}", diags[0].message);
1182 }
1183
1184 /// Negative control alongside
1185 /// [`annotated_fn_param_field_mismatch_is_e071`]: an ordinary annotated
1186 /// param whose declared type already agrees with the field's declared
1187 /// type must stay clean.
1188 #[test]
1189 fn annotated_fn_param_field_agreement_stays_clean() {
1190 let diags = check_all(
1191 "STRUCT Point = #{x: float}\n\
1192 === main(n: float) ===\n~ p = Point#{x: n}\n-> DONE\n",
1193 );
1194 assert!(diags.is_empty(), "{diags:?}");
1195 }
1196
1197 #[test]
1198 fn unused_param_variable_valued_initializer_stays_silent_when_unknown() {
1199 // `n` is never used anywhere else in the body, so it stays `Unknown`
1200 // — "Unknown never disagrees" holds even for a variable initializer.
1201 let diags = check_all(
1202 "STRUCT Point = #{x: float}\n\
1203 === main(n) ===\n~ p = Point#{x: n}\n-> DONE\n",
1204 );
1205 assert!(diags.is_empty(), "{diags:?}");
1206 }
1207
1208 #[test]
1209 fn call_valued_initializer_fires_when_provably_mistyped() {
1210 // `label()`'s only `~ return` is a string literal, so its
1211 // finalized `InferredSig::return_ty` is a concrete `string`.
1212 let diags = check_all(
1213 "STRUCT Point = #{x: float}\n\
1214 === function label() ===\n~ return \"a\"\n\
1215 === main ===\n~ p = Point#{x: label()}\n-> DONE\n",
1216 );
1217 assert_eq!(diags.len(), 1, "{diags:?}");
1218 assert_eq!(diags[0].code, DiagnosticCode::E071);
1219 }
1220
1221 #[test]
1222 fn call_valued_initializer_of_the_right_type_is_clean() {
1223 let diags = check_all(
1224 "STRUCT Point = #{x: float}\n\
1225 === function label() ===\n~ return 1.0\n\
1226 === main ===\n~ p = Point#{x: label()}\n-> DONE\n",
1227 );
1228 assert!(diags.is_empty(), "{diags:?}");
1229 }
1230
1231 #[test]
1232 fn index_valued_initializer_fires_when_provably_mistyped() {
1233 // `xs` is a local `~ temp` bound to a `#[...]` array-of-strings
1234 // literal, so its finalized locals type is `Array<string>` — indexing
1235 // it yields `string`, disagreeing with `Point.x`'s declared `float`.
1236 let diags = check_all(
1237 "STRUCT Point = #{x: float}\n\
1238 === main ===\n\
1239 ~ temp xs = #[\"a\", \"b\"]\n~ p = Point#{x: xs[0]}\n-> DONE\n",
1240 );
1241 assert_eq!(diags.len(), 1, "{diags:?}");
1242 assert_eq!(diags[0].code, DiagnosticCode::E071);
1243 }
1244
1245 #[test]
1246 fn index_valued_initializer_of_the_right_type_is_clean() {
1247 let diags = check_all(
1248 "STRUCT Point = #{x: float}\n\
1249 === main ===\n\
1250 ~ temp xs = #[1.0, 2.0]\n~ p = Point#{x: xs[0]}\n-> DONE\n",
1251 );
1252 assert!(diags.is_empty(), "{diags:?}");
1253 }
1254
1255 #[test]
1256 fn index_valued_initializer_stays_silent_when_unknown() {
1257 // `xs` is only ever indexed, never assigned/observed to a concrete
1258 // type elsewhere — reading through an `Unknown` base never learns an
1259 // array shape (`infer::body`'s own `Expr::Index` arm), so this stays
1260 // silent rather than false-flagging.
1261 let diags = check_all(
1262 "STRUCT Point = #{x: float}\n\
1263 === main(xs) ===\n~ p = Point#{x: xs[0]}\n-> DONE\n",
1264 );
1265 assert!(diags.is_empty(), "{diags:?}");
1266 }
1267
1268 #[test]
1269 fn unresolved_shape_name_is_not_double_reported_here() {
1270 // No `STRUCT Bogus` declared — `resolve::resolve_struct_ref` already
1271 // reports E068 elsewhere; this pass has nothing to check against.
1272 let diags = check_all("=== main ===\n~ p = Bogus#{x: 1}\n-> DONE\n");
1273 assert!(diags.is_empty(), "{diags:?}");
1274 }
1275
1276 #[test]
1277 fn nested_struct_literal_field_is_checked_by_shape_name() {
1278 let diags = check_all(
1279 "STRUCT Inner = #{v: float}\nSTRUCT Outer = #{inner: Inner}\n\
1280 === main ===\n~ o = Outer#{inner: Inner#{v: 1.0}}\n-> DONE\n",
1281 );
1282 assert!(diags.is_empty(), "{diags:?}");
1283 }
1284
1285 #[test]
1286 fn nested_struct_literal_mistyped_field_still_flags_outer() {
1287 let diags = check_all(
1288 "STRUCT Wrong = #{v: float}\nSTRUCT Inner = #{v: float}\nSTRUCT Outer = #{inner: Inner}\n\
1289 === main ===\n~ o = Outer#{inner: Wrong#{v: 1.0}}\n-> DONE\n",
1290 );
1291 assert_eq!(diags.len(), 1, "{diags:?}");
1292 assert_eq!(diags[0].code, DiagnosticCode::E071);
1293 }
1294
1295 #[test]
1296 fn struct_literal_inside_var_initializer_is_checked() {
1297 let diags = check_all("STRUCT Point = #{x: float}\nVAR p = Point#{x: \"hi\"}\n");
1298 assert_eq!(diags.len(), 1, "{diags:?}");
1299 assert_eq!(diags[0].code, DiagnosticCode::E071);
1300 }
1301
1302 #[test]
1303 fn variable_valued_initializer_inside_var_initializer_uses_global_scope_only() {
1304 // A struct literal in a file-level VAR/CONST initializer has no
1305 // enclosing knot/stitch body — only a reference to *another* global
1306 // is classifiable there (never a param/temp, which can't exist at
1307 // file scope). `other`'s declared type disagrees with `Point.x`.
1308 let diags =
1309 check_all("STRUCT Point = #{x: float}\nVAR other = \"hi\"\nVAR p = Point#{x: other}\n");
1310 assert_eq!(diags.len(), 1, "{diags:?}");
1311 assert_eq!(diags[0].code, DiagnosticCode::E071);
1312 }
1313
1314 #[test]
1315 fn stitch_local_variable_valued_initializer_fires_when_provably_mistyped() {
1316 // Every other non-literal-classification test above only ever
1317 // exercises knot scope (`main`), file scope, or `main(n)`'s own
1318 // params — never a stitch body. This drives the `enter_stitch`/
1319 // `stitch_locals` dispatch path specifically: `t`'s finalized
1320 // `BodyTypes::locals` type (a concrete `string`, from its own
1321 // literal initializer) disagrees with `Point.x`'s declared `float`.
1322 let diags = check_all(
1323 "STRUCT Point = #{x: float}\n\
1324 === room ===\n= inside\n~ temp t = \"hi\"\n~ p = Point#{x: t}\n-> DONE\n",
1325 );
1326 assert_eq!(diags.len(), 1, "{diags:?}");
1327 assert_eq!(diags[0].code, DiagnosticCode::E071);
1328 assert!(diags[0].message.contains('x'), "{:?}", diags[0].message);
1329 }
1330
1331 #[test]
1332 fn mistyped_variable_field_diagnostic_is_order_independent() {
1333 // Issue #670's own scope names "order-independence property tests
1334 // per the #627 discipline" as a deliverable — mirrors strict.rs's
1335 // `escape_diagnostics_are_order_independent` forward/reversed
1336 // pattern. `v`'s mistyped classification (and the resulting E071)
1337 // must not depend on which position its field initializer occupies
1338 // in the literal.
1339 let forward = "STRUCT Point = #{x: float, y: float}\n\
1340 VAR v = \"hi\"\n=== main ===\n~ p = Point#{x: v, y: 1.0}\n-> DONE\n";
1341 let reversed = "STRUCT Point = #{x: float, y: float}\n\
1342 VAR v = \"hi\"\n=== main ===\n~ p = Point#{y: 1.0, x: v}\n-> DONE\n";
1343
1344 let diags_f = check_all(forward);
1345 let diags_r = check_all(reversed);
1346
1347 assert_eq!(diags_f.len(), 1, "{diags_f:?}");
1348 assert_eq!(diags_f[0].code, DiagnosticCode::E071);
1349 assert!(diags_f[0].message.contains('x'), "{:?}", diags_f[0].message);
1350
1351 assert_eq!(diags_r.len(), 1, "{diags_r:?}");
1352 assert_eq!(diags_r[0].code, DiagnosticCode::E071);
1353 assert!(diags_r[0].message.contains('x'), "{:?}", diags_r[0].message);
1354 }
1355
1356 // ─── check_assignments (E063, issue #1900) ─────────────────────────
1357
1358 /// [`check_assignments`] driven by [`build_with_inference`]'s output —
1359 /// mirrors [`check_all`] for the plain-assignment sibling check.
1360 fn check_assignments_all(src: &str) -> Vec<Diagnostic> {
1361 let (hir, index, _resolutions, inference) = build_with_inference(src);
1362 check_assignments(&[(FileId(0), &hir)], &index, &inference)
1363 }
1364
1365 #[test]
1366 fn field_assignment_mismatch_on_var_is_e063_naming_the_dotted_target() {
1367 // The issue's own repro: `p.x`'s declared `float` disagrees with the
1368 // RHS `string`.
1369 let diags = check_assignments_all(
1370 "STRUCT Point = #{x: float, y: float}\n\
1371 VAR p: Point = Point#{x: 0.0, y: 0.0}\n\
1372 === main ===\n~ p.x = \"wrong\"\n-> DONE\n",
1373 );
1374 assert_eq!(diags.len(), 1, "{diags:?}");
1375 assert_eq!(diags[0].code, DiagnosticCode::E063);
1376 assert!(diags[0].message.contains("p.x"), "{:?}", diags[0].message);
1377 }
1378
1379 #[test]
1380 fn field_assignment_of_the_declared_type_is_clean() {
1381 let diags = check_assignments_all(
1382 "STRUCT Point = #{x: float, y: float}\n\
1383 VAR p: Point = Point#{x: 0.0, y: 0.0}\n\
1384 === main ===\n~ p.x = 1.0\n-> DONE\n",
1385 );
1386 assert!(diags.is_empty(), "{diags:?}");
1387 }
1388
1389 #[test]
1390 fn field_assignment_int_initializer_for_a_float_field_is_the_legal_coercion() {
1391 // §4's directional int -> float coercion applies here too, same as
1392 // `int_initializer_for_a_float_field_is_the_legal_coercion` above.
1393 let diags = check_assignments_all(
1394 "STRUCT Point = #{x: float}\nVAR p: Point = Point#{x: 0.0}\n\
1395 === main ===\n~ p.x = 1\n-> DONE\n",
1396 );
1397 assert!(diags.is_empty(), "{diags:?}");
1398 }
1399
1400 #[test]
1401 fn field_assignment_mismatch_on_annotated_temp_is_e063() {
1402 // The second of the issue's two named root sources: an annotated `~
1403 // temp`'s own ascription, not a global.
1404 let diags = check_assignments_all(
1405 "STRUCT Point = #{x: float}\n\
1406 === main ===\n~ temp p: Point = Point#{x: 0.0}\n~ p.x = \"wrong\"\n-> DONE\n",
1407 );
1408 assert_eq!(diags.len(), 1, "{diags:?}");
1409 assert_eq!(diags[0].code, DiagnosticCode::E063);
1410 }
1411
1412 #[test]
1413 fn field_assignment_mismatch_on_unannotated_temp_with_construction_literal_initializer_is_e063_issue_2906()
1414 {
1415 // Issue #2906: before this fix, `check_declared_field_assign_target`
1416 // only ever resolved a Temp root's shape from `self.annotated` (an
1417 // explicit `~ temp p: Point = …` ascription) — an *unannotated* `~
1418 // temp p = Point#{…}` has no ascription, so this test used to pin
1419 // silence here even though the shape is plainly knowable from the
1420 // construction-literal initializer itself. That was the recording-
1421 // site gap the issue reports, not a genuine "no shape to check
1422 // against" case — `check_declared_field_assign_target` now also
1423 // consults the initializer's own inferred `Ty::Struct` shape when
1424 // there is no explicit ascription, so this fires `E063` exactly like
1425 // the annotated spelling does (see
1426 // `field_assignment_mismatch_on_annotated_temp_is_e063` above).
1427 let diags = check_assignments_all(
1428 "STRUCT Point = #{x: float}\n\
1429 === main ===\n~ temp p = Point#{x: 0.0}\n~ p.x = \"wrong\"\n-> DONE\n",
1430 );
1431 assert_eq!(diags.len(), 1, "{diags:?}");
1432 assert_eq!(diags[0].code, DiagnosticCode::E063);
1433 }
1434
1435 #[test]
1436 fn field_assignment_on_genuinely_unresolved_temp_stays_silent() {
1437 // The genuine "Unknown never disagrees" case, distinct from the one
1438 // above (issue #2906): a `~ temp` whose value is never statically
1439 // knowable at all — here, an unannotated `EXTERNAL` call with no
1440 // declared return type — never resolves past `Ty::Unknown`, so
1441 // there is truly no shape to check the field name or value against.
1442 let diags = check_assignments_all(
1443 "STRUCT Point = #{x: float}\n\
1444 EXTERNAL make_point()\n\
1445 === main ===\n~ temp p = make_point()\n~ p.x = \"wrong\"\n-> DONE\n",
1446 );
1447 assert!(diags.is_empty(), "{diags:?}");
1448 }
1449
1450 #[test]
1451 fn field_assignment_to_a_nonexistent_field_is_e185_issue_1944() {
1452 // Issue #1944: before this fix, a field name the resolved shape
1453 // doesn't declare was silently accepted here — this test itself
1454 // used to pin that as `diags.is_empty()`, which was the exact hole
1455 // the issue reports (a plain assignment to an unknown field
1456 // compiled clean under strict, with no E070-equivalent for a
1457 // non-literal target). The shape *is* resolved here (`p: Point`),
1458 // so the name can actually be checked — unlike an Unknown/untyped
1459 // receiver, where "Unknown never disagrees" still applies (see
1460 // `field_assignment_to_a_nonexistent_field_on_unresolved_receiver_stays_silent`
1461 // below).
1462 let diags = check_assignments_all(
1463 "STRUCT Point = #{x: float}\n\
1464 VAR p: Point = Point#{x: 0.0}\n\
1465 === main ===\n~ p.bogus = \"wrong\"\n-> DONE\n",
1466 );
1467 assert_eq!(diags.len(), 1, "{diags:?}");
1468 assert_eq!(diags[0].code, DiagnosticCode::E185);
1469 assert!(diags[0].message.contains("bogus"), "{:?}", diags[0].message);
1470 }
1471
1472 /// Sibling should-NOT-fire case (issue #1944 design constraint,
1473 /// corrected per review finding): an unannotated function parameter's
1474 /// root never resolves past `Ty::Unknown` — there is genuinely no shape
1475 /// anywhere to check the field name against — so "Unknown never
1476 /// disagrees" holds for the *receiver* exactly as it does for `E063`
1477 /// (see `field_assignment_on_genuinely_unresolved_temp_stays_silent`
1478 /// above, E063's own sibling). Issue #2906 closed the *other* silent
1479 /// case this test used to also cover — an unannotated `~ temp p =
1480 /// Point#{x: 0.0}` — by widening the recording site's fallback to the
1481 /// initializer's own inferred shape (see
1482 /// `field_assignment_to_a_nonexistent_field_on_unannotated_temp_with_construction_literal_initializer_is_e185_issue_2906`
1483 /// below); a function param has no initializer at all to fall back to,
1484 /// so it stays the genuine "no shape anywhere" case.
1485 #[test]
1486 fn field_assignment_to_a_nonexistent_field_on_unresolved_receiver_stays_silent() {
1487 let diags = check_assignments_all(
1488 "STRUCT Point = #{x: float}\n\
1489 === function f(p) ===\n~ p.bogus = \"wrong\"\n-> DONE\n",
1490 );
1491 assert!(diags.is_empty(), "{diags:?}");
1492 }
1493
1494 #[test]
1495 fn field_assignment_to_a_nonexistent_field_on_unannotated_temp_with_construction_literal_initializer_is_e185_issue_2906()
1496 {
1497 // Issue #2906: the `E185` twin of
1498 // `field_assignment_mismatch_on_unannotated_temp_with_construction_literal_initializer_is_e063_issue_2906`
1499 // above — same recording-site widening, same seam
1500 // (`check_field_assign_mismatch`), the unknown-field-name arm
1501 // instead of the type-mismatch arm.
1502 let diags = check_assignments_all(
1503 "STRUCT Point = #{x: float}\n\
1504 === main ===\n~ temp p = Point#{x: 0.0}\n~ p.bogus = 1\n-> DONE\n",
1505 );
1506 assert_eq!(diags.len(), 1, "{diags:?}");
1507 assert_eq!(diags[0].code, DiagnosticCode::E185);
1508 assert!(diags[0].message.contains("bogus"), "{:?}", diags[0].message);
1509 }
1510
1511 #[test]
1512 fn field_assignment_to_a_nonexistent_field_on_unannotated_temp_reassigned_to_a_different_struct_stays_silent()
1513 {
1514 // Issue #2906's own conservative-reassignment carve-out: `p`'s
1515 // initializer resolves to `Point`, but `p` is reassigned to a
1516 // different, incompatible-shaped struct before the field write. The
1517 // initializer-inferred shape lives in a declaration-time-only map
1518 // (mirroring `self.annotated`'s own "recorded once, consulted as a
1519 // fallback" shape) that a later plain reassignment never touches —
1520 // so this is NOT caught by `Ty::unify` driving `self.locals["p"]` to
1521 // `Ty::Conflicted` the way a bare `check_declared_assign_target`
1522 // fact would be. It is the explicit `reassigned_temps` bookkeeping
1523 // this fix adds (every bare `~ p = expr` reassignment anywhere in
1524 // the body, recorded during the walk) that withdraws the pending
1525 // fact post-walk instead.
1526 let diags = check_assignments_all(
1527 "STRUCT Point = #{x: float}\n\
1528 STRUCT Other = #{y: float}\n\
1529 === main ===\n~ temp p = Point#{x: 0.0}\n~ p = Other#{y: 1.0}\n\
1530 ~ p.bogus = 1\n-> DONE\n",
1531 );
1532 assert!(diags.is_empty(), "{diags:?}");
1533 }
1534
1535 #[test]
1536 fn field_assignment_to_a_nonexistent_field_on_unannotated_temp_reassigned_to_an_unknown_shape_stays_silent()
1537 {
1538 // Issue #2906's harder conservative case, called out explicitly by
1539 // the issue: `p` is reassigned to an *unresolvable* value (an
1540 // unannotated `EXTERNAL` call's return) rather than a different
1541 // concrete struct. `Ty::unify(Ty::Struct(_), Ty::Unknown)` is the
1542 // identity rule — it stays `Ty::Struct("Point")`, NOT `Conflicted`
1543 // — so `self.locals` alone can never distinguish "p was never
1544 // reassigned" from "p was reassigned to something unresolvable".
1545 // Without the explicit `reassigned_temps` tracking this fix adds,
1546 // the widened fallback would still trust the stale `Point` shape
1547 // here and false-fire `E185` on a receiver that might legitimately
1548 // be any shape at this point.
1549 let diags = check_assignments_all(
1550 "STRUCT Point = #{x: float}\n\
1551 EXTERNAL make_thing()\n\
1552 === main ===\n~ temp p = Point#{x: 0.0}\n~ p = make_thing()\n\
1553 ~ p.bogus = 1\n-> DONE\n",
1554 );
1555 assert!(diags.is_empty(), "{diags:?}");
1556 }
1557
1558 /// BLOCKING review finding on issue #2906's own PR: `temp_init_shapes`
1559 /// used to be excluded from [`FrameSnapshot`] on the claim that a
1560 /// lambda-local shadow's imprecision here was under-detection-only,
1561 /// never a false positive. It was a false positive — a lambda-local `~
1562 /// temp p = …` of a *different* struct permanently clobbered the
1563 /// *outer* `p`'s entry, surviving past the lambda's own frame, so the
1564 /// outer `p.y = 1.0` (legal — `p` is an `Other`, which does declare
1565 /// `y`) read the lambda's stale `Point` shape instead and false-fired
1566 /// `E185` (`Point` has no field `y`). Reproduced against PR head
1567 /// 98d2ad24 verbatim from the review finding. Native surface only —
1568 /// lambdas don't exist on the ink-compat surface.
1569 #[test]
1570 fn dotted_assign_target_outer_temp_survives_a_lambda_local_shadow_of_the_same_name() {
1571 let diags = check_assignments_all_native(
1572 "struct Point { x: float }\n\
1573 struct Other { y: float }\n\
1574 fn main() {\n\
1575 \x20 let p = Other { y: 0.0 };\n\
1576 \x20 let f = ||: int { let p = Point { x: 0.0 }; 0 };\n\
1577 \x20 p.y = 1.0;\n\
1578 }\n",
1579 );
1580 assert!(diags.is_empty(), "{diags:?}");
1581 }
1582
1583 /// BLOCKING review finding on issue #2906's own PR: `record_ref_param_writes`
1584 /// only ever folded a `ref`-out call-site rebind into `record_write`/
1585 /// `record_fn_write` (the effect-row summary), never into
1586 /// `reassigned_temps` — so `resolve_pending_field_assign_mismatches`
1587 /// stayed blind to a `ref`-out param rebinding the caller's local to an
1588 /// entirely different, unresolvable-shaped value. `ref`-out params are
1589 /// idiomatic ink, not an exotic shape; a callee that always rebinds its
1590 /// `ref` param is common (`reset`, `swap`, …). Reproduced against PR
1591 /// head 98d2ad24 verbatim from the review finding: `p` is a `Point` at
1592 /// its own declaration, but `reset(ref p)` rebinds it to an `Other`
1593 /// before the dotted write, so `p.bogus = 1` must stay silent exactly
1594 /// like the bare-reassignment carve-out above already does.
1595 #[test]
1596 fn dotted_assign_target_stays_silent_after_a_ref_out_param_rebind() {
1597 let diags = check_assignments_all(
1598 "STRUCT Point = #{x: float}\n\
1599 STRUCT Other = #{y: float}\n\
1600 === function reset(ref q) ===\n~ q = Other#{y: 1.0}\n~ return\n\
1601 === main ===\n~ temp p = Point#{x: 0.0}\n~ reset(ref p)\n\
1602 ~ p.bogus = 1\n-> DONE\n",
1603 );
1604 assert!(diags.is_empty(), "{diags:?}");
1605 }
1606
1607 /// BLOCKING review finding (smaller) on issue #2906's own PR:
1608 /// `register_temp_init_shape` used to unconditionally clear
1609 /// `reassigned_temps` on every same-named `TempDecl` redeclaration —
1610 /// erasing reassignment history a fact staged *earlier* (between the
1611 /// reassignment and the redeclaration) depended on, since
1612 /// `pending_inferred_field_assign_mismatches` is resolved once, post-walk.
1613 /// Reproduced against PR head 98d2ad24 verbatim from the review finding:
1614 /// `p` is reassigned to an unresolvable `make_thing()` result before the
1615 /// dotted write (already covered by the "reassigned to an unknown
1616 /// shape" carve-out above), then redeclared again afterward — the
1617 /// redeclaration must not retroactively un-withdraw the staged fact.
1618 #[test]
1619 fn dotted_assign_target_reassignment_history_survives_a_later_redeclaration_of_the_same_name() {
1620 let diags = check_assignments_all(
1621 "STRUCT Point = #{x: float}\n\
1622 EXTERNAL make_thing()\n\
1623 === main ===\n~ temp p = Point#{x: 0.0}\n~ p = make_thing()\n\
1624 ~ p.bogus = 1\n~ temp p = Point#{x: 0.0}\n-> DONE\n",
1625 );
1626 assert!(diags.is_empty(), "{diags:?}");
1627 }
1628
1629 /// Sibling enumeration (issue #1944): `BlockStmt::Assignment` — the T1b
1630 /// `~ { … }` block form — reports E185 exactly like `Stmt::Assignment`
1631 /// above. `infer_block_stmt`'s `BlockStmt::Assignment` arm calls the
1632 /// identical `check_declared_field_assign_target`, recording the same
1633 /// `FieldAssignMismatch` fact `check_field_assign_mismatch` walks
1634 /// regardless of which statement form produced it — the two call sites
1635 /// are structural mirrors, not independently-checked paths.
1636 #[test]
1637 fn field_assignment_to_a_nonexistent_field_inside_a_block_stmt_is_e185_issue_1944() {
1638 let diags = check_assignments_all(
1639 "STRUCT Point = #{x: float}\n\
1640 VAR p: Point = Point#{x: 0.0}\n\
1641 === main ===\n~ {\n p.bogus = \"wrong\"\n}\n-> DONE\n",
1642 );
1643 assert_eq!(diags.len(), 1, "{diags:?}");
1644 assert_eq!(diags[0].code, DiagnosticCode::E185);
1645 }
1646
1647 #[test]
1648 fn bare_var_assignment_is_not_double_reported_by_check_assignments() {
1649 // A single-segment target is `check_declared_assign_target`'s job
1650 // (issue #1877 / E063 via `strict::check_typed_assign_mismatches`),
1651 // never this dotted-target check's — `check_assignments` must stay
1652 // silent for it (no double-report across the two checks).
1653 let diags = check_assignments_all("VAR v: int = 5\n=== main ===\n~ v = \"hi\"\n-> DONE\n");
1654 assert!(diags.is_empty(), "{diags:?}");
1655 }
1656
1657 // ─── check_duplicates (E084, issue #675) ──────────────────────────
1658
1659 #[test]
1660 fn duplicate_field_is_e084_naming_the_field() {
1661 let (hir, _index) = build(
1662 "STRUCT Point = #{x: float, y: float}\n\
1663 === main ===\n~ p = Point#{x: 1.0, x: 2.0, y: 3.0}\n-> DONE\n",
1664 );
1665 let diags = check_duplicates(&[(FileId(0), &hir)]);
1666 assert_eq!(diags.len(), 1, "{diags:?}");
1667 assert_eq!(diags[0].code, DiagnosticCode::E084);
1668 assert!(diags[0].message.contains('x'), "{:?}", diags[0].message);
1669 }
1670
1671 #[test]
1672 fn duplicate_field_points_at_the_repeated_occurrence_not_the_first() {
1673 let src =
1674 "STRUCT Point = #{x: float}\n=== main ===\n~ p = Point#{x: 1.0, x: 2.0}\n-> DONE\n";
1675 let (hir, _index) = build(src);
1676 let diags = check_duplicates(&[(FileId(0), &hir)]);
1677 assert_eq!(diags.len(), 1, "{diags:?}");
1678 let second_x = src.rfind("x: 2.0").expect("second x initializer");
1679 assert_eq!(usize::from(diags[0].range.start()), second_x);
1680 }
1681
1682 #[test]
1683 fn clean_construction_has_no_duplicate_diagnostic() {
1684 let (hir, _index) = build(
1685 "STRUCT Point = #{x: float, y: float}\n\
1686 === main ===\n~ p = Point#{x: 1.0, y: 2.0}\n-> DONE\n",
1687 );
1688 let diags = check_duplicates(&[(FileId(0), &hir)]);
1689 assert!(diags.is_empty(), "{diags:?}");
1690 }
1691
1692 #[test]
1693 fn duplicate_field_flagged_even_under_gradual_and_unresolved_shape() {
1694 // No `types = strict` context needed here (this build() harness
1695 // never runs `strict::check`'s gate) — and the shape name doesn't
1696 // even need to resolve, unlike `check`'s missing/extra/mistyped
1697 // trio: a repeated field name is a mistake regardless.
1698 let (hir, _index) = build("=== main ===\n~ p = Bogus#{x: 1, x: 2}\n-> DONE\n");
1699 let diags = check_duplicates(&[(FileId(0), &hir)]);
1700 assert_eq!(diags.len(), 1, "{diags:?}");
1701 assert_eq!(diags[0].code, DiagnosticCode::E084);
1702 }
1703
1704 #[test]
1705 fn duplicate_field_inside_var_initializer_is_checked() {
1706 let (hir, _index) = build("STRUCT Point = #{x: float}\nVAR p = Point#{x: 1.0, x: 2.0}\n");
1707 let diags = check_duplicates(&[(FileId(0), &hir)]);
1708 assert_eq!(diags.len(), 1, "{diags:?}");
1709 assert_eq!(diags[0].code, DiagnosticCode::E084);
1710 }
1711
1712 // ─── issue #1764: a lambda's statements in a VAR/CONST initializer ──
1713
1714 /// Coverage for a lambda's statements in a VAR/CONST initializer comes
1715 /// from `visit::visit_with_decl_initializers` (which reaches the
1716 /// initializer at all) composed with `walk_expr`'s `Expr::Lambda` arm
1717 /// (which already descends a lambda's statements) — there is no
1718 /// separate hand-rolled recursion for this position (issue #2098). A
1719 /// block-bodied lambda's `let` is a statement, not the body's value
1720 /// expression.
1721 #[test]
1722 fn a_duplicate_field_in_a_lambda_statement_of_a_var_initializer_is_reported() {
1723 let (hir, _index, _res, _inf) = build_native(
1724 "struct Point { x: float }\nvar f = ||: int {\n let p = Point { x: 1.0, x: 2.0 };\n 0\n};\n",
1725 );
1726 let diags = check_duplicates(&[(FileId(0), &hir)]);
1727 assert_eq!(diags.len(), 1, "{diags:?}");
1728 assert_eq!(diags[0].code, DiagnosticCode::E084);
1729 }
1730
1731 /// The shape-agreement trio reaches the same position — a literal-valued
1732 /// initializer classifies without any locals, so `MistypeCtx::locals =
1733 /// None` is no obstacle here.
1734 #[test]
1735 fn a_mistyped_field_in_a_lambda_statement_of_a_var_initializer_is_reported() {
1736 let diags = check_all_native(
1737 "struct Point { x: float }\nvar f = ||: int {\n let p = Point { x: \"hi\" };\n 0\n};\n",
1738 );
1739 assert_eq!(diags.len(), 1, "{diags:?}");
1740 assert_eq!(diags[0].code, DiagnosticCode::E071);
1741 assert!(diags[0].message.contains('x'), "{:?}", diags[0].message);
1742 }
1743
1744 /// The tail position was already covered — pinned so a later refactor
1745 /// can't trade one half of the body for the other.
1746 #[test]
1747 fn a_mistyped_field_in_a_lambda_tail_of_a_var_initializer_is_still_reported() {
1748 let diags = check_all_native(
1749 "struct Point { x: float }\nvar f = ||: Point {\n let a = 1;\n Point { x: \"hi\" }\n};\n",
1750 );
1751 assert_eq!(diags.len(), 1, "{diags:?}");
1752 assert_eq!(diags[0].code, DiagnosticCode::E071);
1753 }
1754
1755 // ─── issue #2773: a lambda-own binding must not inherit an outer
1756 // same-named local's type ────────────────────────────────────────
1757
1758 /// Reproduces the hazard issue #2773 tracks, live in this file's own
1759 /// `ConstructionVisitor` before its `enter_lambda`/`exit_lambda` frame
1760 /// existed: `resolved_symbol_ty` reads `ctx.locals` (bare-name-keyed
1761 /// `BodyTypes::locals`) with no shadowing frame of its own, and
1762 /// `ConstructionVisitor` is `HirVisitor`-driven — `hir::visit::walk_expr`
1763 /// has descended into a lambda's own block body since issue #1685, so
1764 /// every `enter_expr` this visitor received for an expression inside the
1765 /// lambda body was already reading the *enclosing* `build`'s locals,
1766 /// unpruned. `build`'s own temp `x` is `array`-typed
1767 /// (`[1, 2, 3]`); the lambda's own `x: int` param shadows it. Pre-fix,
1768 /// `Point { x: x }`'s field initializer resolved `x` to the outer
1769 /// `array` — never assignable to `Point.x: float` — a false-positive
1770 /// `E071`. `int` *is* legally assignable to `float` (the directional
1771 /// coercion `int_initializer_for_a_float_field_is_the_legal_coercion`
1772 /// pins above), so the fixed behavior is clean.
1773 #[test]
1774 fn lambda_param_shadowing_outer_local_of_a_different_type_is_not_misclassified() {
1775 let diags = check_all_native(
1776 "struct Point { x: float }\n\
1777 fn build() {\n let x = [1, 2, 3];\n let f = |x: int| {\n let p = Point { x: x };\n };\n}\n",
1778 );
1779 assert!(diags.is_empty(), "{diags:?}");
1780 }
1781
1782 /// The pruning must not silence a *genuine* mistype inside the lambda's
1783 /// own body — only the outer binding's type is discarded, not
1784 /// classification itself. The lambda's own `x: string` param really is
1785 /// the wrong type for `Point.x: float`.
1786 #[test]
1787 fn lambda_param_own_annotation_still_flags_a_genuine_mistype() {
1788 let diags = check_all_native(
1789 "struct Point { x: float }\n\
1790 fn build() {\n let x = [1, 2, 3];\n let f = |x: string| {\n let p = Point { x: x };\n };\n}\n",
1791 );
1792 assert_eq!(diags.len(), 1, "{diags:?}");
1793 assert_eq!(diags[0].code, DiagnosticCode::E071);
1794 }
1795
1796 #[test]
1797 fn three_way_duplicate_flags_every_repeat_after_the_first() {
1798 let (hir, _index) = build(
1799 "STRUCT Point = #{x: float}\n\
1800 === main ===\n~ p = Point#{x: 1.0, x: 2.0, x: 3.0}\n-> DONE\n",
1801 );
1802 let diags = check_duplicates(&[(FileId(0), &hir)]);
1803 assert_eq!(diags.len(), 2, "{diags:?}");
1804 assert!(diags.iter().all(|d| d.code == DiagnosticCode::E084));
1805 }
1806
1807 // ─── issue #2241: declared_shapes is referrer-scoped, not last-wins ──
1808
1809 /// Build a project file coexisting with a "std"-shaped file (M-2d
1810 /// cross-declared-module coexistence, mirroring the real stdlib mount
1811 /// #2080) — each declares its own `STRUCT Cue`, with the project's own
1812 /// carrying MORE fields than the coexisting file's same-named one.
1813 /// Returns everything [`check`] needs to validate the project's own
1814 /// construction literal.
1815 ///
1816 /// No `#@module` directive in either source: the module tag a real
1817 /// compile derives from `#@module`/the native path is supplied directly
1818 /// here via `ModuleMap`, exactly as `manifest::tests`'s own
1819 /// `cross_declared_module_duplicate_coexists_under_brink` does — this
1820 /// test only needs the tag on the index, not the parsed source's own
1821 /// (irrelevant) `HirFile::module`.
1822 fn build_project_with_std_homonym(
1823 project_src: &str,
1824 std_src: &str,
1825 ) -> (
1826 FileId,
1827 HirFile,
1828 FileId,
1829 HirFile,
1830 SymbolIndex,
1831 ResolutionMap,
1832 InferenceResult,
1833 ) {
1834 let project_file = FileId(0);
1835 let std_file = FileId(1);
1836
1837 let project_parsed = brink_syntax::parse(project_src);
1838 let (project_hir, project_manifest, _diag) = lower(project_file, &project_parsed.tree());
1839 let std_parsed = brink_syntax::parse(std_src);
1840 let (std_hir, std_manifest, _diag) = lower(std_file, &std_parsed.tree());
1841
1842 let mut modules = crate::ModuleMap::new();
1843 modules.insert(
1844 project_file,
1845 crate::ResolvedModule {
1846 name: "story::main".to_string(),
1847 declared: true,
1848 was: None,
1849 },
1850 );
1851 modules.insert(
1852 std_file,
1853 crate::ResolvedModule {
1854 name: "std::conventions::screenplay".to_string(),
1855 declared: true,
1856 was: None,
1857 },
1858 );
1859
1860 let (index, diag) = crate::symbol_index_with_modules(
1861 &[(project_file, &project_manifest), (std_file, &std_manifest)],
1862 &modules,
1863 crate::Dialect::Brink,
1864 false,
1865 );
1866 assert!(
1867 diag.is_empty(),
1868 "cross-declared-module `Cue`s must coexist with no diagnostic: {diag:?}"
1869 );
1870
1871 let project_scope =
1872 crate::ImportScope::new(Some("story::main".to_string()), &project_hir.imports);
1873 let (project_resolutions, _diag) =
1874 crate::resolve(project_file, &project_manifest, &index, &project_scope);
1875 let std_scope = crate::ImportScope::new(
1876 Some("std::conventions::screenplay".to_string()),
1877 &std_hir.imports,
1878 );
1879 let (std_resolutions, _diag) = crate::resolve(std_file, &std_manifest, &index, &std_scope);
1880
1881 let mut resolutions: ResolutionMap = (*project_resolutions).clone();
1882 resolutions.extend((*std_resolutions).iter().cloned());
1883
1884 let files = [(project_file, &project_hir), (std_file, &std_hir)];
1885 let inference = crate::infer_project(&files, &index, &resolutions, None, &BTreeMap::new());
1886
1887 (
1888 project_file,
1889 project_hir,
1890 std_file,
1891 std_hir,
1892 (*index).clone(),
1893 resolutions,
1894 inference,
1895 )
1896 }
1897
1898 /// The wave's own headline scenario: a project's own `STRUCT Cue`
1899 /// coexists with a same-named `STRUCT Cue` from a distinct declared
1900 /// module (mirrors `std/conventions/screenplay.brink`'s real one-field
1901 /// `Cue`). Before this fix, `declared_shapes` built a flat
1902 /// `BTreeMap<String, ShapeInfo>` via plain last-`insert`-wins — with
1903 /// `files` ordered `[project, std]` below, std's `ShapeInfo` is inserted
1904 /// LAST and silently overwrites the project's own in the table, even
1905 /// though the construction literal itself lives in the project file and
1906 /// `resolve::resolve_struct_ref` already resolves it to the PROJECT's own
1907 /// `Cue` (never std's — the referrer's own module wins that tie-break).
1908 /// The missing-field check would then validate against std's one-field
1909 /// shape, which the literal's sole `speaker` initializer already
1910 /// satisfies — a silent E069 false negative: "accepted when it should
1911 /// error" (issue #2241's own words).
1912 ///
1913 /// Rule 20a: verified this test FAILS on the pre-fix code (reverting
1914 /// `declared_shapes` to the flat bare-name `BTreeMap::insert` and
1915 /// `check_literal` to `shapes.get(&sl.shape.text)`) — the assertion
1916 /// below (`diags.len() == 1`, `E069` naming `voiceover`) fails with
1917 /// `diags` empty instead, because std's one-field shape (which won the
1918 /// last-insert race with `files = [project, std]`) sees the literal's
1919 /// sole `speaker` field as complete.
1920 #[test]
1921 fn construction_check_resolves_the_referrers_own_shape_when_std_and_project_share_a_name() {
1922 let project_src = "STRUCT Cue = #{speaker: string, voiceover: string}\n\
1923 === main ===\n~ p = Cue#{speaker: \"A\"}\n-> DONE\n";
1924 let std_src = "STRUCT Cue = #{speaker: string}\nHello.\n";
1925
1926 let (project_file, project_hir, std_file, std_hir, index, resolutions, inference) =
1927 build_project_with_std_homonym(project_src, std_src);
1928
1929 // Deliberately `[project, std]` — std's shape is inserted LAST into
1930 // the pre-fix flat table, exposing the last-wins bug.
1931 let files = [(project_file, &project_hir), (std_file, &std_hir)];
1932 let diags = check(&files, &index, &inference, &resolutions);
1933
1934 assert_eq!(diags.len(), 1, "{diags:?}");
1935 assert_eq!(diags[0].code, DiagnosticCode::E069);
1936 assert!(
1937 diags[0].message.contains("voiceover"),
1938 "the missing-field diagnostic must name the PROJECT's own missing field \
1939 (`voiceover`), proving the check validated the literal against the project's own \
1940 2-field `Cue` shape rather than the coexisting file's 1-field one: {diags:?}"
1941 );
1942 }
1943
1944 /// F2 review finding (#2253): [`ShapeTable::resolve`] is the path
1945 /// [`check_assignments`]/[`check_field_assign_mismatch`] (E063) uses —
1946 /// unlike [`check`]/[`check_literal`] above, which never calls
1947 /// `resolve` at all (it goes through [`ShapeTable::get_by_def`] with an
1948 /// identity already resolved by `resolve::resolve_struct_ref`). This
1949 /// exercises the multi-candidate branch `resolve` exists to handle,
1950 /// through its own dedicated consumer rather than a proxy.
1951 ///
1952 /// Project and std each declare their own `STRUCT Cue`, deliberately
1953 /// with *different* declared types for the same field name (`x: float`
1954 /// vs `x: string`) so a wrong resolution doesn't just report the wrong
1955 /// message — it silently reports NOTHING: assigning the string
1956 /// `"wrong"` to `p.x` disagrees with the project's own `float`, but
1957 /// would agree with std's `string`. If `resolve` ever picked std's
1958 /// `Cue` for a reference inside the project file, this regresses to an
1959 /// empty `diags` exactly like the pre-fix last-insert-wins bug did for
1960 /// E069 above.
1961 ///
1962 /// Rule 20a: verified this test FAILS (empty `diags` instead of one
1963 /// `E063`) against `ShapeTable::resolve` reverted to always return the
1964 /// std candidate (i.e. simulating a resolution that ignores the
1965 /// referrer's own module) — restored before committing.
1966 #[test]
1967 fn check_assignments_resolves_the_referrers_own_shape_when_std_and_project_share_a_name() {
1968 let project_src = "STRUCT Cue = #{x: float}\n\
1969 VAR p: Cue = Cue#{x: 0.0}\n\
1970 === main ===\n~ p.x = \"wrong\"\n-> DONE\n";
1971 let std_src = "STRUCT Cue = #{x: string}\nHello.\n";
1972
1973 let (project_file, project_hir, std_file, std_hir, index, _resolutions, inference) =
1974 build_project_with_std_homonym(project_src, std_src);
1975
1976 let files = [(project_file, &project_hir), (std_file, &std_hir)];
1977 let diags = check_assignments(&files, &index, &inference);
1978
1979 assert_eq!(diags.len(), 1, "{diags:?}");
1980 assert_eq!(diags[0].code, DiagnosticCode::E063);
1981 assert!(
1982 diags[0].message.contains("float"),
1983 "the mismatch must be reported against the PROJECT's own `float`-declared `x`, not \
1984 std's `string`-declared one — which would silently accept the identically-typed \
1985 \"wrong\" RHS and produce zero diagnostics: {diags:?}"
1986 );
1987 }
1988}