brink-ir 0.0.17

Intermediate representations for inkle's ink narrative scripting language
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
//! The explain-match query (issue #2113, NS-T seam 3/6) — the tooling read
//! that discharges the maintainer's "no invisible expansion" requirement:
//! for any line, is it matched, **by what** (handler fn + source location,
//! hoverable), **what bound** (captures as spans), and on a **miss** the
//! patterns attempted (registration order), or on a **hit** what else
//! matched but was **shadowed**.
//!
//! # This is a read, not a second walk
//!
//! Every field this module produces is already sitting in #2112's
//! [`crate::classify_line`] output or in #2111's
//! [`ConventionsProjection::entries`] itself — composing them into the
//! caller-facing shape below is arithmetic-free, walk-free composition.
//! Concretely: `attempted` on a miss is *exactly* `projection.entries`,
//! because the walk already ruled (2026-08-02, `classify.rs`'s own doc)
//! that it tries **every** entry whether or not one wins — there is no
//! separate "did we try this" bit to recompute, the fact that nothing
//! matched at all IS the miss case, and every entry the projection carries
//! was attempted.
//!
//! # Raw captures, not computed values
//!
//! Same boundary [`crate::classify_line`] documents: a capture is a real
//! regex binding (`name = "VENDOR"`), never a value the matched handler's
//! own body would go on to compute. This module does not relax that
//! anywhere.
//!
//! # `ElementKind` composition — deliberately deferred
//!
//! #2112's own review comment (PR #2257, "wave w133 true-up") named this as
//! this issue's to resolve: whether to compose [`crate::ElementKind`] (the
//! "matched kind" column — scene heading / cue / parenthetical / content
//! line) alongside this module's output, since [`ConventionProjectionEntry`]
//! carries no `kind` field.
//!
//! **This module does not compose it, and that is a decision, not an
//! oversight.** [`crate::hir::lower_native::element::candidate`] — the one
//! function that computes [`crate::ElementKind`] — reads a **parsed CST
//! node**, not a bare string, and one variant is chain-gated on more than
//! the line itself: a [`crate::ElementKind::Parenthetical`] only parses as
//! `PARENTHETICAL` directly after a *live cue* (`at_parenthetical` in
//! `brink-syntax-native`). This module's own entry points take `text: &str`
//! with no surrounding-line context — the same shape [`crate::classify_line`]
//! itself takes, deliberately, so it can classify a line an author is
//! actively typing before it has ever been parsed into a real file's CST at
//! all (`classify.rs`'s own "a hypothetically-broken entry is safer than
//! panicking on a line an author is actively typing"). A standalone line of
//! text cannot answer "was the line before this one a live cue" — so this
//! module cannot correctly derive `ElementKind` for its own inputs, only
//! guess at it.
//!
//! The w133 comment named two ways out: **(a)** compose `ElementKind` from
//! the compile-time detector directly, independent of the projection, or
//! **(b)** get a schema extension upstream first. Option (a) needs a real
//! parsed CST node as this module's input (not bare text) to answer the
//! chain-gated case correctly — a bigger seam than a pure per-line function
//! wants to own, and a different one than #2112's own "no compiler
//! pipeline, no salsa" framing for this walk. Option (b) has no consumer
//! yet either. Composing `ElementKind` is left as a follow-up (a caller that
//! *does* hold a parsed CST node — a real, already-open editor
//! document — can call `candidate` itself and pair its result alongside
//! this module's [`LineExplanation`] the same way `brink_ir::HirFile::element_matches`
//! already does for an actually-compiled file; nothing here forecloses
//! that), rather than shipped half-correct.
//!
//! **Delivered by issue #2310**, one layer up: `brink-web`'s
//! `editor::explain_match` module is exactly that caller — it holds the
//! active document's `FileId` and reads `kind` off
//! [`ElementMatch`](crate::ElementMatch) for the winning line via
//! `ProjectDb::hir`, a salsa query recomputed off the current revision (the
//! same revision this module's own live walk reads its line text from) —
//! never a stored snapshot that could lag an edit. It declines (reports no
//! kind) rather than guess whenever there is simply no compiled record to
//! read agreement from: an ink-dialect file (`element_matches` is always
//! empty there), a file with no compiled `HirFile` at all, or a line the
//! compiler structurally declined to claim on its own — a heading carrying
//! a `[slug]`/tags, or a line folded into a block handler's captured run —
//! even though this module's own live walk matched it. This module's own
//! contract is unchanged by that: it still never sees or reports
//! `ElementKind` itself.
//!
//! **Issue #2351 adds the node-aware entry point this w133 comment named**
//! ([`explain_match_node`], and `ExplainMatchCache::explain`'s `node`
//! parameter) — but for a narrower, different reason than `ElementKind`
//! composition: `classify_line` matching a *whole raw line* against a
//! preset pattern structurally cannot agree with the compiler's own
//! `try_claim` for a cue, a parenthetical, a compact cue, or a
//! slugged/tag-bearing heading, all of which the compiler matches against
//! a *sub-node's* extracted text instead. `explain_match_node` still takes
//! `text: &str` nowhere near it — it takes a real parsed
//! [`brink_syntax_native::SyntaxNode`], selects the exact sub-node
//! `candidate` would, and classifies THAT. It still never composes
//! `ElementKind` itself (that stays #2310's job, reading the compiler's
//! own compiled record one layer up) — `matched`/`winner`/`shadowed` now
//! simply agree with the compiler on more lines than before, which is what
//! lets #2310's existing `kind` composition finally reach a real cue or
//! parenthetical line at all.
//!
//! # Memoization (issue #2113's own reassigned remainder)
//!
//! [`ExplainMatchCache`] is the memoized query the ruled cost compensation
//! calls for: cached per `(line text, projection)`, with the projection's
//! compiled pattern set (`brink_ir::hir::classify::compile_entries`) cached
//! alongside it so a **repeat** classification of the same line skips both
//! the walk and the compile, while even a **first** classification of a new
//! line still skips the compile (the w133 perf finding's own target). See
//! its own doc for the caching contract.

