brink_analyzer/coalesce.rs
1//! B1 `or`-coalescing typing: the recorded operand/result types
2//! ([`resolve`]) and the static mismatch check derived from them
3//! ([`check`]) — `docs/stdlib-spec.md` §1.6a, issues #1460 and #1492.
4//!
5//! ## Two halves of one pass (issue #1492)
6//!
7//! RULED (maintainer, 2026-07-26, `docs/decision-log.md` "Lowering consumes
8//! analyzer types"): **typing verdicts belong to the analyzer; lowering
9//! consumes recorded types, never re-derives them.** A syntactic
10//! shape-sniff in LIR lowering cannot see through an `Expr::Call` to its
11//! declared return type, nor through a bare `Path` to a `VAR`/temp declared
12//! `Option[T]` — both are type questions, and the answer already exists
13//! here.
14//!
15//! So this pass produces two things from one walk:
16//!
17//! - a [`CoalesceTable`] — the `node → verdict` side channel LIR lowering
18//! reads to pick a chain's code shape ("inner stays `Option`" vs "unwrap
19//! at the end"), reusing #1482's [`SideTable`] plumbing verbatim rather
20//! than inventing a second mechanism; and
21//! - the `E066` diagnostics, which are now *derived from the recorded
22//! verdicts* rather than computed alongside them, so a chain that lowers
23//! and a chain that is rejected can never disagree about its own types.
24//!
25//! ## Chains, and why the table is keyed at the chain root
26//!
27//! `a or b or c` is a left-associative `Expr::Infix(Infix(a, or, b), or,
28//! c)`. One entry is recorded per **chain root**, carrying every step's
29//! verdict in innermost-first order ([`CoalesceChain::steps`]), because the
30//! fold is what produces the verdicts: a step's left-hand type is the
31//! previous step's *result*, not anything re-derivable from the spine node
32//! in isolation.
33//!
34//! The root's [`NodeKey`] is `brink_ir::hir::expr_span` of the root — since
35//! issue #1517 the root `Expr::Infix`'s **own `Provenance` range**, which
36//! strictly contains its left operand's, so a chain and its own left spine
37//! are always distinct keys. Before #1517 they were not (an infix node had
38//! no provenance and a trailing scalar literal contributed no range, so
39//! `some(a) or f() or 99` keyed identically to `some(a) or f()`), and this
40//! pass had to poison any key two roots would share. That workaround is
41//! gone; nothing here drops an entry to avoid an ambiguous key.
42//!
43//! Absence is still always safe: a consumer with no verdict falls back to
44//! the runtime check, which is what gradual mode does anyway.
45//!
46//! ## The old shape, retained
47//!
48//! `infer::ty::coalesce`'s two failure shapes (`CoalesceError::LeftNotOption`,
49//! `CoalesceError::Mismatch`) were being silently absorbed into
50//! `Ty::Conflicted` by `infer::body::InferPass::infer_infix`'s
51//! `InfixOp::Coalesce` arm, with no diagnostic ever raised at the
52//! coalescing expression itself. The arm's own doc comment claimed the
53//! generic `E066` Conflicted-escape check (`strict::check`) was a
54//! sufficient backstop — it is not: that check only fires once a
55//! `Conflicted` value reaches a *signature or body-local slot* boundary,
56//! which a coalescing expression used directly in content/argument
57//! position (`{some(1) or "text"}`, never bound to a slot) never does. This
58//! module closes that gap directly, at the coalescing expression's own
59//! site, mirroring `conversions`/`range_refinement`/`option_conditions`'s
60//! own strict-mode-only, expression-position posture exactly (the same
61//! `strict::check` wiring point, the same `structs::classify_expr_ty`
62//! inference-substrate classification, the same "Unknown never disagrees —
63//! stays silently unchecked, the runtime fault is the residual backstop"
64//! posture for anything not statically classifiable).
65//!
66//! Folding the chain (issue #1492) *widens* that check: before, only a
67//! chain's innermost step was ever judged, because `classify_expr_ty`
68//! returns `None` for an `Expr::Infix` operand, so `{some(1) or none or
69//! "text"}` passed analysis silently. The fold feeds each step's recorded
70//! result type in as the next step's left-hand type, so every step is
71//! judged — the issue's "an ill-typed chain never reaches lowering".
72//!
73//! Strict-mode-only: under `types = gradual` (including native's
74//! un-overridden default — B0.10's dialect-keyed strict-only wiring has not
75//! landed, `strict.rs`'s own `native_strict_only_error` doc) this module is
76//! never invoked, and the runtime `TypeError` fault
77//! (`brink_runtime::value_ops::coalesce_unwrap_some`, backing
78//! `Opcode::CoalesceSome` — issue #1471 replaced the binary `Coalesce`
79//! opcode this originally named with a short-circuiting branch) is the sole
80//! backstop — see that
81//! function's doc for the fault's actual (narrower-than-previously-claimed)
82//! coverage: it only catches a non-Option left-hand side, not a mismatched
83//! fallback type.
84//!
85//! Reuses `infer::ty::coalesce` itself — the identical typing rule
86//! `infer::body::InferPass::infer_infix`'s `InfixOp::Coalesce` arm calls —
87//! rather than re-deriving a parallel mismatch rule, so the two can never
88//! drift apart.
89
90use std::collections::{BTreeMap, BTreeSet};
91
92use brink_format::DefinitionId;
93use brink_ir::hir::expr_span;
94use brink_ir::hir::visit::{self, ContentContext, HirVisitor};
95use brink_ir::{
96 Choice, ConstDecl, Content, Diagnostic, DiagnosticCode, Expr, FileId, HirFile, InfixOp, Knot,
97 ResolutionMap, Stitch, Stmt, SymbolIndex, SymbolKind, VarDecl,
98};
99use rowan::TextRange;
100
101use crate::annotations;
102use crate::infer::{self, CoalesceError, InferenceResult, InferredSig, Ty};
103use crate::structs::{self, MistypeCtx};
104use crate::ufcs::{NodeKey, SideTable};
105
106// ─── The recorded verdict (issue #1492) ──────────────────────────────
107
108/// Which of `infer::ty::coalesce`'s three outcomes one `or` step took —
109/// the shape question LIR lowering asks, answered from types instead of
110/// syntax.
111#[derive(Debug, Clone, Copy, PartialEq, Eq)]
112pub enum CoalesceShape {
113 /// `Option[T] or Option[U]` — optionality survives the step, so the
114 /// step's value stays an `Option` for whatever consumes it (the next
115 /// step of a chain, or the expression's own consumer).
116 PreserveOption,
117 /// `Option[T] or U` — the step collapses to the plain value type.
118 Collapse,
119 /// The left-hand type is not statically pinned (`Ty::Unknown` /
120 /// `Ty::Conflicted` — gradual mode, or a strict escape already reported
121 /// by `E065`/`E066`).
122 ///
123 /// **The runtime check is the semantics here** (RULED 2026-07-26, and
124 /// documented on `brink_format::Opcode::CoalesceSome`): an `Option` value
125 /// coalesces, a plain value faults, exactly like every other gradual
126 /// runtime check. A consumer must not statically commit to either
127 /// shape on this verdict.
128 RuntimeCheck,
129}
130
131/// The recorded types of one `or` step, in the left-associative order the
132/// grammar builds.
133#[derive(Debug, Clone, PartialEq, Eq)]
134pub struct CoalesceStep {
135 /// The left-hand type. For the innermost step this is the classified
136 /// operand; for every later step it is the previous step's `result`.
137 pub lhs: Ty,
138 /// The fallback operand's classified type.
139 pub rhs: Ty,
140 /// `infer::ty::coalesce(lhs, rhs)` — the step's value type.
141 pub result: Ty,
142 /// The shape [`Self::result`] implies for a consumer.
143 pub shape: CoalesceShape,
144}
145
146/// Every step of one `or`-coalescing chain, innermost first.
147///
148/// `a or b or c` records two steps: `[a or b, (that) or c]`. A consumer
149/// walking the HIR meets the chain root *first* and descends its left
150/// spine, so it consumes this vector back-to-front; the order is fixed
151/// here (and only here) so producer and consumer cannot drift.
152#[derive(Debug, Clone, PartialEq, Eq)]
153pub struct CoalesceChain {
154 /// The chain's steps, innermost first. Never empty.
155 pub steps: Vec<CoalesceStep>,
156}
157
158/// Every `or`-coalescing chain's recorded typing, keyed at the chain root
159/// (see the module doc for why not per step).
160pub type CoalesceTable = SideTable<CoalesceChain>;
161
162/// Translate a [`CoalesceTable`] into `brink-ir`'s own lowering-facing
163/// mirror — the **one** translation point between the two crates (issue
164/// #1471), exactly as [`crate::ufcs_lir_lookup`] is for the UFCS table.
165///
166/// `brink-ir` sits below `brink-analyzer` in the crate graph, so it cannot
167/// name [`CoalesceShape`]; `brink_ir::lir::CoalesceShape`'s own doc explains
168/// the mirror. Only the per-step *shape* crosses — the recorded `Ty`s
169/// themselves are analysis detail lowering has no use for. Step order
170/// (innermost first) is preserved verbatim: it is the order
171/// `lir::lower::expr::lower_coalesce_chain` folds a chain in.
172#[must_use]
173pub fn to_lir_lookup(table: &CoalesceTable) -> brink_ir::lir::CoalesceLookup {
174 let entries = table
175 .iter()
176 .map(|(key, chain)| {
177 let range = TextRange::new(key.range.0.into(), key.range.1.into());
178 let shapes = chain
179 .steps
180 .iter()
181 .map(|step| match step.shape {
182 CoalesceShape::PreserveOption => brink_ir::lir::CoalesceShape::PreserveOption,
183 CoalesceShape::Collapse => brink_ir::lir::CoalesceShape::Collapse,
184 CoalesceShape::RuntimeCheck => brink_ir::lir::CoalesceShape::RuntimeCheck,
185 })
186 .collect();
187 (key.file, range, shapes)
188 })
189 .collect();
190 brink_ir::lir::CoalesceLookup::from_entries(entries)
191}
192
193/// Cheap structural scan: does any expression in `hir` coalesce? The
194/// laziness gate for [`resolve`]'s caller — a project with no `or`-coalescing
195/// anywhere (every ink-dialect project, by construction: `InfixOp::Coalesce`
196/// is native-lowering-only) never triggers whole-project inference on this
197/// pass's account, mirroring [`crate::project_has_ufcs_call`]'s own shape.
198///
199/// Covers the file-level `VAR`/`CONST` initializers too (issue #2098: via
200/// [`visit::visit_with_decl_initializers`], not a hand-rolled second walk —
201/// `Scan` has no state that needs resetting per-decl, so the shared entry
202/// point alone covers both the block tree and every initializer).
203#[must_use]
204pub fn project_has_coalesce(hir: &HirFile) -> bool {
205 struct Scan {
206 found: bool,
207 }
208 impl HirVisitor for Scan {
209 fn visit_exprs(&self) -> bool {
210 true
211 }
212 fn enter_expr(&mut self, expr: &Expr) {
213 if coalesce_operands(expr).is_some() {
214 self.found = true;
215 }
216 }
217 }
218 let mut scan = Scan { found: false };
219 visit::visit_with_decl_initializers(hir, &mut scan);
220 scan.found
221}
222
223/// Record every `or`-coalescing chain's operand/result types and report the
224/// `E066` mismatches that fall out of the same fold.
225///
226/// Callers only reach this once `strict::config_error` has confirmed
227/// `types = strict` + `dialect = brink` (mirrors `conversions::check`'s own
228/// entry condition — same wiring point, `strict::check`) — *for the
229/// diagnostics*. The table half is served separately through
230/// [`crate::coalesce_types`], mirroring `ufcs_resolution`'s split for the
231/// same reason: the two consumers want opposite halves of one result and
232/// neither should pay for the other's.
233#[must_use]
234pub fn resolve(
235 files: &[(FileId, &HirFile)],
236 index: &SymbolIndex,
237 inference: &InferenceResult,
238 resolutions: &ResolutionMap,
239) -> (CoalesceTable, Vec<Diagnostic>) {
240 let globals = crate::infer::collect_globals(files, index, None);
241 let mut out = Vec::new();
242 let mut table = CoalesceTable::new();
243 for &(file, hir) in files {
244 let resolution_by_range = resolution_index(resolutions, file);
245 let mut v = CoalesceVisitor {
246 file,
247 index,
248 globals: &globals,
249 signatures: &inference.signatures,
250 bodies: &inference.bodies,
251 resolution_by_range: &resolution_by_range,
252 current_knot_name: None,
253 knot_locals: None,
254 stitch_locals: None,
255 fallback: TextRange::new(0.into(), 0.into()),
256 spine: BTreeSet::new(),
257 table: &mut table,
258 lambda_locals: Vec::new(),
259 diagnostics: &mut out,
260 };
261 // Issue #2098: `CoalesceVisitor::enter_var_decl`/`enter_const_decl`
262 // reset `fallback` (and the knot/stitch locals) to the declaration's
263 // own scope before its initializer's expressions arrive, so the
264 // shared entry point covers the block tree and every file-level
265 // declaration's own initializer in one drive — the hand-rolled
266 // `check_expr`/`expr_children` mirror of `visit::visit`'s own
267 // descent this used to need is gone.
268 visit::visit_with_decl_initializers(hir, &mut v);
269 }
270 (table, out)
271}
272
273/// Strict-mode-only `or`-coalescing mismatch checks over every
274/// `InfixOp::Coalesce` expression in the project — [`resolve`]'s diagnostic
275/// half, the shape `strict::check` wires in.
276#[must_use]
277pub fn check(
278 files: &[(FileId, &HirFile)],
279 index: &SymbolIndex,
280 inference: &InferenceResult,
281 resolutions: &ResolutionMap,
282) -> Vec<Diagnostic> {
283 resolve(files, index, inference, resolutions).1
284}
285
286struct CoalesceVisitor<'a> {
287 file: FileId,
288 index: &'a SymbolIndex,
289 globals: &'a BTreeMap<DefinitionId, Ty>,
290 signatures: &'a BTreeMap<DefinitionId, InferredSig>,
291 bodies: &'a BTreeMap<DefinitionId, crate::infer::BodyTypes>,
292 resolution_by_range: &'a BTreeMap<(u32, u32), DefinitionId>,
293 /// The currently-open knot's own name — `enter_stitch` needs it to
294 /// reconstruct the qualified `knot.stitch` name a stitch is indexed
295 /// under. Mirrors `structs::ConstructionVisitor`'s identical field.
296 current_knot_name: Option<String>,
297 /// The enclosing knot's own finalized locals, set for the duration of
298 /// its body (and every stitch nested inside it, until `enter_stitch`
299 /// overrides it with the stitch's own). Mirrors
300 /// `structs::ConstructionVisitor`'s identical field.
301 knot_locals: Option<&'a BTreeMap<String, Ty>>,
302 /// The currently-open stitch's own finalized locals, if any — takes
303 /// priority over `knot_locals` while set.
304 stitch_locals: Option<&'a BTreeMap<String, Ty>>,
305 /// Diagnostic anchor of last resort: the nearest enclosing statement's
306 /// (or content line's, or choice's) own `Provenance` range, updated as
307 /// the walk descends. A coalescing operand carries its own tighter
308 /// range whenever [`expr_anchor`] can find one (a path, a call's
309 /// callee); this is only reached for operand shapes with none of their
310 /// own (a bare literal — `{5 or 9}`, the review-finding fixture).
311 fallback: TextRange,
312 /// Addresses of the coalescing nodes already consumed as part of an
313 /// enclosing chain. `walk_expr` calls `enter_expr` on every node of a
314 /// chain's left spine as well as on its root, but a chain is analysed
315 /// (and recorded) exactly once, at its root; this is how the spine
316 /// nodes are recognized on the way past. Addresses only — never
317 /// dereferenced, and stable because the HIR is not mutated during the
318 /// walk. Membership only — never iterated — but a `BTreeSet` anyway,
319 /// per the crate's determinism lint.
320 spine: BTreeSet<usize>,
321 /// The chain-root verdicts recorded so far.
322 table: &'a mut CoalesceTable,
323 /// Issue #2773: a stack of pruned-locals frames, one per currently-open
324 /// lambda literal (innermost last). Mirrors
325 /// `structs::ConstructionVisitor`'s identical field/hook pair exactly —
326 /// see that field's own doc.
327 lambda_locals: Vec<BTreeMap<String, Ty>>,
328 diagnostics: &'a mut Vec<Diagnostic>,
329}
330
331impl CoalesceVisitor<'_> {
332 fn current_locals(&self) -> Option<&BTreeMap<String, Ty>> {
333 self.lambda_locals
334 .last()
335 .or_else(|| self.stitch_locals.or(self.knot_locals))
336 }
337
338 /// The `DefinitionId` a knot/stitch's own name resolves to — mirrors
339 /// `structs::ConstructionVisitor::knot_def_id` exactly (same #626
340 /// top-level-stitch-promoted-to-knot rationale).
341 fn knot_def_id(&self, knot: &Knot) -> Option<DefinitionId> {
342 let kind = knot.symbol_kind();
343 annotations::def_id_for(self.index, self.file, kind, &knot.name.text)
344 }
345}
346
347impl HirVisitor for CoalesceVisitor<'_> {
348 fn visit_exprs(&self) -> bool {
349 true
350 }
351
352 fn enter_knot(&mut self, knot: &Knot) {
353 self.current_knot_name = Some(knot.name.text.clone());
354 self.knot_locals = self
355 .knot_def_id(knot)
356 .and_then(|id| self.bodies.get(&id))
357 .map(|b| &b.locals);
358 }
359
360 fn exit_knot(&mut self, _knot: &Knot) {
361 self.current_knot_name = None;
362 self.knot_locals = None;
363 }
364
365 fn enter_stitch(&mut self, stitch: &Stitch) {
366 // Stitches are indexed by qualified `knot.stitch` name — mirrors
367 // `structs::ConstructionVisitor::enter_stitch` exactly.
368 self.stitch_locals = self.current_knot_name.as_ref().and_then(|knot_name| {
369 let qualified = format!("{knot_name}.{}", stitch.name.text);
370 annotations::def_id_for(self.index, self.file, SymbolKind::Stitch, &qualified)
371 .and_then(|id| self.bodies.get(&id))
372 .map(|b| &b.locals)
373 });
374 }
375
376 fn exit_stitch(&mut self, _stitch: &Stitch) {
377 self.stitch_locals = None;
378 }
379
380 /// Issue #2098: a file-level `VAR` sits outside any knot/stitch, so its
381 /// initializer needs the same "no enclosing def" reset `exit_knot`/
382 /// `exit_stitch` already give the walk when it *leaves* one — plus its
383 /// own range as the diagnostic anchor of last resort (this replaces the
384 /// hand-rolled `check_expr` recursion's explicit `fallback` parameter,
385 /// which passed `var.ptr.text_range()` for exactly this reason).
386 fn enter_var_decl(&mut self, var: &VarDecl) {
387 self.fallback = var.ptr.text_range();
388 self.current_knot_name = None;
389 self.knot_locals = None;
390 self.stitch_locals = None;
391 }
392
393 /// [`HirVisitor::enter_var_decl`]'s `CONST` twin.
394 fn enter_const_decl(&mut self, konst: &ConstDecl) {
395 self.fallback = konst.ptr.text_range();
396 self.current_knot_name = None;
397 self.knot_locals = None;
398 self.stitch_locals = None;
399 }
400
401 fn enter_stmt(&mut self, stmt: &Stmt) {
402 if let Some(range) = stmt_anchor(stmt) {
403 self.fallback = range;
404 }
405 }
406
407 fn enter_content(&mut self, content: &Content, _ctx: ContentContext) {
408 if let Some(ptr) = content.ptr {
409 self.fallback = ptr.text_range();
410 }
411 }
412
413 fn enter_choice(&mut self, choice: &Choice) {
414 self.fallback = choice.ptr.text_range();
415 }
416
417 fn enter_expr(&mut self, expr: &Expr) {
418 // A chain's left spine is analysed at its root, so the spine nodes
419 // `walk_expr` hands over on the way down are consumed and dropped.
420 if self.spine.remove(&std::ptr::from_ref(expr).addr()) {
421 return;
422 }
423 if coalesce_operands(expr).is_none() {
424 return;
425 }
426 for node in chain_spine(expr).iter().skip(1) {
427 self.spine.insert(std::ptr::from_ref(*node).addr());
428 }
429 // Built from direct field projections (not `self.ctx()`) so the
430 // borrow checker sees this only borrows the locals-shaped fields,
431 // disjoint from the `self.table`/`self.diagnostics` reborrows below
432 // — see `structs::ConstructionVisitor::enter_expr`'s identical
433 // comment.
434 let ctx = MistypeCtx {
435 index: self.index,
436 globals: self.globals,
437 signatures: self.signatures,
438 resolution_by_range: self.resolution_by_range,
439 locals: self
440 .lambda_locals
441 .last()
442 .or_else(|| self.stitch_locals.or(self.knot_locals)),
443 };
444 analyze_chain(
445 expr,
446 self.fallback,
447 self.file,
448 &ctx,
449 self.table,
450 self.diagnostics,
451 );
452 }
453
454 fn enter_lambda(&mut self, l: &brink_ir::LambdaExpr) {
455 let pruned = structs::pruned_locals_for_lambda(l, self.index, self.current_locals());
456 self.lambda_locals.push(pruned);
457 }
458
459 fn exit_lambda(&mut self, _l: &brink_ir::LambdaExpr) {
460 self.lambda_locals.pop();
461 }
462}
463
464/// The two operands of a coalescing node, or `None` for anything else.
465fn coalesce_operands(expr: &Expr) -> Option<(&Expr, &Expr)> {
466 match expr {
467 Expr::Infix(ie) if ie.op == InfixOp::Coalesce => Some((&ie.lhs, &ie.rhs)),
468 _ => None,
469 }
470}
471
472/// The coalescing chain rooted at `root`, **outermost first**: `root`
473/// itself, then its left-hand operand for as long as that is a coalescing
474/// node too. `a or b or c` yields `[(… or c), (a or b)]`.
475///
476/// Empty when `root` is not a coalescing node at all.
477fn chain_spine(root: &Expr) -> Vec<&Expr> {
478 let mut spine = Vec::new();
479 let mut cursor = root;
480 while let Some((lhs, _)) = coalesce_operands(cursor) {
481 spine.push(cursor);
482 cursor = lhs;
483 }
484 spine
485}
486
487/// Fold the coalescing chain rooted at `root`, left-associatively: classify
488/// the innermost left-hand operand once, then feed each step's result type
489/// in as the next step's left-hand type ([`infer::coalesce`] — the same rule
490/// `infer::body`'s own `InfixOp::Coalesce` arm calls, never a parallel one).
491///
492/// A step that types cleanly is recorded; the first step that disagrees
493/// raises `E066` and abandons the chain (nothing is recorded — an ill-typed
494/// chain must never hand a consumer a verdict). The innermost left-hand
495/// operand not classifying to a statically-known [`Ty`] (an untyped
496/// parameter with no other use, say) does **not** abandon the chain: it is
497/// recorded as [`Ty::Unknown`], which `infer::coalesce` always accepts
498/// (`(Unknown, _) -> Ok(Unknown)`, never an error), so the step is recorded
499/// with [`CoalesceShape::RuntimeCheck`] — the unpinned-`lhs` posture
500/// `Opcode::CoalesceSome`'s own doc describes, and it propagates: every later
501/// step folds from `Unknown` too. A step whose *fallback* operand does not
502/// classify is different — the shape question ("is the fallback
503/// `Option`-shaped?") has no safe unpinned answer, so that abandons the
504/// chain silently, the same "Unknown never disagrees" posture every
505/// sibling module in this crate takes; the runtime fault remains the
506/// backstop.
507///
508/// The verdict is keyed by [`expr_span`] of the root — the derivation LIR
509/// lowering shares, in `brink-ir`, so producer and consumer cannot drift.
510/// Since issue #1517 that is the root `Expr::Infix`'s own `Provenance`
511/// range, so every chain root in a file has its own key and there is no
512/// ambiguity to guard against.
513fn analyze_chain(
514 root: &Expr,
515 fallback: TextRange,
516 file: FileId,
517 ctx: &MistypeCtx<'_>,
518 table: &mut CoalesceTable,
519 out: &mut Vec<Diagnostic>,
520) {
521 let spine = chain_spine(root);
522 let mut steps = Vec::with_capacity(spine.len());
523 let mut carried: Option<Ty> = None;
524 for node in spine.iter().rev() {
525 let Some((lhs_expr, rhs_expr)) = coalesce_operands(node) else {
526 return;
527 };
528 let lhs = match carried.take() {
529 Some(ty) => ty,
530 // An unclassifiable innermost left-hand operand is recorded as
531 // `Unknown`, not bailed on: `infer::coalesce`'s `(Unknown, _)`
532 // arm always accepts it, so this yields a real
533 // `CoalesceShape::RuntimeCheck` step instead of silently
534 // recording nothing (see this function's own doc).
535 None => classify_coalesce_operand(lhs_expr, ctx).unwrap_or(Ty::Unknown),
536 };
537 let Some(rhs) = classify_coalesce_operand(rhs_expr, ctx) else {
538 return;
539 };
540 match infer::coalesce(&lhs, &rhs) {
541 Ok(result) => {
542 let shape = step_shape(&lhs, &rhs);
543 carried = Some(result.clone());
544 steps.push(CoalesceStep {
545 lhs,
546 rhs,
547 result,
548 shape,
549 });
550 }
551 Err(err) => {
552 let range = expr_anchor(lhs_expr)
553 .or_else(|| expr_anchor(rhs_expr))
554 .unwrap_or(fallback);
555 out.push(Diagnostic {
556 file,
557 range,
558 message: coalesce_error_message(&err),
559 code: DiagnosticCode::E066,
560 });
561 return;
562 }
563 }
564 }
565 if steps.is_empty() {
566 return;
567 }
568 let Some(range) = expr_span(root) else {
569 return;
570 };
571 table.insert(NodeKey::new(file, range), CoalesceChain { steps });
572}
573
574/// Which shape one step's operand types imply, per the `docs/stdlib-spec.md`
575/// §1.6a rule [`infer::coalesce`] encodes: an `Option` fallback keeps
576/// optionality, a plain fallback collapses, and an unpinned left-hand type
577/// commits to neither.
578fn step_shape(lhs: &Ty, rhs: &Ty) -> CoalesceShape {
579 if matches!(lhs, Ty::Unknown | Ty::Conflicted) {
580 return CoalesceShape::RuntimeCheck;
581 }
582 if matches!(rhs, Ty::Option(_)) {
583 CoalesceShape::PreserveOption
584 } else {
585 CoalesceShape::Collapse
586 }
587}
588
589fn coalesce_error_message(err: &CoalesceError) -> String {
590 match err {
591 CoalesceError::LeftNotOption(ty) => format!(
592 "{}: `or`-coalescing requires an `Option[T]` left-hand side (docs/stdlib-spec.md \
593 §1.6a) — found `{}`",
594 DiagnosticCode::E066.title(),
595 ty.display(),
596 ),
597 CoalesceError::Mismatch { element, fallback } => format!(
598 "{}: `or`-coalescing's fallback type disagrees with the `Option`'s element type \
599 (docs/stdlib-spec.md §1.6a) — `{}` vs `{}`",
600 DiagnosticCode::E066.title(),
601 element.display(),
602 fallback.display(),
603 ),
604 }
605}
606
607/// Classify a coalescing operand's own statically-known type —
608/// [`structs::classify_expr_ty`]'s existing inference-substrate
609/// classification (Path/resolved-Call/Index/literals) first, extended with
610/// the two shapes it doesn't cover and
611/// `option_conditions::condition_is_option` already special-cases for the
612/// identical reason: an unresolved (builtin, not author-shadowed) call to
613/// an Option-returning intrinsic, and the bare unresolved `none` literal.
614/// `some(x)` additionally classifies its own inner element, recursively —
615/// the one extra step `condition_is_option` doesn't need (it only cares
616/// whether the type is `Option`, not what element it carries).
617fn classify_coalesce_operand(expr: &Expr, ctx: &MistypeCtx<'_>) -> Option<Ty> {
618 match expr {
619 Expr::Call(path, args) => {
620 // `path.range` here is the same call-path `ResolvedRef::range`
621 // key `lir::lower::expr::lower_call`/`ufcs_receiver_path` and
622 // `strict::check_void_root` also key on unchanged (issue
623 // #1561; see that field's doc). This particular lookup is a
624 // *negative* check — presence means a real user symbol shadows
625 // the `some`/intrinsic pseudo-function name, so this call must
626 // not be treated as the built-in coalescing sugar.
627 if let [seg] = path.segments.as_slice()
628 && !ctx.resolution_by_range.contains_key(&range_key(path.range))
629 {
630 if seg.text == "some" {
631 let elem = args
632 .first()
633 .and_then(|a| classify_coalesce_operand(a, ctx))
634 .unwrap_or(Ty::Unknown);
635 return Some(Ty::Option(Box::new(elem)));
636 }
637 if crate::infer::intrinsic_returns_option(&seg.text) {
638 return Some(Ty::Option(Box::new(Ty::Unknown)));
639 }
640 }
641 structs::classify_expr_ty(expr, ctx)
642 }
643 Expr::Path(p) => {
644 if let [seg] = p.segments.as_slice()
645 && seg.text == "none"
646 && !ctx.resolution_by_range.contains_key(&range_key(p.range))
647 {
648 return Some(Ty::Option(Box::new(Ty::Unknown)));
649 }
650 structs::classify_expr_ty(expr, ctx)
651 }
652 _ => structs::classify_expr_ty(expr, ctx),
653 }
654}
655
656/// A best-effort own-range for an operand expression, for diagnostic
657/// anchoring — mirrors `option_conditions::expr_anchor` exactly (same
658/// shapes carry a source range: a path, a call's callee path, and the
659/// roots reachable through unary/index/field/infix wrappers). `None` falls
660/// back to the enclosing statement/content/choice's own span.
661fn expr_anchor(expr: &Expr) -> Option<TextRange> {
662 match expr {
663 Expr::Path(p) => Some(p.range),
664 Expr::Call(path, _) => Some(path.range),
665 Expr::Prefix(_, inner) | Expr::Postfix(inner, _) => expr_anchor(inner),
666 Expr::Index(idx) => expr_anchor(&idx.base),
667 Expr::FieldAccess(fa) => expr_anchor(&fa.base),
668 Expr::Infix(ie) => expr_anchor(&ie.lhs).or_else(|| expr_anchor(&ie.rhs)),
669 _ => None,
670 }
671}
672
673/// The nearest source range a statement carries on its own `Provenance`, if
674/// any — `enter_stmt`'s fallback-anchor update. `ChoiceSet` and
675/// `LabeledBlock` carry no `ptr` of their own (their children — `Choice`,
676/// nested statements — do, picked up by `enter_choice`/their own
677/// `enter_stmt`); `ExprStmt`/`EndOfLine` likewise have nothing to offer.
678fn stmt_anchor(stmt: &Stmt) -> Option<TextRange> {
679 match stmt {
680 Stmt::Content(c) => c.ptr.map(|p| p.text_range()),
681 Stmt::Divert(d) => d.ptr.map(|p| p.text_range()),
682 Stmt::TunnelCall(t) => Some(t.ptr.text_range()),
683 Stmt::ThreadStart(t) => Some(t.ptr.text_range()),
684 Stmt::TempDecl(t) => Some(t.ptr.text_range()),
685 Stmt::Assignment(a) => Some(a.ptr.text_range()),
686 Stmt::Return(r) => r.ptr.map(|p| p.text_range()),
687 Stmt::Conditional(c) => Some(c.ptr.text_range()),
688 Stmt::Sequence(s) => Some(s.ptr.text_range()),
689 Stmt::LogicBlock(lb) => Some(lb.ptr.text_range()),
690 Stmt::Await(a) => Some(a.ptr.text_range()),
691 // Issue #2108: `AttachElement`/`EndElementRun` carry no
692 // `Provenance`/`ptr` field of their own — same "nothing to offer"
693 // posture as `ExprStmt`/`EndOfLine`.
694 Stmt::ChoiceSet(_)
695 | Stmt::LabeledBlock(_)
696 | Stmt::ExprStmt(_)
697 | Stmt::EndOfLine
698 | Stmt::AttachElement(_)
699 | Stmt::EndElementRun => None,
700 }
701}
702
703fn range_key(range: TextRange) -> (u32, u32) {
704 (range.start().into(), range.end().into())
705}
706
707/// This file's own reference resolutions, projected to a range-keyed lookup
708/// — mirrors `conversions::resolution_index`/`option_conditions`'s own copy.
709fn resolution_index(
710 resolutions: &ResolutionMap,
711 file: FileId,
712) -> BTreeMap<(u32, u32), DefinitionId> {
713 resolutions
714 .iter()
715 .filter(|r| r.file == file)
716 .map(|r| (range_key(r.range), r.target))
717 .collect()
718}
719
720#[cfg(test)]
721#[expect(
722 clippy::panic,
723 reason = "test-only assertions; see sibling test modules"
724)]
725mod tests {
726 use super::*;
727 use brink_ir::{FileId as HirFileId, SymbolIndex};
728
729 /// Native-lowered `(HirFile, SymbolIndex, ResolutionMap, InferenceResult)`
730 /// — `InfixOp::Coalesce` is produced only by `hir::lower_native`
731 /// (B1, issue #1460), so unlike every other check module's test harness
732 /// in this crate (which parses through `brink_syntax`, the ink/brink-
733 /// extension frontend), this one must go through the native frontend.
734 fn build_native(src: &str) -> (HirFile, SymbolIndex, ResolutionMap, InferenceResult) {
735 let parse = brink_syntax_native::parse(src);
736 assert!(
737 parse.errors().is_empty(),
738 "fixture must parse cleanly: {:?}",
739 parse.errors()
740 );
741 let tree = parse.tree();
742 let (hir, manifest, _diag) = brink_ir::hir::lower_native::lower(HirFileId(0), &tree);
743 let (index, _diag) = crate::symbol_index(&[(HirFileId(0), &manifest)]);
744 let (resolutions, _diag) = crate::resolve(
745 HirFileId(0),
746 &manifest,
747 &index,
748 &crate::ImportScope::default(),
749 );
750 let inference = crate::infer_project(
751 &[(HirFileId(0), &hir)],
752 &index,
753 &resolutions,
754 None,
755 &BTreeMap::new(),
756 );
757 (hir, (*index).clone(), (*resolutions).clone(), inference)
758 }
759
760 fn check_all(src: &str) -> Vec<Diagnostic> {
761 let (hir, index, resolutions, inference) = build_native(src);
762 check(&[(HirFileId(0), &hir)], &index, &inference, &resolutions)
763 }
764
765 // ── issue #1764: a lambda's statements in a VAR/CONST initializer ────
766
767 /// Coverage for a lambda's statements in a VAR/CONST initializer comes
768 /// from `visit::visit_with_decl_initializers` (which reaches the
769 /// initializer at all) composed with `walk_expr`'s `Expr::Lambda` arm
770 /// (which already descends a lambda's statements) — there is no
771 /// separate hand-rolled recursion for this position (issue #2098).
772 #[test]
773 fn a_bad_chain_in_a_lambda_statement_of_a_var_initializer_is_e066() {
774 let diags = check_all("var f = ||: int {\n let x = 5 or 9;\n 0\n};\n");
775 assert_eq!(diags.len(), 1, "{diags:?}");
776 assert_eq!(diags[0].code, DiagnosticCode::E066);
777 }
778
779 /// The same walk answers "does this project coalesce at all?", the gate
780 /// on building a `CoalesceTable` at all. Missing the chain there dropped
781 /// its *shape* from the table, not just its diagnostic. Before issue
782 /// #1774 (RULED 2026-08-01) this table never actually reached
783 /// `CoalesceLookup` in LIR for a lambda in a VAR/CONST initializer
784 /// specifically — the position was a hard `E083` (see
785 /// `hir::visit::walk_expr`'s `Expr::Lambda` arm, which descends a
786 /// lambda's statements) — so this pinned only the analyzer-layer
787 /// shape. #1774 lifted that gate, so this chain's shape is now
788 /// LIR-reachable too: `brink-ir`'s
789 /// `coalesce_chain_in_lambda_decl_default_gets_its_real_recorded_shape`
790 /// (`tests/lir_lowering/lambda_literal_declaration_default.rs`) is the
791 /// sibling pin on that end of the pipeline.
792 #[test]
793 fn a_chain_in_a_lambda_statement_of_a_var_initializer_trips_the_project_gate() {
794 let (hir, _index, _res, _inf) =
795 build_native("var f = ||: int {\n let x = some(1) or 2;\n 0\n};\n");
796 assert!(project_has_coalesce(&hir));
797 }
798
799 /// …and is recorded in the table with its real collapsed shape.
800 #[test]
801 fn a_chain_in_a_lambda_statement_of_a_var_initializer_is_recorded_in_the_table() {
802 let chain = only_chain("var f = ||: int {\n let x = some(1) or 2;\n 0\n};\n");
803 assert_eq!(chain.steps.len(), 1, "{chain:?}");
804 assert_eq!(chain.steps[0].rhs, Ty::Int);
805 }
806
807 // ─── issue #2793: the ordinary (non-lambda) fn/knot annotated-param
808 // half of #2786's `BodyTypes::locals` visibility fix — an UNWANTED
809 // result flagged, not a new true positive ──────────────────────────
810
811 /// #2793 audit finding, corrected per review: unlike every other of the
812 /// six consumers (and unlike this same file's own lambda-param half,
813 /// pinned by
814 /// `an_annotated_shadowing_lambda_param_keeps_its_preserve_option_shape`
815 /// below), an *ordinary* `fn`/knot param's own written annotation is
816 /// **not visible to this file's own E066 check** when that param is
817 /// used directly as an `or` chain's own left-hand operand — #2786's fix
818 /// cannot reach this position at all, regardless of the param's
819 /// annotation. That much still holds. But the annotation is **not
820 /// silently defeated project-wide**: this test only asserts
821 /// `coalesce::resolve`'s own output, which was too narrow a claim.
822 ///
823 /// Root cause: `infer::body::InferPass::infer_infix`'s `InfixOp::Coalesce`
824 /// arm unconditionally back-propagates an *assumed* `Option[T]` shape
825 /// onto a bare-Path left-hand operand mid-walk (`self.observe(lhs,
826 /// &expected_lhs)`, always `Option`-shaped, "a coalescing `lhs` is
827 /// never optional-vs-leniency, it is *required* to be `Option[T]`" —
828 /// see that arm's own doc). That `observe` call runs *during*
829 /// `pass.infer_block`, writing `pass.locals["x"] = Option(Int)` (`x`'s
830 /// prior entry is `Unknown`, so `unify` accepts the assumption outright)
831 /// **before** `infer_def_body`'s own post-walk annotation overlay ever
832 /// runs. The overlay is correctly keyed on `contains_key`, not
833 /// `is_unknown()` (the #1912 firewall, guarding the *re-bound-temp*
834 /// case) — but that same guard, doing exactly its job, now also
835 /// protects this wrong, walk-assumed entry from ever being corrected by
836 /// `x`'s real `int` annotation: the overlay sees `"x"` already present
837 /// and skips it. The lambda-param path is immune to this because
838 /// `structs::pruned_locals_for_lambda` re-seeds a lambda's own
839 /// annotated param straight from its `TypeExpr` (`annotations::resolve`)
840 /// rather than reading back through this same mutated `pass.locals`
841 /// map — an asymmetry between the two param-visibility paths #2786's
842 /// own fix left in place.
843 ///
844 /// This is exactly the case `docs/typed-mode-spec.md` §2's **RULED
845 /// (issue #1912)** paragraph already carves out: "*evidence-producing*
846 /// positions — `infer_infix`'s comparison and arithmetic operands, an
847 /// intrinsic's sibling-argument `observe`… deliberately never consult
848 /// the [annotation] fallback." The `Coalesce` arm's `lhs` operand is
849 /// exactly one of those `infer_infix` evidence-producing positions —
850 /// this test does not reopen or contradict that ruling, and the
851 /// production fix this file's module doc defers to a "design
852 /// discussion" (out of this audit's scope) is *not* a proposal to
853 /// revise it. What's new here, and not something #1912 already
854 /// covered: #1912's own firewall description assumes a genuinely
855 /// unconstrained param stays `Unknown` and is then safely left alone;
856 /// it does not anticipate an evidence-producing site writing an
857 /// *assumed*, non-`Unknown` shape that later gets exported as the
858 /// param's own final signature type and then contradicts the written
859 /// annotation at a downstream CHECK *consumer* (`annotations::mismatches`
860 /// / E063) rather than staying silent. That's the gap this test and its
861 /// E063 assertion below pin.
862 ///
863 /// This same `pass.locals["x"] = Option(Int)` entry is also what
864 /// `infer_def_body`'s `param_types` overlay reads (`crates/internal/
865 /// brink-analyzer/src/infer/body.rs`): since it is non-`Unknown` by the
866 /// time that overlay runs, `x`'s real `int` annotation is never applied
867 /// there either, so the def's *exported signature* records `x:
868 /// Option<int>`. `strict::check` feeds that signature through
869 /// `annotations::mismatches` (E063), which compares it against the
870 /// written annotation and — because `assignable(Int, Option(Int))` is
871 /// false — reports a disagreement. So the annotation is not silently
872 /// defeated: it surfaces as E063 at the annotation site, a materially
873 /// different (and worse — the wrong code, wrong message, wrong span)
874 /// diagnostic than a correct E066 at the `or` site would be. Both
875 /// halves are pinned below: `coalesce::resolve`'s own local silence,
876 /// and the real `annotations::mismatches` diagnostic that actually
877 /// fires project-wide.
878 #[test]
879 fn annotated_fn_param_non_option_lhs_of_or_is_not_visible_to_e066() {
880 let src = "fn build(x: int) {\n let y = x or 5;\n}\n";
881 let (hir, index, resolutions, inference) = build_native(src);
882 let (table, diags) = resolve(&[(HirFileId(0), &hir)], &index, &inference, &resolutions);
883 assert!(
884 diags.is_empty(),
885 "documents the gap: no E066 fires despite `x: int` disagreeing \
886 with `or`'s Option requirement: {diags:?}"
887 );
888 let (_key, chain) = table.iter().next().expect("one recorded chain");
889 assert_eq!(
890 chain.steps[0].lhs,
891 Ty::Option(Box::new(Ty::Int)),
892 "the coalesce arm's own forced back-propagation, not `x`'s real \
893 `int` annotation, is what the recorded shape reflects: {chain:?}"
894 );
895
896 // Pin what actually fires project-wide instead of stopping at
897 // `coalesce::resolve`'s silence: the same forced `Option<int>`
898 // shape exported as `x`'s signature type trips E063 at the
899 // annotation site.
900 let mismatch_diags =
901 annotations::mismatches(&[(HirFileId(0), &hir)], &index, &inference, None);
902 assert_eq!(mismatch_diags.len(), 1, "{mismatch_diags:?}");
903 assert_eq!(mismatch_diags[0].code, DiagnosticCode::E063);
904 assert_eq!(
905 mismatch_diags[0].message,
906 "annotated type `int` disagrees with the type inferred from usage (`Option<int>`)",
907 "{mismatch_diags:?}"
908 );
909 }
910
911 #[test]
912 fn non_option_left_hand_side_is_e066() {
913 let diags = check_all("flow main() {\n {5 or 9}\n -> END\n}\n");
914 assert_eq!(diags.len(), 1, "{diags:?}");
915 assert_eq!(diags[0].code, DiagnosticCode::E066);
916 }
917
918 #[test]
919 fn mismatched_fallback_type_is_e066() {
920 let diags = check_all("flow main() {\n {some(1) or \"text\"}\n -> END\n}\n");
921 assert_eq!(diags.len(), 1, "{diags:?}");
922 assert_eq!(diags[0].code, DiagnosticCode::E066);
923 }
924
925 #[test]
926 fn collapse_form_with_agreeing_types_is_clean() {
927 let diags = check_all("flow main() {\n {some(1) or 2}\n -> END\n}\n");
928 assert!(diags.is_empty(), "{diags:?}");
929 }
930
931 #[test]
932 fn two_option_form_with_agreeing_types_is_clean() {
933 let diags = check_all("flow main() {\n {some(1) or none}\n -> END\n}\n");
934 assert!(diags.is_empty(), "{diags:?}");
935 }
936
937 #[test]
938 fn unclassifiable_operand_stays_silently_unchecked() {
939 // `x`'s type isn't statically known here (no other use to infer
940 // from) — "Unknown never disagrees", same posture every sibling
941 // check in this crate takes.
942 let diags = check_all("flow main(x) {\n {x or 9}\n -> END\n}\n");
943 assert!(diags.is_empty(), "{diags:?}");
944 }
945
946 /// Companion to the diagnostics-only assertion above: an unpinned
947 /// left-hand type is not merely diagnostic-clean, it is recorded as a
948 /// real `CoalesceShape::RuntimeCheck` step — the unpinned-`lhs` posture
949 /// `Opcode::CoalesceSome`'s own doc claims. Pins the review finding that
950 /// this branch was previously unreachable (the operand fell through a
951 /// silent `return` instead of ever reaching `step_shape`).
952 ///
953 /// `!x` (an `Expr::Prefix` wrapping the param, carrying its span) rather
954 /// than the bare param path itself: a bare single-segment param/temp
955 /// path used *directly* as a coalescing `lhs` gets narrowed to
956 /// `Option[…]` by `infer::body`'s own feedback
957 /// (`coalesce_lhs_param_narrows_to_option_of_the_rhs_type`), so it
958 /// always classifies. `Expr::Prefix` is neither a shape `observe`
959 /// narrows (only a bare `Path`) nor one `classify_expr_ty` handles at
960 /// all (only Path/Call/Index beyond literals) — genuinely
961 /// unclassifiable, and (like every infix node since #1517) keyable
962 /// from the operation's own provenance.
963 #[test]
964 fn unpinned_left_hand_side_records_a_runtime_check_step() {
965 // `fn`'s plain `{ }` routes through the code-ground `stmt_block`
966 // (unlike `flow`'s, which is content-ground and reads a leading `!`
967 // inside `{ }` as a once-only sequence marker, not boolean negation
968 // — this fixture must be a `fn` body to get a real `Expr::Prefix`).
969 let src = concat!(
970 "fn f(x) {\n return !x or 9;\n}\n",
971 "flow main() {\n -> END\n}\n",
972 );
973 let chain = only_chain(src);
974 assert_eq!(chain.steps.len(), 1, "{chain:?}");
975 let step = &chain.steps[0];
976 assert_eq!(step.lhs, Ty::Unknown);
977 assert_eq!(step.rhs, Ty::Int);
978 assert_eq!(step.result, Ty::Unknown);
979 assert_eq!(step.shape, CoalesceShape::RuntimeCheck);
980 }
981
982 // ─── The recorded side channel (issue #1492) ──────────────────────
983
984 fn table_of(src: &str) -> CoalesceTable {
985 let (hir, index, resolutions, inference) = build_native(src);
986 resolve(&[(HirFileId(0), &hir)], &index, &inference, &resolutions).0
987 }
988
989 /// The single recorded chain in a one-chain fixture.
990 fn only_chain(src: &str) -> CoalesceChain {
991 let table = table_of(src);
992 assert_eq!(table.len(), 1, "expected exactly one chain: {table:?}");
993 let (_key, chain) = table.iter().next().expect("one entry");
994 chain.clone()
995 }
996
997 fn opt(inner: Ty) -> Ty {
998 Ty::Option(Box::new(inner))
999 }
1000
1001 #[test]
1002 fn collapse_form_records_the_collapsed_value_type() {
1003 let chain = only_chain("flow main() {\n {some(1) or 2}\n -> END\n}\n");
1004 assert_eq!(chain.steps.len(), 1);
1005 let step = &chain.steps[0];
1006 assert_eq!(step.lhs, opt(Ty::Int));
1007 assert_eq!(step.rhs, Ty::Int);
1008 assert_eq!(step.result, Ty::Int);
1009 assert_eq!(step.shape, CoalesceShape::Collapse);
1010 }
1011
1012 #[test]
1013 fn two_option_form_records_preserved_optionality() {
1014 let chain = only_chain("flow main() {\n {some(1) or none}\n -> END\n}\n");
1015 assert_eq!(chain.steps.len(), 1);
1016 let step = &chain.steps[0];
1017 assert_eq!(step.shape, CoalesceShape::PreserveOption);
1018 assert!(
1019 matches!(step.result, Ty::Option(_)),
1020 "optionality survives: {:?}",
1021 step.result
1022 );
1023 }
1024
1025 /// The verdict LIR lowering needs and no syntactic shape-sniff can
1026 /// reach: the fallback is a *call*, so "is the fallback `Option`-shaped"
1027 /// is answerable only from the callee's recorded return type.
1028 #[test]
1029 fn a_call_fallback_is_typed_from_its_return_type_not_its_syntax() {
1030 let src = concat!(
1031 "fn maybe() {\n return some(7);\n}\n",
1032 "flow main() {\n {some(1) or maybe()}\n -> END\n}\n",
1033 );
1034 let chain = only_chain(src);
1035 assert_eq!(chain.steps.len(), 1);
1036 assert_eq!(chain.steps[0].rhs, opt(Ty::Int));
1037 assert_eq!(chain.steps[0].shape, CoalesceShape::PreserveOption);
1038 }
1039
1040 /// The chain the whole side channel exists for: an `Option`-returning
1041 /// call in the middle keeps the inner step optional, and only the final
1042 /// plain fallback collapses.
1043 #[test]
1044 fn a_chain_records_every_step_innermost_first() {
1045 let src = concat!(
1046 "fn maybe() {\n return some(7);\n}\n",
1047 "flow main() {\n {some(1) or maybe() or 99}\n -> END\n}\n",
1048 );
1049 let chain = only_chain(src);
1050 assert_eq!(chain.steps.len(), 2, "{chain:?}");
1051 assert_eq!(chain.steps[0].shape, CoalesceShape::PreserveOption);
1052 assert_eq!(chain.steps[0].result, opt(Ty::Int));
1053 // The inner step's result is the outer step's left-hand type — the
1054 // fold, not a re-classification of the `Expr::Infix` node (which
1055 // `classify_expr_ty` cannot type at all).
1056 assert_eq!(chain.steps[1].lhs, opt(Ty::Int));
1057 assert_eq!(chain.steps[1].rhs, Ty::Int);
1058 assert_eq!(chain.steps[1].result, Ty::Int);
1059 assert_eq!(chain.steps[1].shape, CoalesceShape::Collapse);
1060 }
1061
1062 /// A bare `Path` to a declared `Option[int]` temp as the fallback — the
1063 /// w56 scope finding folded into #1492. Syntax says "an identifier";
1064 /// the recorded type says `Option[int]`, so optionality is preserved.
1065 #[test]
1066 fn an_option_typed_path_fallback_preserves_optionality() {
1067 let src = concat!(
1068 "fn pick() {\n",
1069 " let fallback = some(3);\n",
1070 " return some(1) or fallback;\n",
1071 "}\n",
1072 "flow main() {\n -> END\n}\n",
1073 );
1074 let chain = only_chain(src);
1075 assert_eq!(chain.steps.len(), 1, "{chain:?}");
1076 assert_eq!(chain.steps[0].rhs, opt(Ty::Int));
1077 assert_eq!(chain.steps[0].shape, CoalesceShape::PreserveOption);
1078 }
1079
1080 /// The widening the fold buys: before #1492 only a chain's innermost
1081 /// step was judged (`classify_expr_ty` returns `None` for an
1082 /// `Expr::Infix` left-hand operand), so this compiled silently.
1083 #[test]
1084 fn a_mismatch_at_a_later_chain_step_is_now_e066() {
1085 let diags = check_all("flow main() {\n {some(1) or none or \"text\"}\n -> END\n}\n");
1086 assert_eq!(diags.len(), 1, "{diags:?}");
1087 assert_eq!(diags[0].code, DiagnosticCode::E066);
1088 }
1089
1090 #[test]
1091 fn an_ill_typed_chain_records_no_verdict() {
1092 let table = table_of("flow main() {\n {some(1) or \"text\"}\n -> END\n}\n");
1093 assert!(table.is_empty(), "{table:?}");
1094 }
1095
1096 #[test]
1097 fn an_all_literal_chain_is_keyable_but_still_ill_typed() {
1098 // Before #1517 this chain could not be keyed at all — neither
1099 // operand carried a range and the infix node had none of its own,
1100 // so `expr_span` yielded `None`. It is keyable now (the operation's
1101 // own provenance), and records nothing purely because it is `E066`.
1102 let src = "flow main() {\n {5 or 9}\n -> END\n}\n";
1103 let (hir, ..) = build_native(src);
1104 let root = first_coalesce_root(&hir).expect("one chain");
1105 assert!(expr_span(root).is_some(), "the operation is keyable now");
1106 assert!(table_of(src).is_empty(), "but it is ill-typed");
1107 }
1108
1109 /// The #1517 refactor's payoff, at the producer: a chain root's key is
1110 /// its **own** provenance range, so it covers the trailing literal that
1111 /// used to contribute nothing, and the chain's own left spine derives a
1112 /// *different* key that the table simply misses. Before #1517 the two
1113 /// were the same key, which is why this pass had to poison any key two
1114 /// roots could share.
1115 #[test]
1116 fn a_chain_root_and_its_left_spine_derive_different_keys() {
1117 let src = concat!(
1118 "fn maybe() {\n return some(7);\n}\n",
1119 "flow main() {\n {some(1) or maybe() or 99}\n -> END\n}\n",
1120 );
1121 let table = table_of(src);
1122 assert_eq!(table.len(), 1, "{table:?}");
1123 let (key, _) = table.iter().next().expect("one entry");
1124 let start = usize::try_from(key.range.0).unwrap();
1125 let end = usize::try_from(key.range.1).unwrap();
1126 assert_eq!(&src[start..end], "some(1) or maybe() or 99");
1127
1128 // Derive the spine's key from the HIR itself, not from a fabricated
1129 // `src.find(...)` range: the stamped range includes trailing
1130 // whitespace trivia before the next operator (see the #1517 comment
1131 // in `hir::spans`), so a hand-picked substring range would not be
1132 // the spine's *real* key and would trivially miss the table for the
1133 // wrong reason.
1134 let (hir, ..) = build_native(src);
1135 let root_expr = first_coalesce_root(&hir).expect("one chain");
1136 let Expr::Infix(root) = root_expr else {
1137 panic!("expected a left-associative chain, got {root_expr:?}");
1138 };
1139 let spine_range = expr_span(&root.lhs).expect("the left spine is an infix too");
1140 assert_ne!(
1141 spine_range,
1142 TextRange::new(key.range.0.into(), key.range.1.into())
1143 );
1144 assert!(
1145 table.at(HirFileId(0), spine_range).is_none(),
1146 "a spine node must miss, never inherit the root's verdict: {table:?}"
1147 );
1148 }
1149
1150 /// The first coalescing chain root in a file's knot bodies.
1151 fn first_coalesce_root(hir: &HirFile) -> Option<&Expr> {
1152 for knot in &hir.knots {
1153 for stmt in &knot.body.stmts {
1154 if let Stmt::Content(c) = stmt {
1155 for part in &c.parts {
1156 if let brink_ir::ContentPart::Interpolation(e) = part
1157 && coalesce_operands(e).is_some()
1158 {
1159 return Some(e);
1160 }
1161 }
1162 }
1163 }
1164 }
1165 None
1166 }
1167
1168 #[test]
1169 fn each_chain_is_recorded_once_at_its_root() {
1170 let src = concat!(
1171 "fn maybe() {\n return some(7);\n}\n",
1172 "flow main() {\n {some(1) or maybe() or 99}\n {some(2) or 3}\n -> END\n}\n",
1173 );
1174 let table = table_of(src);
1175 assert_eq!(table.len(), 2, "one entry per chain root: {table:?}");
1176 }
1177
1178 /// Two textually identical chains in the same file stay separately
1179 /// addressable: the key is each root's own source range, not its text.
1180 #[test]
1181 fn sibling_chains_keep_distinct_keys() {
1182 let src = "flow main() {\n {some(1) or 2}\n {some(1) or 2}\n -> END\n}\n";
1183 let table = table_of(src);
1184 assert_eq!(table.len(), 2, "{table:?}");
1185 }
1186
1187 #[test]
1188 fn a_var_initializer_chain_is_recorded_too() {
1189 let src = "var v = some(1) or 2\nflow main() {\n -> END\n}\n";
1190 let chain = only_chain(src);
1191 assert_eq!(chain.steps.len(), 1);
1192 assert_eq!(chain.steps[0].shape, CoalesceShape::Collapse);
1193 }
1194
1195 /// Issue #2098: a bare-literal operand (`5 or 9`) carries no `Provenance`
1196 /// of its own for [`stmt_anchor`]/`enter_content`/`enter_choice` to pick
1197 /// up (see `CoalesceVisitor::fallback`'s own doc) — inside a VAR
1198 /// initializer specifically, that means the diagnostic's anchor can only
1199 /// come from `CoalesceVisitor::enter_var_decl`'s reset. Before the
1200 /// migration this was the hand-rolled `check_expr` recursion's explicit
1201 /// `fallback: var.ptr.text_range()` parameter; this pins the same
1202 /// resulting range through the shared `HirVisitor` entry point instead.
1203 #[test]
1204 fn a_bare_literal_chain_in_a_var_initializer_anchors_on_the_declaration() {
1205 let src = "var v = 5 or 9\nflow main() {\n -> END\n}\n";
1206 let (hir, index, resolutions, inference) = build_native(src);
1207 let diags = check(&[(HirFileId(0), &hir)], &index, &inference, &resolutions);
1208 assert_eq!(diags.len(), 1, "{diags:?}");
1209 assert_eq!(diags[0].code, DiagnosticCode::E066);
1210 assert_eq!(
1211 diags[0].range,
1212 hir.variables[0].ptr.text_range(),
1213 "a bare-literal chain's fallback anchor must be the VAR's own \
1214 range when nothing narrower is available"
1215 );
1216 }
1217
1218 // ─── issue #2773: the pruned lambda frame changes the RECORDED SHAPE,
1219 // not just which diagnostics fire ─────────────────────────────────
1220
1221 /// Review finding on issue #2773: of the six consumers the lambda-frame
1222 /// fix touches, this one is **not** diagnostics-only. `analyze_chain`
1223 /// writes `CoalesceStep::shape` into the `CoalesceTable`, which
1224 /// `brink_analyzer::coalesce_lir_lookup` hands to `brink-db`'s
1225 /// `coalesce_types_query` and from there to
1226 /// `lir::lower::expr::lower_coalesce_chain` — so a flipped shape is
1227 /// **different emitted bytecode**, not a different squiggle.
1228 ///
1229 /// This is the control half. The lambda's own `x` carries a resolvable
1230 /// `: Option<int>` annotation, so `pruned_locals_for_lambda` seeds it
1231 /// back after pruning: the left-hand operand stays pinned and the step
1232 /// still records `PreserveOption`. Unchanged by the fix — it is here so
1233 /// the flip below is demonstrably caused by the *missing annotation*
1234 /// and not by the pruning eating every lambda param unconditionally.
1235 #[test]
1236 fn an_annotated_shadowing_lambda_param_keeps_its_preserve_option_shape() {
1237 let chain = only_chain(
1238 "fn build() {\n let x = some(1);\n let f = |x: Option<int>| x or none;\n}\n",
1239 );
1240 assert_eq!(chain.steps.len(), 1, "{chain:?}");
1241 let step = &chain.steps[0];
1242 assert_eq!(step.lhs, opt(Ty::Int));
1243 assert_eq!(step.shape, CoalesceShape::PreserveOption);
1244 }
1245
1246 /// The flip itself. `build`'s own `x` is `Option<int>`; the lambda's
1247 /// own `x` param shadows it and carries **no** annotation, so the
1248 /// pruned frame removes it and re-seeds nothing.
1249 ///
1250 /// Pre-fix, `classify_coalesce_operand` read the *outer* `x` by bare
1251 /// name and pinned the left-hand side to `Option<int>` — recording
1252 /// `PreserveOption` from a binding that is not the one in scope, which
1253 /// is a real miscompile, not merely a wrong diagnostic. Post-fix the
1254 /// operand is genuinely unclassifiable, `analyze_chain`'s
1255 /// `.unwrap_or(Ty::Unknown)` records `Ty::Unknown`, and `step_shape`
1256 /// short-circuits to `RuntimeCheck` — the honest posture for a value
1257 /// whose Option-ness is not knowable here.
1258 ///
1259 /// If a future edit dropped the frame push, this regresses to
1260 /// `PreserveOption` and silently changes generated code again.
1261 #[test]
1262 fn an_unannotated_shadowing_lambda_param_flips_the_step_to_runtime_check() {
1263 let chain = only_chain("fn build() {\n let x = some(1);\n let f = |x| x or none;\n}\n");
1264 assert_eq!(chain.steps.len(), 1, "{chain:?}");
1265 let step = &chain.steps[0];
1266 assert_eq!(
1267 step.lhs,
1268 Ty::Unknown,
1269 "the lambda's own unannotated `x` must not inherit the outer \
1270 `Option<int>`: {step:?}"
1271 );
1272 assert_eq!(step.shape, CoalesceShape::RuntimeCheck);
1273 }
1274}