Skip to main content

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}