use std::collections::BTreeMap;

use brink_syntax_native::SyntaxNode;
use rowan::{TextRange, TextSize};

use super::classify::{
    ClassifiedCapture, ClassifiedMatch, CompiledEntry, classify_line_compiled,
    classify_node_compiled, compile_entries,
};
use super::types::ConventionProjectionEntry;
use crate::ConventionsProjection;

/// One line's full explain-match record — the caller-facing composition
/// this module exists to build. See this module's own doc for why every
/// field here is a read of #2111/#2112 data, never a second walk.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum LineExplanation {
    /// A pattern matched this line.
    Matched {
        /// The winning handler — fn name + declaration-site range
        /// (hoverable), and its captures as spans into the classified
        /// line.
        winner: ClassifiedMatch,
        /// Every other handler whose pattern also matched, ascending by
        /// `order` — shadowed by `winner`, ruled 2026-08-02. Empty when
        /// nothing else matched.
        shadowed: Vec<ClassifiedMatch>,
    },
    /// Nothing matched this line.
    Unmatched {
        /// Every entry the walk tried, in registration (ascending-`order`)
        /// sequence — empty only when the line was itself whitespace-only
        /// (the walk never even starts one, per [`crate::classify_line`]'s
        /// own trim contract; reporting the full entry list in that case
        /// would claim an attempt that never happened).
        attempted: Vec<ConventionProjectionEntry>,
    },
}

impl LineExplanation {
    /// `true` for [`Self::Matched`].
    #[must_use]
    pub fn is_matched(&self) -> bool {
        matches!(self, Self::Matched { .. })
    }

    /// `Some((winner, shadowed))` for [`Self::Matched`], `None` for
    /// [`Self::Unmatched`].
    #[must_use]
    pub fn into_matched(self) -> Option<(ClassifiedMatch, Vec<ClassifiedMatch>)> {
        match self {
            Self::Matched { winner, shadowed } => Some((winner, shadowed)),
            Self::Unmatched { .. } => None,
        }
    }

    /// `Some(attempted)` for [`Self::Unmatched`], `None` for
    /// [`Self::Matched`].
    #[must_use]
    pub fn into_attempted(self) -> Option<Vec<ConventionProjectionEntry>> {
        match self {
            Self::Unmatched { attempted } => Some(attempted),
            Self::Matched { .. } => None,
        }
    }
}

/// Explain what [`crate::classify_line`] would do for `text`, composed into
/// [`LineExplanation`]'s caller-facing shape. Pure and uncached — see
/// [`ExplainMatchCache`] for the memoized, compiled-pattern-cached entry
/// point a caller classifying many lines against one projection should use
/// instead.
#[must_use]
pub fn explain_match(
    projection: &ConventionsProjection,
    base: TextSize,
    text: &str,
) -> LineExplanation {
    let compiled = compile_entries(projection);
    explain_match_compiled(&compiled, &projection.entries, base, text)
}

/// [`explain_match`], but against an already-[`compile_entries`]-compiled
/// pattern set — [`ExplainMatchCache`]'s own inner call.
fn explain_match_compiled(
    compiled: &[CompiledEntry],
    entries: &[ConventionProjectionEntry],
    base: TextSize,
    text: &str,
) -> LineExplanation {
    if text.trim().is_empty() {
        return LineExplanation::Unmatched {
            attempted: Vec::new(),
        };
    }
    from_classification(classify_line_compiled(compiled, base, text), entries)
}

/// Explain the CST node `node` — one of the five claim-candidate shapes
/// [`crate::hir::lower_native::element::candidate`] recognizes (`CUE`,
/// `COMPACT_CUE`, `PARENTHETICAL`, `SCENE_HEADING`, or a wholly-literal
/// `CONTENT_LINE`) — matching the exact sub-node text `candidate`/
/// `try_claim` extract for that shape, the same way
/// [`crate::hir::classify::classify_node_compiled`] does (issue #2351).
/// Pure and uncached, `node`'s own [`compile_entries`] counterpart to
/// [`explain_match`] — see [`ExplainMatchCache`] for the memoized entry
/// point.
///
/// `None` when `node`'s own kind is not one of those five shapes at all —
/// the caller should fall back to [`explain_match`] against `node`'s own
/// raw text in that case, exactly the pre-#2351 behavior for anything
/// outside these five shapes.
#[must_use]
pub fn explain_match_node(
    projection: &ConventionsProjection,
    node: &SyntaxNode,
) -> Option<LineExplanation> {
    let compiled = compile_entries(projection);
    let classification = classify_node_compiled(&compiled, node)?;
    Some(from_classification(classification, &projection.entries))
}

/// Shared tail of [`explain_match_compiled`]/[`explain_match_node`]:
/// compose a raw [`crate::LineClassification`] into [`LineExplanation`]'s
/// caller-facing shape — written once so the two entry points cannot drift
/// on what "matched" vs. "unmatched" means.
fn from_classification(
    classification: crate::LineClassification,
    entries: &[ConventionProjectionEntry],
) -> LineExplanation {
    match classification.matched {
        Some(winner) => LineExplanation::Matched {
            winner,
            shadowed: classification.shadowed,
        },
        None => LineExplanation::Unmatched {
            attempted: entries.to_vec(),
        },
    }
}

/// Shift every [`ClassifiedCapture::range`] inside `explanation` by `delta`
/// — never [`ClassifiedMatch::handler`]'s own range, which is a location in
/// the *conventions module's* source, unrelated to whatever line this
/// explanation classified. [`ExplainMatchCache`] uses this to rebase a
/// cached, text-relative result onto the real caller-supplied `base`,
/// exactly the way [`crate::classify_line`] itself composes `base` plus a
/// local offset for a fresh call — see this function's own caller for why
/// caching at `base = 0` and rebasing here is what keeps the cache valid
/// across every position the same line text occurs at.
fn rebase(explanation: LineExplanation, delta: TextSize) -> LineExplanation {
    fn rebase_capture(capture: ClassifiedCapture, delta: TextSize) -> ClassifiedCapture {
        ClassifiedCapture {
            range: TextRange::new(capture.range.start() + delta, capture.range.end() + delta),
            ..capture
        }
    }
    fn rebase_match(m: ClassifiedMatch, delta: TextSize) -> ClassifiedMatch {
        ClassifiedMatch {
            captures: m
                .captures
                .into_iter()
                .map(|c| rebase_capture(c, delta))
                .collect(),
            ..m
        }
    }
    match explanation {
        LineExplanation::Matched { winner, shadowed } => LineExplanation::Matched {
            winner: rebase_match(winner, delta),
            shadowed: shadowed
                .into_iter()
                .map(|m| rebase_match(m, delta))
                .collect(),
        },
        // `attempted` entries carry no captures into the classified line at
        // all — nothing here needs shifting.
        unmatched @ LineExplanation::Unmatched { .. } => unmatched,
    }
}

/// A cache pairing a compiled pattern set with memoized per-line results,
/// both invalidated together whenever the underlying [`ConventionsProjection`]
/// changes — the memoization issue #2113 owns as #2112's reassigned
/// remainder (ruled: memoize on `(line text, projection revision)`), plus
/// the w133 perf finding's own ask (cache the *compiled* pattern set, not
/// just the classification result).
///
/// There is no synthetic "revision" counter: [`ConventionsProjection`]
/// already derives `Eq`, so equality against the last-seen projection *is*
/// the revision check — the same cutoff signal salsa's own
/// `conventions_projection_query` already gives a caller for free. A caller
/// that already sits on a salsa graph may prefer wrapping [`explain_match`]
/// in a tracked query directly instead of this cache; both are the "ordinary
/// salsa way" / "small cache at the boundary" options #2112's own doc left
/// open. `@brink-lang/web`'s `EditorSession` is not itself salsa-tracked at
/// the wasm boundary, so it holds one of these.
///
/// # Caching at a text-relative base
///
/// Results are computed and cached at `base = 0` and rebased to the
/// caller's real `base` on every lookup (`rebase`) — otherwise two
/// occurrences of byte-identical line text at different file offsets would
/// collide on the same cache entry and one would get the other's capture
/// ranges. This mirrors [`crate::classify_line`]'s own documented
/// text-relative-at-`base`-zero convention for a caller with no real source
/// position yet.
/// A cached line's classification, stripped of the `attempted` payload a
/// miss would otherwise carry. Every miss's `attempted` is *exactly*
/// `self.projection.entries` — see `explain_match_compiled`'s own doc — so
/// storing that clone in every one of potentially many distinct-miss cache
/// entries would be O(distinct lines × projection size) for a value that is
/// byte-identical across every non-blank miss under one projection. `Miss`
/// carries nothing; the real `attempted` list is materialized from
/// `self.projection.entries` at lookup time instead (`ExplainMatchCache::explain`).
#[derive(Debug, Clone, PartialEq, Eq)]
enum CachedLine {
    Matched {
        winner: ClassifiedMatch,
        shadowed: Vec<ClassifiedMatch>,
    },
    Miss,
}

fn to_cached(explanation: LineExplanation) -> CachedLine {
    match explanation {
        LineExplanation::Matched { winner, shadowed } => CachedLine::Matched { winner, shadowed },
        // The blank-line empty case never reaches here (see `explain`'s own
        // early return), so this is always a real, attempted-and-failed miss.
        LineExplanation::Unmatched { .. } => CachedLine::Miss,
    }
}

fn from_cached(cached: CachedLine, entries: &[ConventionProjectionEntry]) -> LineExplanation {
    match cached {
        CachedLine::Matched { winner, shadowed } => LineExplanation::Matched { winner, shadowed },
        CachedLine::Miss => LineExplanation::Unmatched {
            attempted: entries.to_vec(),
        },
    }
}

/// Hard cap on distinct cached line texts (CLAUDE.md: "any loop that
/// accumulates data must have a limit"). `lines` is keyed on keystroke-driven
/// text over the lifetime of one wasm session, with no natural revision
/// boundary to clear it on short of a projection change — an author who
/// edits a long document line by line for a whole session could otherwise
/// grow it without bound. Not a real LRU: once the cap is hit the whole map
/// is cleared and rebuilt from scratch, trading a burst of re-classification
/// for zero extra bookkeeping — a session churns through far more repeat
/// classifications of a small working set of lines than it does distinct
/// line texts, so hitting the cap at all is the uncommon case.
const MAX_CACHED_LINES: usize = 4096;

#[derive(Debug, Default)]
pub struct ExplainMatchCache {
    projection: ConventionsProjection,
    compiled: Vec<CompiledEntry>,
    lines: BTreeMap<String, CachedLine>,
}

impl ExplainMatchCache {
    /// A fresh, empty cache — equivalent to [`Self::default`], spelled out
    /// for callers that prefer a constructor over a trait bound.
    #[must_use]
    pub fn new() -> Self {
        Self::default()
    }

    /// Explain `text` at `base` against `projection`, memoized. Recompiles
    /// the pattern set and clears every cached line only when `projection`
    /// differs (by `Eq`) from the last call's — an unchanged projection
    /// reuses both the compiled patterns and, for a repeat `text`, the
    /// classification itself.
    ///
    /// `node` (issue #2351): when the caller holds a parsed claim-candidate
    /// CST node for this exact line (e.g. via
    /// [`crate::nearest_element_candidate`]), pass it here so the walk
    /// matches the SAME sub-node text the compiler's own `try_claim` would,
    /// rather than `text` whole — `text` still identifies the line (it
    /// remains the cache key, see below) and still gates the blank-line
    /// short-circuit, but `node`, when given, decides what is actually
    /// classified. `None` keeps the pre-#2351 raw-text behavior, unchanged.
    ///
    /// `base` means "the file-absolute offset that offset zero in the
    /// classified text corresponds to": `text`'s own start when `node` is
    /// `None`, or `node.text_range().start()` when it is `Some` — passing
    /// the wrong one of the two silently mis-shifts every returned range.
    ///
    /// # Why a node-derived result is never cached by `text` alone
    ///
    /// The raw-text cache below (keyed on `text`) assumes classifying the
    /// same text twice always yields the same answer. That is true for the
    /// text-only walk, but not for the node-aware one: the identical raw
    /// line text can select a *different* CST shape depending on parser
    /// context alone — e.g. `(hushed)` parses as a chain-gated
    /// `PARENTHETICAL` (stripping the parens before matching) only directly
    /// after a live cue; the same text elsewhere is an ordinary
    /// `CONTENT_LINE` (matched with the parens still in it). Caching a
    /// node-derived result under the bare-text key would let one
    /// occurrence's classification leak onto an unrelated occurrence of the
    /// same text. So the node path bypasses `self.lines` entirely — it
    /// still reuses `self.compiled` (the w133 review's dominant-cost fix),
    /// just not per-line memoization.
    #[must_use]
    pub fn explain(
        &mut self,
        projection: &ConventionsProjection,
        base: TextSize,
        text: &str,
        node: Option<&SyntaxNode>,
    ) -> LineExplanation {
        if &self.projection != projection {
            self.projection = projection.clone();
            self.compiled = compile_entries(projection);
            self.lines.clear();
        }
        // A blank line never even starts the walk (`explain_match_compiled`'s
        // own trim contract) — distinguishable before the cache is consulted,
        // so it is never cached at all.
        if text.trim().is_empty() {
            return LineExplanation::Unmatched {
                attempted: Vec::new(),
            };
        }
        if let Some(node) = node
            && let Some(classification) = classify_node_compiled(&self.compiled, node)
        {
            return rebase(
                from_classification(classification, &self.projection.entries),
                base,
            );
        }
        // `node` is `None`, or is not (and has no ancestor that is, if the
        // caller already resolved via `nearest_element_candidate`) one of
        // the five claim-candidate shapes at all — fall through to the
        // ordinary raw-text walk below, exactly the pre-#2351 behavior.
        if let Some(cached) = self.lines.get(text) {
            return rebase(from_cached(cached.clone(), &self.projection.entries), base);
        }
        if self.lines.len() >= MAX_CACHED_LINES {
            self.lines.clear();
        }
        let explanation = explain_match_compiled(
            &self.compiled,
            &self.projection.entries,
            TextSize::from(0),
            text,
        );
        self.lines
            .insert(text.to_owned(), to_cached(explanation.clone()));
        rebase(explanation, base)
    }
}

#[cfg(test)]
mod tests {
    use std::collections::BTreeMap;

    use super::*;
    use crate::{ClaimHandlerDecl, ConventionAttachField, Name};

    fn name(text: &str) -> Name {
        Name {
            text: text.to_string(),
            range: TextRange::default(),
        }
    }

    fn decl(name_text: &str, order: i64, pattern: &str) -> ClaimHandlerDecl {
        ClaimHandlerDecl {
            name: name(name_text),
            annotation: TextRange::default(),
            params: Vec::new(),
            pattern: pattern.to_string(),
            block: false,
            order,
            attach: None,
        }
    }

    fn no_structs() -> BTreeMap<String, Vec<ConventionAttachField>> {
        BTreeMap::new()
    }

    fn projection(decls: &[ClaimHandlerDecl]) -> ConventionsProjection {
        ConventionsProjection::from_decls(decls, &[], &no_structs())
    }

    #[test]
    fn a_hit_reports_the_winner_and_every_shadowed_entry_in_order() {
        let p = projection(&[
            decl("cue", 10, "^(?<name>[A-Z]+)$"),
            decl("any_line", 20, "^.*$"),
            decl("also_any_line", 30, "^.*$"),
        ]);
        let explanation = explain_match(&p, TextSize::from(0), "VENDOR");
        let (winner, shadowed) = explanation.into_matched().expect("expected a match");
        assert_eq!(winner.handler.text, "cue");
        let shadowed_names: Vec<&str> = shadowed.iter().map(|m| m.handler.text.as_str()).collect();
        assert_eq!(shadowed_names, vec!["any_line", "also_any_line"]);
    }

    /// The core case this module exists for: on a miss, `attempted` names
    /// every entry the walk tried, in registration order — not just the
    /// winner-shaped subset a naive "what matched" view would give.
    #[test]
    fn a_miss_reports_every_entry_attempted_in_registration_order() {
        let p = projection(&[
            decl("interior", 10, "^INT\\. (?<place>.+)$"),
            decl("exterior", 20, "^EXT\\. (?<place>.+)$"),
            decl("cue", 30, "^(?<name>[A-Z]+)$"),
        ]);
        let explanation = explain_match(&p, TextSize::from(0), "plain content, matches nothing");
        let attempted = explanation.into_attempted().expect("expected a miss");
        let names: Vec<&str> = attempted.iter().map(|e| e.name.text.as_str()).collect();
        assert_eq!(
            names,
            vec!["interior", "exterior", "cue"],
            "registration order is resolution order — report attempted patterns in it"
        );
    }

    /// A whitespace-only line never even starts the walk
    /// ([`crate::classify_line`]'s own trim contract) — `attempted` must
    /// stay empty rather than falsely claim every entry was tried.
    #[test]
    fn a_blank_line_reports_no_attempted_patterns_at_all() {
        let p = projection(&[decl("any_line", 10, "^.*$")]);
        let explanation = explain_match(&p, TextSize::from(0), "   \t  ");
        let attempted = explanation.into_attempted().expect("expected a miss");
        assert!(attempted.is_empty());
    }

    /// An empty projection is a miss with nothing attempted — there is
    /// nothing to try.
    #[test]
    fn an_empty_projection_attempts_nothing() {
        let p = projection(&[]);
        let explanation = explain_match(&p, TextSize::from(0), "anything at all");
        let attempted = explanation.into_attempted().expect("expected a miss");
        assert!(attempted.is_empty());
    }

    /// A declined-entirely entry (a named group that never participated,
    /// e.g. the losing branch of an alternation — `classify_line`'s own
    /// rule) still shows up in `attempted` on a miss: it WAS tried, even
    /// though the walk declined to report it as a partial match.
    #[test]
    fn a_declined_entirely_entry_still_appears_as_attempted_on_a_miss() {
        let p = projection(&[decl(
            "interior_or_exterior",
            10,
            "^(?:INT\\. (?<place>.+)|EXT\\. (?<outside>.+))$",
        )]);
        let explanation = explain_match(&p, TextSize::from(0), "no match here");
        let attempted = explanation.into_attempted().expect("expected a miss");
        assert_eq!(attempted.len(), 1);
        assert_eq!(attempted[0].name.text, "interior_or_exterior");
    }

    /// The captures on a hit carry real spans into the classified line —
    /// this module composes, it does not re-derive.
    #[test]
    fn captures_on_a_hit_are_real_spans_at_the_given_base() {
        let p = projection(&[decl("interior", 10, "^INT\\. (?<place>.+)$")]);
        let explanation = explain_match(&p, TextSize::from(100), "INT. MARKET SQUARE");
        let (winner, _shadowed) = explanation.into_matched().expect("expected a match");
        assert_eq!(winner.captures.len(), 1);
        assert_eq!(winner.captures[0].text, "MARKET SQUARE");
        assert_eq!(
            winner.captures[0].range,
            TextRange::new(105.into(), 118.into())
        );
    }

    // ─── ExplainMatchCache ────────────────────────────────────────

    #[test]
    fn the_cache_gives_the_same_answer_as_the_uncached_call() {
        let p = projection(&[
            decl("interior", 10, "^INT\\. (?<place>.+)$"),
            decl("any_line", 20, "^.*$"),
        ]);
        let direct = explain_match(&p, TextSize::from(50), "INT. MARKET SQUARE");
        let mut cache = ExplainMatchCache::new();
        let cached = cache.explain(&p, TextSize::from(50), "INT. MARKET SQUARE", None);
        assert_eq!(direct, cached);
    }

    /// The bug this cache design specifically has to avoid: caching the
    /// full (already-based) result keyed only on text would make a SECOND
    /// occurrence of the identical line, at a different offset, silently
    /// reuse the FIRST occurrence's capture ranges. Rebasing at lookup time
    /// (`rebase`) is what this test proves actually happens.
    #[test]
    fn identical_line_text_at_two_different_bases_gets_correctly_rebased_captures() {
        let p = projection(&[decl("interior", 10, "^INT\\. (?<place>.+)$")]);
        let mut cache = ExplainMatchCache::new();

        let first = cache.explain(&p, TextSize::from(0), "INT. MARKET SQUARE", None);
        let second = cache.explain(&p, TextSize::from(1000), "INT. MARKET SQUARE", None);

        let (w1, _) = first.into_matched().expect("expected a match");
        let (w2, _) = second.into_matched().expect("expected a match");
        assert_eq!(w1.captures[0].range, TextRange::new(5.into(), 18.into()));
        assert_eq!(
            w2.captures[0].range,
            TextRange::new(1005.into(), 1018.into()),
            "the second occurrence must be rebased onto its own base, not \
             reuse the first occurrence's cached range"
        );
    }

    /// A projection change must invalidate every cached line, not merely
    /// the compiled pattern set — a stale cache entry from the OLD
    /// projection must never survive.
    #[test]
    fn a_changed_projection_invalidates_every_cached_result() {
        let before = projection(&[decl("any_line", 10, "^.*$")]);
        let after = projection(&[decl("cue", 10, "^(?<name>[A-Z]+)$")]);

        let mut cache = ExplainMatchCache::new();
        let first = cache.explain(&before, TextSize::from(0), "VENDOR", None);
        let (winner, _) = first.into_matched().expect("expected a match");
        assert_eq!(winner.handler.text, "any_line");

        let second = cache.explain(&after, TextSize::from(0), "VENDOR", None);
        let (winner, _) = second.into_matched().expect("expected a match");
        assert_eq!(
            winner.handler.text, "cue",
            "the stale `any_line` entry from the old projection must not \
             still win after the projection changed"
        );
    }

    /// Guard against unbounded growth (CLAUDE.md's own rule): a session that
    /// classifies more distinct line texts than `MAX_CACHED_LINES` under one
    /// unchanged projection must never grow `lines` past the cap.
    #[test]
    fn the_cache_never_grows_the_line_map_past_its_cap() {
        let p = projection(&[decl("any_line", 10, "^.*$")]);
        let mut cache = ExplainMatchCache::new();
        for i in 0..(MAX_CACHED_LINES + 10) {
            let _ = cache.explain(&p, TextSize::from(0), &format!("line number {i}"), None);
        }
        assert!(
            cache.lines.len() <= MAX_CACHED_LINES,
            "cache grew to {} entries, past its {MAX_CACHED_LINES} cap",
            cache.lines.len()
        );
    }

    /// A blank line is never inserted into the cache at all — it is decided
    /// before the cache is even consulted, so it must not consume a cap slot.
    #[test]
    fn a_blank_line_never_occupies_a_cache_slot() {
        let p = projection(&[decl("any_line", 10, "^.*$")]);
        let mut cache = ExplainMatchCache::new();
        let _ = cache.explain(&p, TextSize::from(0), "   \t  ", None);
        assert!(cache.lines.is_empty());
    }

    /// A miss must also be memoized correctly — `attempted` reflects the
    /// CURRENT projection's entries, not a stale snapshot.
    #[test]
    fn a_changed_projection_updates_attempted_patterns_on_a_repeat_miss() {
        let before = projection(&[decl("interior", 10, "^INT\\. (?<place>.+)$")]);
        let after = projection(&[
            decl("interior", 10, "^INT\\. (?<place>.+)$"),
            decl("exterior", 20, "^EXT\\. (?<place>.+)$"),
        ]);

        let mut cache = ExplainMatchCache::new();
        let first = cache.explain(&before, TextSize::from(0), "plain content", None);
        let attempted = first.into_attempted().expect("expected a miss");
        assert_eq!(attempted.len(), 1);

        let second = cache.explain(&after, TextSize::from(0), "plain content", None);
        let attempted = second.into_attempted().expect("expected a miss");
        assert_eq!(
            attempted.len(),
            2,
            "the new entry must appear after the projection changed"
        );
    }

    // ─── Node-aware explain (issue #2351) ────────────────────────────

    /// Lower `src` through the real native frontend, returning the compiled
    /// `HirFile` alongside the root [`SyntaxNode`] the same parse produced
    /// — mirrors `classify::tests::lower_src`, this module's own layer of
    /// the same fixture-building need.
    fn lower_src(src: &str) -> (crate::HirFile, SyntaxNode) {
        use brink_syntax_native::ast::AstNode as _;

        let parse = brink_syntax_native::parse(src);
        let tree = parse.tree();
        let (hir, _manifest, diags) = crate::hir::lower_native::lower(crate::FileId(0), &tree);
        assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
        (hir, tree.syntax().clone())
    }

    fn projection_from(hir: &crate::HirFile) -> ConventionsProjection {
        ConventionsProjection::from_decls(
            &hir.claim_handlers,
            &hir.dispatch_handlers,
            &no_structs(),
        )
    }

    /// Find the exact node `try_claim` claimed for `elm`, via
    /// [`crate::nearest_element_candidate`] starting from the token at the
    /// claimed line's own start.
    fn node_for(root: &SyntaxNode, elm: &crate::ElementMatch) -> SyntaxNode {
        let token = root
            .token_at_offset(elm.line.start())
            .right_biased()
            .expect("a real token must start at the claimed line's own start");
        let start_node = token.parent().expect("every token has a parent node");
        crate::nearest_element_candidate(&start_node)
            .expect("the compiler claimed this line, so some ancestor must be a candidate")
    }

    const CUE_FIXTURE: &str = "\
@[convention(claims = \"^(?<name>[A-Z][A-Z ]*)$\", order = 10)]
fn cue(name: string) {
  return name;
}
@[convention(claims = \"^(?<delivery>[a-z][a-z' -]*)$\", order = 20)]
fn parenthetical(delivery: string) {
  return delivery;
}

flow main() {
  @VENDOR
  (hushed)
}
";

    /// Issue #2351's own headline case: [`explain_match_node`] against a
    /// real `@VENDOR` cue node agrees with the compiler — `matched: true`,
    /// winner `cue`, capture `name = "VENDOR"` — where the pre-fix raw-text
    /// walk reported a flat miss (see `EditorSession`'s own
    /// `explain_match_never_reports_a_cue_kind_for_a_real_at_cue_line`,
    /// renamed by this issue's fix to prove the opposite).
    #[test]
    fn explain_match_node_reports_a_real_hit_for_a_cue_line() {
        let (hir, root) = lower_src(CUE_FIXTURE);
        let projection = projection_from(&hir);
        let cue_match = hir
            .element_matches
            .iter()
            .find(|m| m.kind == crate::ElementKind::Cue)
            .expect("the @VENDOR line must be claimed");
        let node = node_for(&root, cue_match);

        let explanation = explain_match_node(&projection, &node).expect("a recognized shape");
        let (winner, _shadowed) = explanation
            .into_matched()
            .expect("the node-aware walk must agree the compiler claimed this line");
        assert_eq!(winner.handler.text, "cue");
        assert_eq!(winner.captures.len(), 1);
        assert_eq!(winner.captures[0].name, "name");
        assert_eq!(winner.captures[0].text, "VENDOR");
    }

    /// [`explain_match_node`] declines (returns `None`) for a node that is
    /// not, and has no ancestor that is, one of the five claim-candidate
    /// shapes — the caller's documented fallback-to-raw-text signal.
    #[test]
    fn explain_match_node_declines_for_a_non_candidate_node() {
        let (hir, root) = lower_src(CUE_FIXTURE);
        let projection = projection_from(&hir);
        // The `flow main() { ... }` header itself is not one of the five
        // shapes at all.
        let flow_kw = root
            .descendants_with_tokens()
            .find_map(rowan::NodeOrToken::into_token)
            .expect("the file must start with a real token");
        let start_node = flow_kw.parent().expect("every token has a parent");
        assert!(
            crate::nearest_element_candidate(&start_node).is_none(),
            "the file's very first token must not resolve to a claim candidate"
        );
        assert!(explain_match_node(&projection, &start_node).is_none());
    }

    /// [`ExplainMatchCache::explain`] uses the node path when given one,
    /// reporting the SAME real hit `explain_match_node` does directly —
    /// proving the cache is genuinely wired to the node-aware walk, not
    /// silently still falling through to the raw-text miss.
    #[test]
    fn explain_cache_reports_a_real_hit_through_the_node_path() {
        let (hir, root) = lower_src(CUE_FIXTURE);
        let projection = projection_from(&hir);
        let cue_match = hir
            .element_matches
            .iter()
            .find(|m| m.kind == crate::ElementKind::Cue)
            .expect("the @VENDOR line must be claimed");
        let node = node_for(&root, cue_match);
        let node_start = node.text_range().start();

        let mut cache = ExplainMatchCache::new();
        let explanation = cache.explain(&projection, node_start, "@VENDOR", Some(&node));
        let (winner, _) = explanation
            .into_matched()
            .expect("the cache must report the same real hit explain_match_node does");
        assert_eq!(winner.handler.text, "cue");
        assert_eq!(winner.captures[0].text, "VENDOR");
    }

    /// Documented design decision (this module's own `explain` doc, "Why a
    /// node-derived result is never cached by `text` alone"): a node-path
    /// classification must never occupy a `self.lines` slot, since caching
    /// it under the bare-text key would be unsound the moment the same raw
    /// text can select a different CST shape elsewhere in a file.
    #[test]
    fn explain_cache_never_caches_a_node_derived_result_by_raw_text() {
        let (hir, root) = lower_src(CUE_FIXTURE);
        let projection = projection_from(&hir);
        let cue_match = hir
            .element_matches
            .iter()
            .find(|m| m.kind == crate::ElementKind::Cue)
            .expect("the @VENDOR line must be claimed");
        let node = node_for(&root, cue_match);
        let node_start = node.text_range().start();

        let mut cache = ExplainMatchCache::new();
        let _ = cache.explain(&projection, node_start, "@VENDOR", Some(&node));
        assert!(
            cache.lines.is_empty(),
            "a node-derived hit must never be inserted into the raw-text cache: {:?}",
            cache.lines
        );
    }
}