Skip to main content

antlr4_runtime/
semir.rs

1//! Semantic IR for grammar-embedded predicates and actions.
2//!
3//! ANTLR grammars embed target-language semantic predicates and actions that
4//! a metadata-first runtime cannot execute directly (issue #9). This module
5//! defines the small data-driven language those snippets are *translated
6//! into*: heuristic template matching at codegen time, hand-written tables,
7//! and (long term) a real Rust target all lower to the same IR, and the
8//! runtime evaluates only the IR.
9//!
10//! Design constraints, in priority order:
11//!
12//! - **Prediction-safe**: predicates run speculatively inside adaptive
13//!   prediction, possibly many times on abandoned paths. [`PExpr`] therefore
14//!   has no mutating node — effects exist only in [`AStmt`], which the
15//!   runtime executes on committed paths (or transactionally for
16//!   member-state speculation).
17//! - **Allocation-free on the hot path**: expression storage is a flat arena
18//!   indexed by [`ExprId`], and text comparisons resolve borrowed `&str`
19//!   operands without materializing `String`s (see `eval_text_cmp`).
20//! - **Absence is explicit**: recognizer queries that can fail (missing
21//!   lookahead token, absent context child, no rule argument) produce
22//!   [`Value::Null`], and comparison semantics over Null are fixed here so
23//!   every producer of IR agrees on them.
24//!
25//! # Null semantics
26//!
27//! - `Eq` is true iff both sides are present and equal, or both are Null.
28//! - `Ne` is the negation of `Eq`.
29//! - Ordering comparisons (`Lt`, `Le`, `Gt`, `Ge`) with any Null side are
30//!   false.
31//! - Arithmetic with any Null operand is Null; division/modulo by zero is
32//!   Null.
33//! - Truthiness: Null is false, `Bool(b)` is `b`, `Int(i)` is `i != 0`.
34//!
35//! These rules are load-bearing: `{...}?` lookahead-text predicates must fail
36//! when the token is absent (`Eq(Null, "text") == false`), while
37//! context-child text guards must pass when the child is absent
38//! (`Ne(Null, "text") == true`). Predicates that are non-restrictive when a
39//! value is absent (rule arguments) compose [`PExpr::IsNull`] with `Or`.
40//!
41//! # Member state
42//!
43//! Grammars declare their own state in `@members` / `@lexer::members`. The IR
44//! models it as [`MemberEnv`]: numbered slots that are either **scalar**
45//! integers ([`PExpr::Member`], [`AStmt::SetMember`], [`AStmt::AddMember`]) or
46//! **stacks** of integers ([`PExpr::MemberTop`], [`PExpr::MemberLen`],
47//! [`AStmt::PushMember`], [`AStmt::PopMember`]). Stack slots cover the nesting
48//! counters real grammars keep for string interpolation and mode tracking
49//! (issue #206).
50//!
51//! Slot values are integers, so a boolean operand is coerced by
52//! [`Value::truthy`]'s inverse — `true` is 1, `false` is 0 — and reads back
53//! with the same truthiness. Empty-stack reads and pops are **defined, not
54//! errors**:
55//!
56//! - [`PExpr::MemberTop`] on an empty (or never-pushed) stack is
57//!   [`Value::Null`], which is falsy. This is exactly the
58//!   `Count > 0 ? Peek() : false` idiom grammars write by hand.
59//! - [`PExpr::MemberLen`] on a never-pushed stack is `0`, not Null: a stack
60//!   that was never used is empty, not absent.
61//! - [`AStmt::PopMember`] on an empty stack is a no-op. An unbalanced pop is a
62//!   grammar bug the recognizer cannot diagnose, and panicking inside
63//!   prediction would turn it into a crash on input the grammar merely
64//!   mis-describes.
65
66use std::borrow::Cow;
67use std::collections::BTreeMap;
68use std::fmt::Debug;
69
70/// Index of an expression node inside a [`SemIr`] arena.
71#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
72pub struct ExprId(u32);
73
74impl ExprId {
75    /// Builds an expression id from a producer-assigned arena index.
76    #[must_use]
77    pub const fn new(index: u32) -> Self {
78        Self(index)
79    }
80
81    /// Returns this id's arena index.
82    #[must_use]
83    pub const fn index(self) -> usize {
84        self.0 as usize
85    }
86}
87
88/// Index of a statement node inside a [`SemIr`] arena.
89#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
90pub struct StmtId(u32);
91
92impl StmtId {
93    /// Builds a statement id from a producer-assigned arena index.
94    #[must_use]
95    pub const fn new(index: u32) -> Self {
96        Self(index)
97    }
98
99    /// Returns this id's arena index.
100    #[must_use]
101    pub const fn index(self) -> usize {
102        self.0 as usize
103    }
104}
105
106/// Index of an interned string inside a [`SemIr`] arena.
107#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
108pub struct StrId(u32);
109
110impl StrId {
111    /// Builds an interned-string id from a producer-assigned pool index.
112    #[must_use]
113    pub const fn new(index: u32) -> Self {
114        Self(index)
115    }
116
117    /// Returns this id's string-pool index.
118    #[must_use]
119    pub const fn index(self) -> usize {
120        self.0 as usize
121    }
122}
123
124/// Opaque identifier of an externally implemented hook.
125///
126/// The IR deliberately cannot express arbitrary target code; a hook node
127/// defers one predicate or action to the evaluation context, which maps the
128/// id to grammar-specific behavior (a user trait method, or a runtime shim
129/// such as the conformance suite's evaluation-reporting predicates).
130#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
131pub struct HookId(u32);
132
133impl HookId {
134    /// Builds a hook id from a producer-assigned side-table index.
135    #[must_use]
136    pub const fn new(index: u32) -> Self {
137        Self(index)
138    }
139
140    /// Position of this hook in the producer's hook side table.
141    #[must_use]
142    pub const fn index(self) -> usize {
143        self.0 as usize
144    }
145}
146
147/// Comparison operator for [`PExpr::Cmp`].
148#[derive(Clone, Copy, Debug, Eq, PartialEq)]
149pub enum CmpOp {
150    Eq,
151    Ne,
152    Lt,
153    Le,
154    Gt,
155    Ge,
156}
157
158/// Arithmetic operator for [`PExpr::Arith`].
159#[derive(Clone, Copy, Debug, Eq, PartialEq)]
160pub enum ArithOp {
161    Add,
162    Sub,
163    Mul,
164    Div,
165    Mod,
166}
167
168/// Pure predicate expression node.
169///
170/// Text-valued nodes ([`Self::Str`], [`Self::TokenText`],
171/// [`Self::CtxRuleText`], [`Self::TokenTextSoFar`]) are only meaningful as
172/// operands of [`Self::Cmp`] or [`Self::IsNull`]; evaluating one in any other
173/// position yields [`Value::Null`].
174#[derive(Clone, Debug, Eq, PartialEq)]
175pub enum PExpr {
176    /// Boolean literal.
177    Bool(bool),
178    /// Integer literal.
179    Int(i64),
180    /// Interned text literal (comparison operand only).
181    Str(StrId),
182    /// Token type of `LT(offset)` (parser) or lookahead char (lexer).
183    La(isize),
184    /// Text of the token at `LT(offset)`; Null when the token is absent.
185    TokenText(isize),
186    /// Whether the two most recently consumed tokens were adjacent in the
187    /// token stream (`LT(-2).index + 1 == LT(-1).index`); false when either
188    /// is absent.
189    TokenIndexAdjacent,
190    /// Text of the current rule context's first child with this rule index;
191    /// Null when the context or child is absent.
192    CtxRuleText(usize),
193    /// Integer state slot declared by the grammar (`@members` counters).
194    Member(usize),
195    /// Top of a stack-valued state slot; Null when the stack is empty or the
196    /// slot was never pushed. See the module's "Member state" section.
197    MemberTop(usize),
198    /// Depth of a stack-valued state slot; `0` when never pushed.
199    MemberLen(usize),
200    /// Integer argument of the current rule invocation; Null when the rule
201    /// was invoked without one.
202    LocalArg,
203    /// Lexer: current character position within the line.
204    Column,
205    /// Lexer: character position of the current token's first character.
206    TokenStartColumn,
207    /// Lexer: text matched so far for the in-progress token.
208    TokenTextSoFar,
209    /// True when the operand evaluates to Null (or, for a text-valued
210    /// operand, when its text is absent).
211    IsNull(ExprId),
212    /// Logical negation of the operand's truthiness.
213    Not(ExprId),
214    /// Short-circuit conjunction, evaluated left to right.
215    And(Box<[ExprId]>),
216    /// Short-circuit disjunction, evaluated left to right.
217    Or(Box<[ExprId]>),
218    /// Comparison; text operands take the text-comparison path.
219    Cmp(CmpOp, ExprId, ExprId),
220    /// Integer arithmetic with Null propagation.
221    Arith(ArithOp, ExprId, ExprId),
222    /// Defer to the context's hook table.
223    Hook(HookId),
224    /// Return a boolean while letting the recognizer report the evaluation.
225    ///
226    /// This keeps ANTLR runtime-testsuite `Invoke_pred` templates data-driven
227    /// without making ordinary predicates effectful.
228    EvalTrace(bool),
229}
230
231/// Effectful action statement node.
232///
233/// Statements never run during prediction unless the runtime explicitly
234/// classifies them as speculation-eligible (member-only mutations evaluated
235/// against a transactional member environment).
236#[derive(Clone, Debug, Eq, PartialEq)]
237pub enum AStmt {
238    /// `member = expr`.
239    SetMember(usize, ExprId),
240    /// `member += expr`.
241    AddMember(usize, ExprId),
242    /// `member.push(expr)` on a stack-valued slot.
243    PushMember(usize, ExprId),
244    /// `member.pop()` on a stack-valued slot; a no-op when empty.
245    PopMember(usize),
246    /// Assign a rule return field by name.
247    SetReturn(StrId, ExprId),
248    /// Execute statements in order.
249    Seq(Box<[StmtId]>),
250    /// Defer to the context's action hook table.
251    Hook(HookId),
252}
253
254/// Evaluation result of a non-text expression.
255#[allow(variant_size_differences)]
256#[derive(Clone, Copy, Debug, Eq, PartialEq)]
257pub enum Value {
258    /// An absent recognizer value (missing token, member, argument, …).
259    Null,
260    Bool(bool),
261    Int(i64),
262}
263
264impl Value {
265    /// Truthiness used by logical nodes and by [`eval_pred`]'s final result.
266    #[must_use]
267    pub const fn truthy(self) -> bool {
268        match self {
269            Self::Null => false,
270            Self::Bool(value) => value,
271            Self::Int(value) => value != 0,
272        }
273    }
274}
275
276/// Recognizer-state queries the predicate evaluator needs.
277///
278/// Implementations are thin adapters over a lexer or parser; queries that do
279/// not exist for the implementing recognizer return `None` (evaluating to
280/// Null). Lookahead methods take `&mut self` because token streams buffer
281/// lazily.
282pub trait PredContext {
283    type TokenText<'a>: AsRef<str>
284    where
285        Self: 'a;
286
287    /// Token type (parser) or character (lexer) at the given lookahead.
288    fn la(&mut self, offset: isize) -> i64;
289    /// Text of the token at the given lookahead, if present.
290    fn token_text(&mut self, offset: isize) -> Option<Self::TokenText<'_>>;
291    /// Whether `LT(-2)` and `LT(-1)` are adjacent token-stream entries.
292    fn token_index_adjacent(&mut self) -> bool;
293    /// Text of the current context's first child with this rule index.
294    fn ctx_rule_text(&self, rule_index: usize) -> Option<String>;
295    /// Integer member slot value.
296    fn member(&self, member: usize) -> Option<i64>;
297    /// Top of a stack-valued member slot; `None` when empty or never pushed.
298    ///
299    /// Recognizers with no grammar-declared stack state keep the default.
300    fn member_top(&self, _member: usize) -> Option<i64> {
301        None
302    }
303    /// Depth of a stack-valued member slot.
304    fn member_len(&self, _member: usize) -> usize {
305        0
306    }
307    /// Integer argument of the current rule invocation.
308    fn local_arg(&self) -> Option<i64>;
309    /// Lexer current character position within the line.
310    fn column(&self) -> Option<i64>;
311    /// Lexer character position of the current token's start.
312    fn token_start_column(&self) -> Option<i64>;
313    /// Lexer text matched so far for the in-progress token.
314    fn token_text_so_far(&self) -> Option<String>;
315    /// Evaluates an externally implemented predicate hook.
316    fn hook(&mut self, hook: HookId) -> bool;
317    /// Reports an observable predicate-evaluation template and returns `value`.
318    fn trace_bool(&mut self, value: bool) -> bool {
319        value
320    }
321}
322
323/// Mutations the action evaluator needs, on top of predicate queries.
324pub trait ActContext: PredContext {
325    /// Writes an integer member slot.
326    fn set_member(&mut self, member: usize, value: i64);
327    /// Pushes onto a stack-valued member slot.
328    ///
329    /// Recognizers with no grammar-declared stack state keep the default
330    /// no-op; [`AStmt::PushMember`] is only produced for grammars that declare
331    /// a stack slot, so a silent drop here is unreachable rather than lossy.
332    fn push_member(&mut self, _member: usize, _value: i64) {}
333    /// Pops a stack-valued member slot, returning the removed value. A no-op
334    /// returning `None` when the stack is empty.
335    fn pop_member(&mut self, _member: usize) -> Option<i64> {
336        None
337    }
338    /// Assigns a rule return field by name.
339    fn set_return(&mut self, name: &str, value: i64);
340    /// Runs an externally implemented action hook.
341    fn action_hook(&mut self, hook: HookId);
342}
343
344/// Grammar-declared member state: numbered scalar and stack slots.
345///
346/// Recognition threads this by value along each speculative path (it is part
347/// of the parser's memo key), so it is ordered and compares structurally.
348/// Absent slots are not stored: a slot holding `0` is distinct from one never
349/// written, but an *emptied* stack is canonicalized back to absent so two
350/// logically identical paths stay `Eq` — and keep sharing memo entries.
351///
352/// Scalar and stack slot numbers live in separate namespaces; the generator
353/// assigns each declared member to one or the other.
354#[derive(Clone, Debug, Default, Eq, Ord, PartialEq, PartialOrd)]
355pub struct MemberEnv {
356    scalars: BTreeMap<usize, i64>,
357    stacks: BTreeMap<usize, Vec<i64>>,
358}
359
360impl MemberEnv {
361    #[must_use]
362    pub const fn new() -> Self {
363        Self {
364            scalars: BTreeMap::new(),
365            stacks: BTreeMap::new(),
366        }
367    }
368
369    /// Builds an environment holding a grammar's declared initial scalar values.
370    ///
371    /// Grammars write `private bool verbatium = true;` / `private int level =
372    /// 1;`. Those initializers are part of the grammar's meaning: a predicate
373    /// reading a slot that silently started at 0 instead would reject input the
374    /// source grammar accepts. Generated recognizers seed with this so a fresh
375    /// recognizer — and every [`Self::reset_to_initial`] afterwards — starts
376    /// where the grammar says.
377    #[must_use]
378    pub fn with_initial_scalars(initial: impl IntoIterator<Item = (usize, i64)>) -> Self {
379        Self {
380            scalars: initial.into_iter().collect(),
381            stacks: BTreeMap::new(),
382        }
383    }
384
385    /// Clears all state back to the declared initial scalar values.
386    ///
387    /// This is what a recognizer reset needs: not "empty", but the state a
388    /// freshly constructed recognizer had. Stacks always reset to empty, since
389    /// a declaration cannot pre-seed one.
390    pub fn reset_to_initial(&mut self, initial: impl IntoIterator<Item = (usize, i64)>) {
391        self.scalars = initial.into_iter().collect();
392        self.stacks.clear();
393    }
394
395    /// Whether no slot has been written.
396    #[must_use]
397    pub fn is_empty(&self) -> bool {
398        self.scalars.is_empty() && self.stacks.is_empty()
399    }
400
401    /// Reads a scalar slot; `None` when never written.
402    #[must_use]
403    pub fn scalar(&self, member: usize) -> Option<i64> {
404        self.scalars.get(&member).copied()
405    }
406
407    /// Writes a scalar slot.
408    pub fn set_scalar(&mut self, member: usize, value: i64) {
409        self.scalars.insert(member, value);
410    }
411
412    /// Adds to a scalar slot (absent reads as `0`) and returns the new value.
413    pub fn add_scalar(&mut self, member: usize, delta: i64) -> i64 {
414        let value = self.scalars.entry(member).or_default();
415        *value = value.saturating_add(delta);
416        *value
417    }
418
419    /// Top of a stack slot; `None` when empty or never pushed.
420    #[must_use]
421    pub fn stack_top(&self, member: usize) -> Option<i64> {
422        self.stacks.get(&member)?.last().copied()
423    }
424
425    /// Depth of a stack slot; `0` when never pushed.
426    #[must_use]
427    pub fn stack_len(&self, member: usize) -> usize {
428        self.stacks.get(&member).map_or(0, Vec::len)
429    }
430
431    /// Pushes onto a stack slot.
432    pub fn push_stack(&mut self, member: usize, value: i64) {
433        self.stacks.entry(member).or_default().push(value);
434    }
435
436    /// Pops a stack slot, returning the removed value, or `None` when empty.
437    ///
438    /// An emptied stack drops its slot so it compares equal to one never
439    /// pushed — otherwise `push`-then-`pop` would produce a memo key that no
440    /// longer matches the equivalent untouched path.
441    pub fn pop_stack(&mut self, member: usize) -> Option<i64> {
442        let stack = self.stacks.get_mut(&member)?;
443        let value = stack.pop();
444        if stack.is_empty() {
445            self.stacks.remove(&member);
446        }
447        value
448    }
449
450    /// Iterates written scalar slots in slot order.
451    pub fn scalars(&self) -> impl Iterator<Item = (usize, i64)> + '_ {
452        self.scalars.iter().map(|(slot, value)| (*slot, *value))
453    }
454}
455
456/// Flat expression/statement arena with an interned string pool.
457///
458/// Producers append nodes through the builder methods and hand the finished
459/// arena plus root ids to the runtime; evaluation never mutates the arena.
460#[derive(Clone, Debug, Default, Eq, PartialEq)]
461pub struct SemIr {
462    exprs: Vec<PExpr>,
463    stmts: Vec<AStmt>,
464    strings: Vec<Box<str>>,
465}
466
467impl SemIr {
468    #[must_use]
469    pub fn new() -> Self {
470        Self::default()
471    }
472
473    /// Appends an expression node and returns its id.
474    pub fn expr(&mut self, node: PExpr) -> ExprId {
475        let id = ExprId(u32::try_from(self.exprs.len()).expect("expression arena fits in u32"));
476        self.exprs.push(node);
477        id
478    }
479
480    /// Appends a statement node and returns its id.
481    pub fn stmt(&mut self, node: AStmt) -> StmtId {
482        let id = StmtId(u32::try_from(self.stmts.len()).expect("statement arena fits in u32"));
483        self.stmts.push(node);
484        id
485    }
486
487    /// Interns a string literal, reusing an existing pool entry when equal.
488    pub fn intern(&mut self, value: &str) -> StrId {
489        if let Some(position) = self.strings.iter().position(|entry| &**entry == value) {
490            return StrId(u32::try_from(position).expect("string pool fits in u32"));
491        }
492        let id = StrId(u32::try_from(self.strings.len()).expect("string pool fits in u32"));
493        self.strings.push(value.into());
494        id
495    }
496
497    /// Resolves an interned string.
498    #[must_use]
499    pub fn text(&self, id: StrId) -> &str {
500        &self.strings[id.0 as usize]
501    }
502
503    fn node(&self, id: ExprId) -> &PExpr {
504        &self.exprs[id.0 as usize]
505    }
506
507    fn stmt_node(&self, id: StmtId) -> &AStmt {
508        &self.stmts[id.0 as usize]
509    }
510}
511
512/// Evaluates a predicate expression to its truthiness.
513///
514/// This is the runtime entry point for semantic predicate transitions; it is
515/// side-effect-free except for [`PExpr::Hook`] nodes, whose implementations
516/// own their replay-safety (they may run repeatedly on speculative paths).
517pub fn eval_pred<C: PredContext>(ir: &SemIr, expr: ExprId, ctx: &mut C) -> bool {
518    eval_value(ir, expr, ctx).truthy()
519}
520
521/// Executes an action statement against a mutable context.
522pub fn exec_stmt<C: ActContext>(ir: &SemIr, stmt: StmtId, ctx: &mut C) {
523    match ir.stmt_node(stmt) {
524        AStmt::SetMember(member, value) => {
525            let value = int_or_zero(eval_value(ir, *value, ctx));
526            ctx.set_member(*member, value);
527        }
528        AStmt::AddMember(member, delta) => {
529            let delta = int_or_zero(eval_value(ir, *delta, ctx));
530            let current = ctx.member(*member).unwrap_or_default();
531            ctx.set_member(*member, current.saturating_add(delta));
532        }
533        AStmt::PushMember(member, value) => {
534            let value = int_or_zero(eval_value(ir, *value, ctx));
535            ctx.push_member(*member, value);
536        }
537        AStmt::PopMember(member) => {
538            // An unbalanced pop is a grammar bug, not a recognizer error: drop
539            // it rather than panicking inside prediction.
540            let _ = ctx.pop_member(*member);
541        }
542        AStmt::SetReturn(name, value) => {
543            let value = int_or_zero(eval_value(ir, *value, ctx));
544            let name = ir.text(*name).to_owned();
545            ctx.set_return(&name, value);
546        }
547        AStmt::Seq(stmts) => {
548            for stmt in stmts {
549                exec_stmt(ir, *stmt, ctx);
550            }
551        }
552        AStmt::Hook(hook) => ctx.action_hook(*hook),
553    }
554}
555
556/// Coerces a statement operand to the integer a member slot stores.
557///
558/// Slots are integers, so a boolean operand (`{ verbatium = false; }`,
559/// `interpolatedVerbatiums.Push(true)`) must survive the round trip through
560/// [`Value::truthy`]: `true` is 1 so reading the slot back is truthy, `false`
561/// is 0. Null has no value to store and becomes 0.
562const fn int_or_zero(value: Value) -> i64 {
563    match value {
564        Value::Int(value) => value,
565        Value::Bool(value) => value as i64,
566        Value::Null => 0,
567    }
568}
569
570fn eval_value<C: PredContext>(ir: &SemIr, expr: ExprId, ctx: &mut C) -> Value {
571    match ir.node(expr) {
572        // Text-valued nodes are comparison operands; anywhere else they have
573        // no defined value.
574        PExpr::Str(_) | PExpr::TokenText(_) | PExpr::CtxRuleText(_) | PExpr::TokenTextSoFar => {
575            debug_assert!(false, "text-valued node evaluated outside a comparison");
576            Value::Null
577        }
578        PExpr::Bool(value) => Value::Bool(*value),
579        PExpr::Int(value) => Value::Int(*value),
580        PExpr::La(offset) => Value::Int(ctx.la(*offset)),
581        PExpr::TokenIndexAdjacent => Value::Bool(ctx.token_index_adjacent()),
582        PExpr::Member(member) => ctx.member(*member).map_or(Value::Null, Value::Int),
583        // An empty stack reads as Null (falsy), which is the grammar idiom
584        // `Count > 0 ? Peek() : false` without the guard.
585        PExpr::MemberTop(member) => ctx.member_top(*member).map_or(Value::Null, Value::Int),
586        // A never-pushed stack is empty, not absent, so depth is 0 not Null.
587        PExpr::MemberLen(member) => {
588            Value::Int(i64::try_from(ctx.member_len(*member)).unwrap_or(i64::MAX))
589        }
590        PExpr::LocalArg => ctx.local_arg().map_or(Value::Null, Value::Int),
591        PExpr::Column => ctx.column().map_or(Value::Null, Value::Int),
592        PExpr::TokenStartColumn => ctx.token_start_column().map_or(Value::Null, Value::Int),
593        PExpr::IsNull(inner) => Value::Bool(eval_is_null(ir, *inner, ctx)),
594        PExpr::Not(inner) => Value::Bool(!eval_value(ir, *inner, ctx).truthy()),
595        PExpr::And(children) => Value::Bool(
596            children
597                .iter()
598                .all(|child| eval_value(ir, *child, ctx).truthy()),
599        ),
600        PExpr::Or(children) => Value::Bool(
601            children
602                .iter()
603                .any(|child| eval_value(ir, *child, ctx).truthy()),
604        ),
605        PExpr::Cmp(op, lhs, rhs) => eval_cmp(ir, *op, *lhs, *rhs, ctx),
606        PExpr::Arith(op, lhs, rhs) => eval_arith(ir, *op, *lhs, *rhs, ctx),
607        PExpr::Hook(hook) => Value::Bool(ctx.hook(*hook)),
608        PExpr::EvalTrace(value) => Value::Bool(ctx.trace_bool(*value)),
609    }
610}
611
612fn eval_is_null<C: PredContext>(ir: &SemIr, inner: ExprId, ctx: &mut C) -> bool {
613    if let Some(source) = text_source(ir, inner) {
614        return resolve_owned_text(ir, source, ctx).is_none();
615    }
616    eval_value(ir, inner, ctx) == Value::Null
617}
618
619fn eval_cmp<C: PredContext>(ir: &SemIr, op: CmpOp, lhs: ExprId, rhs: ExprId, ctx: &mut C) -> Value {
620    let left_source = text_source(ir, lhs);
621    let right_source = text_source(ir, rhs);
622    if left_source.is_some() || right_source.is_some() {
623        return eval_text_cmp(ir, op, (lhs, left_source), (rhs, right_source), ctx);
624    }
625    let left = eval_value(ir, lhs, ctx);
626    let right = eval_value(ir, rhs, ctx);
627    Value::Bool(match (left, right) {
628        (Value::Null, Value::Null) => cmp_on_equality(op, true),
629        (Value::Null, _) | (_, Value::Null) => cmp_on_equality(op, false),
630        (Value::Bool(left), Value::Bool(right)) => cmp_on_equality(op, left == right),
631        (Value::Int(left), Value::Int(right)) => cmp_ints(op, left, right),
632        (Value::Bool(_), Value::Int(_)) | (Value::Int(_), Value::Bool(_)) => {
633            cmp_on_equality(op, false)
634        }
635    })
636}
637
638/// Comparison outcome for operands that only carry equality (Null, Bool,
639/// mismatched kinds): ordering operators are false.
640const fn cmp_on_equality(op: CmpOp, equal: bool) -> bool {
641    match op {
642        CmpOp::Eq => equal,
643        CmpOp::Ne => !equal,
644        CmpOp::Lt | CmpOp::Le | CmpOp::Gt | CmpOp::Ge => false,
645    }
646}
647
648const fn cmp_ints(op: CmpOp, left: i64, right: i64) -> bool {
649    match op {
650        CmpOp::Eq => left == right,
651        CmpOp::Ne => left != right,
652        CmpOp::Lt => left < right,
653        CmpOp::Le => left <= right,
654        CmpOp::Gt => left > right,
655        CmpOp::Ge => left >= right,
656    }
657}
658
659/// Where a text-valued operand's characters come from.
660///
661/// Only [`Self::Lookahead`] holds a borrow of the context while its `&str`
662/// is alive; the other sources either borrow the IR string pool or return an
663/// owned `String`. `eval_text_cmp` resolves the non-lookahead side first so
664/// the common `token-text == literal` comparison stays allocation-free.
665#[derive(Clone, Copy, Debug)]
666enum TextSource {
667    Literal(StrId),
668    Lookahead(isize),
669    CtxRule(usize),
670    SoFar,
671}
672
673fn text_source(ir: &SemIr, expr: ExprId) -> Option<TextSource> {
674    match ir.node(expr) {
675        PExpr::Str(id) => Some(TextSource::Literal(*id)),
676        PExpr::TokenText(offset) => Some(TextSource::Lookahead(*offset)),
677        PExpr::CtxRuleText(rule_index) => Some(TextSource::CtxRule(*rule_index)),
678        PExpr::TokenTextSoFar => Some(TextSource::SoFar),
679        _ => None,
680    }
681}
682
683/// Resolves a non-lookahead text operand without holding a context borrow.
684fn resolve_static_text<'ir, C: PredContext>(
685    ir: &'ir SemIr,
686    source: TextSource,
687    ctx: &C,
688) -> Option<Cow<'ir, str>> {
689    match source {
690        TextSource::Literal(id) => Some(Cow::Borrowed(ir.text(id))),
691        TextSource::Lookahead(_) => unreachable!("lookahead operands are resolved last"),
692        TextSource::CtxRule(rule_index) => ctx.ctx_rule_text(rule_index).map(Cow::Owned),
693        TextSource::SoFar => ctx.token_text_so_far().map(Cow::Owned),
694    }
695}
696
697/// Owned resolution used by [`PExpr::IsNull`] over text operands.
698fn resolve_owned_text<C: PredContext>(
699    ir: &SemIr,
700    source: TextSource,
701    ctx: &mut C,
702) -> Option<String> {
703    match source {
704        TextSource::Lookahead(offset) => {
705            ctx.token_text(offset).map(|text| text.as_ref().to_owned())
706        }
707        other => resolve_static_text(ir, other, ctx).map(Cow::into_owned),
708    }
709}
710
711fn eval_text_cmp<C: PredContext>(
712    ir: &SemIr,
713    op: CmpOp,
714    (lhs, left_source): (ExprId, Option<TextSource>),
715    (rhs, right_source): (ExprId, Option<TextSource>),
716    ctx: &mut C,
717) -> Value {
718    // A text operand compared against a non-text operand has no defined
719    // value relationship; only equality semantics apply (never equal).
720    let (Some(left_source), Some(right_source)) = (left_source, right_source) else {
721        debug_assert!(false, "text operand compared with non-text operand");
722        let _ = (lhs, rhs);
723        return Value::Bool(cmp_on_equality(op, false));
724    };
725    Value::Bool(match (left_source, right_source) {
726        (TextSource::Lookahead(left), TextSource::Lookahead(right)) => {
727            // Holding the first token-text borrow would keep `ctx` borrowed,
728            // so own this unsupported producer shape's first operand.
729            let left = ctx.token_text(left).map(|text| text.as_ref().to_owned());
730            let right = ctx.token_text(right);
731            cmp_texts(op, left.as_deref(), right.as_ref().map(AsRef::as_ref))
732        }
733        (TextSource::Lookahead(offset), other) => {
734            let right = resolve_static_text(ir, other, ctx);
735            let left = ctx.token_text(offset);
736            cmp_texts(op, left.as_ref().map(AsRef::as_ref), right.as_deref())
737        }
738        (other, TextSource::Lookahead(offset)) => {
739            let left = resolve_static_text(ir, other, ctx);
740            let right = ctx.token_text(offset);
741            cmp_texts(op, left.as_deref(), right.as_ref().map(AsRef::as_ref))
742        }
743        (left, right) => {
744            let left = resolve_static_text(ir, left, ctx);
745            let right = resolve_static_text(ir, right, ctx);
746            cmp_texts(op, left.as_deref(), right.as_deref())
747        }
748    })
749}
750
751fn cmp_texts(op: CmpOp, left: Option<&str>, right: Option<&str>) -> bool {
752    match (left, right) {
753        (None, None) => cmp_on_equality(op, true),
754        (None, Some(_)) | (Some(_), None) => cmp_on_equality(op, false),
755        (Some(left), Some(right)) => match op {
756            CmpOp::Eq => left == right,
757            CmpOp::Ne => left != right,
758            CmpOp::Lt => left < right,
759            CmpOp::Le => left <= right,
760            CmpOp::Gt => left > right,
761            CmpOp::Ge => left >= right,
762        },
763    }
764}
765
766fn eval_arith<C: PredContext>(
767    ir: &SemIr,
768    op: ArithOp,
769    lhs: ExprId,
770    rhs: ExprId,
771    ctx: &mut C,
772) -> Value {
773    let (Value::Int(left), Value::Int(right)) =
774        (eval_value(ir, lhs, ctx), eval_value(ir, rhs, ctx))
775    else {
776        return Value::Null;
777    };
778    let result = match op {
779        ArithOp::Add => left.checked_add(right),
780        ArithOp::Sub => left.checked_sub(right),
781        ArithOp::Mul => left.checked_mul(right),
782        ArithOp::Div => left.checked_div(right),
783        ArithOp::Mod => left.checked_rem(right),
784    };
785    result.map_or(Value::Null, Value::Int)
786}
787
788#[cfg(test)]
789mod tests {
790    use super::{
791        AStmt, ActContext, ArithOp, CmpOp, ExprId, HookId, MemberEnv, PExpr, PredContext, SemIr,
792        Value, eval_pred, eval_value, exec_stmt,
793    };
794    use std::collections::BTreeMap;
795
796    /// Scriptable recognizer stand-in for evaluator tests.
797    #[derive(Debug, Default)]
798    struct MockCtx {
799        tokens: Vec<(i64, Option<&'static str>)>,
800        adjacent: bool,
801        ctx_rule_texts: BTreeMap<usize, String>,
802        members: BTreeMap<usize, i64>,
803        stacks: MemberEnv,
804        local_arg: Option<i64>,
805        column: Option<i64>,
806        token_start_column: Option<i64>,
807        text_so_far: Option<String>,
808        hook_results: Vec<bool>,
809        hook_calls: Vec<HookId>,
810        la_calls: usize,
811        returns: BTreeMap<String, i64>,
812    }
813
814    impl PredContext for MockCtx {
815        type TokenText<'a>
816            = &'a str
817        where
818            Self: 'a;
819
820        fn la(&mut self, offset: isize) -> i64 {
821            self.la_calls += 1;
822            self.lookup(offset).map_or(-1, |(token_type, _)| token_type)
823        }
824
825        fn token_text(&mut self, offset: isize) -> Option<Self::TokenText<'_>> {
826            self.lookup(offset).and_then(|(_, text)| text)
827        }
828
829        fn token_index_adjacent(&mut self) -> bool {
830            self.adjacent
831        }
832
833        fn ctx_rule_text(&self, rule_index: usize) -> Option<String> {
834            self.ctx_rule_texts.get(&rule_index).cloned()
835        }
836
837        fn member(&self, member: usize) -> Option<i64> {
838            self.members.get(&member).copied()
839        }
840
841        fn member_top(&self, member: usize) -> Option<i64> {
842            self.stacks.stack_top(member)
843        }
844
845        fn member_len(&self, member: usize) -> usize {
846            self.stacks.stack_len(member)
847        }
848
849        fn local_arg(&self) -> Option<i64> {
850            self.local_arg
851        }
852
853        fn column(&self) -> Option<i64> {
854            self.column
855        }
856
857        fn token_start_column(&self) -> Option<i64> {
858            self.token_start_column
859        }
860
861        fn token_text_so_far(&self) -> Option<String> {
862            self.text_so_far.clone()
863        }
864
865        fn hook(&mut self, hook: HookId) -> bool {
866            self.hook_calls.push(hook);
867            self.hook_results[hook.index()]
868        }
869    }
870
871    impl ActContext for MockCtx {
872        fn set_member(&mut self, member: usize, value: i64) {
873            self.members.insert(member, value);
874        }
875
876        fn push_member(&mut self, member: usize, value: i64) {
877            self.stacks.push_stack(member, value);
878        }
879
880        fn pop_member(&mut self, member: usize) -> Option<i64> {
881            self.stacks.pop_stack(member)
882        }
883
884        fn set_return(&mut self, name: &str, value: i64) {
885            self.returns.insert(name.to_owned(), value);
886        }
887
888        fn action_hook(&mut self, hook: HookId) {
889            self.hook_calls.push(hook);
890        }
891    }
892
893    impl MockCtx {
894        fn lookup(&self, offset: isize) -> Option<(i64, Option<&'static str>)> {
895            // Offset 1 is the first entry, -1 the last, mirroring LT(k).
896            let index = if offset > 0 {
897                usize::try_from(offset - 1).ok()?
898            } else {
899                self.tokens.len().checked_sub(offset.unsigned_abs())?
900            };
901            self.tokens.get(index).copied()
902        }
903    }
904
905    fn build(build: impl FnOnce(&mut SemIr) -> ExprId) -> (SemIr, ExprId) {
906        let mut ir = SemIr::new();
907        let root = build(&mut ir);
908        (ir, root)
909    }
910
911    #[test]
912    fn literals_and_truthiness() {
913        for (value, expected) in [(true, true), (false, false)] {
914            let (ir, root) = build(|ir| ir.expr(PExpr::Bool(value)));
915            assert_eq!(eval_pred(&ir, root, &mut MockCtx::default()), expected);
916        }
917        let (ir, root) = build(|ir| ir.expr(PExpr::Int(2)));
918        assert!(eval_pred(&ir, root, &mut MockCtx::default()));
919        let (ir, root) = build(|ir| ir.expr(PExpr::Int(0)));
920        assert!(!eval_pred(&ir, root, &mut MockCtx::default()));
921    }
922
923    #[test]
924    fn lookahead_text_equals_literal_and_absent_token_fails() {
925        let (ir, root) = build(|ir| {
926            let text = ir.expr(PExpr::TokenText(1));
927            let literal = ir.intern("of");
928            let literal = ir.expr(PExpr::Str(literal));
929            ir.expr(PExpr::Cmp(CmpOp::Eq, text, literal))
930        });
931
932        let mut ctx = MockCtx {
933            tokens: vec![(7, Some("of"))],
934            ..MockCtx::default()
935        };
936        assert!(eval_pred(&ir, root, &mut ctx));
937
938        ctx.tokens = vec![(7, Some("in"))];
939        assert!(!eval_pred(&ir, root, &mut ctx));
940
941        // Absent token: Eq against a present literal is false.
942        ctx.tokens = Vec::new();
943        assert!(!eval_pred(&ir, root, &mut ctx));
944    }
945
946    #[test]
947    fn ctx_rule_text_not_equals_passes_when_child_absent() {
948        let (ir, root) = build(|ir| {
949            let child = ir.expr(PExpr::CtxRuleText(4));
950            let literal = ir.intern("static");
951            let literal = ir.expr(PExpr::Str(literal));
952            ir.expr(PExpr::Cmp(CmpOp::Ne, child, literal))
953        });
954
955        // Child absent: non-restrictive, passes.
956        assert!(eval_pred(&ir, root, &mut MockCtx::default()));
957
958        let mut ctx = MockCtx {
959            ctx_rule_texts: std::iter::once((4, "static".to_owned())).collect(),
960            ..MockCtx::default()
961        };
962        assert!(!eval_pred(&ir, root, &mut ctx));
963
964        ctx.ctx_rule_texts = std::iter::once((4, "dynamic".to_owned())).collect();
965        assert!(eval_pred(&ir, root, &mut ctx));
966    }
967
968    #[test]
969    fn absent_local_arg_composes_non_restrictive_guard() {
970        // Legacy `LocalIntEquals` semantics: pass when the rule has no
971        // argument, compare when it does.
972        let (ir, root) = build(|ir| {
973            let arg = ir.expr(PExpr::LocalArg);
974            let absent = ir.expr(PExpr::IsNull(arg));
975            let value = ir.expr(PExpr::Int(2));
976            let equals = ir.expr(PExpr::Cmp(CmpOp::Eq, arg, value));
977            ir.expr(PExpr::Or([absent, equals].into()))
978        });
979
980        assert!(eval_pred(&ir, root, &mut MockCtx::default()));
981        let mut ctx = MockCtx {
982            local_arg: Some(2),
983            ..MockCtx::default()
984        };
985        assert!(eval_pred(&ir, root, &mut ctx));
986        ctx.local_arg = Some(3);
987        assert!(!eval_pred(&ir, root, &mut ctx));
988    }
989
990    #[test]
991    fn member_modulo_comparison() {
992        let (ir, root) = build(|ir| {
993            let member = ir.expr(PExpr::Member(0));
994            let modulus = ir.expr(PExpr::Int(2));
995            let remainder = ir.expr(PExpr::Arith(ArithOp::Mod, member, modulus));
996            let expected = ir.expr(PExpr::Int(0));
997            ir.expr(PExpr::Cmp(CmpOp::Eq, remainder, expected))
998        });
999
1000        let mut ctx = MockCtx {
1001            members: std::iter::once((0, 4)).collect(),
1002            ..MockCtx::default()
1003        };
1004        assert!(eval_pred(&ir, root, &mut ctx));
1005        ctx.members.insert(0, 5);
1006        assert!(!eval_pred(&ir, root, &mut ctx));
1007        // Absent member is Null; Eq with a present value is false.
1008        ctx.members.clear();
1009        assert!(!eval_pred(&ir, root, &mut ctx));
1010    }
1011
1012    #[test]
1013    fn arithmetic_null_propagation_and_division_by_zero() {
1014        let (ir, root) = build(|ir| {
1015            let member = ir.expr(PExpr::Member(9));
1016            let zero = ir.expr(PExpr::Int(0));
1017            let modulo = ir.expr(PExpr::Arith(ArithOp::Mod, member, zero));
1018            ir.expr(PExpr::IsNull(modulo))
1019        });
1020        // member(9) present, but % 0 is Null.
1021        let mut ctx = MockCtx {
1022            members: std::iter::once((9, 3)).collect(),
1023            ..MockCtx::default()
1024        };
1025        assert!(eval_pred(&ir, root, &mut ctx));
1026    }
1027
1028    #[test]
1029    fn and_or_short_circuit_left_to_right() {
1030        let (ir, root) = build(|ir| {
1031            let gate = ir.expr(PExpr::Bool(false));
1032            let la = ir.expr(PExpr::La(1));
1033            let one = ir.expr(PExpr::Int(1));
1034            let la_check = ir.expr(PExpr::Cmp(CmpOp::Eq, la, one));
1035            ir.expr(PExpr::And([gate, la_check].into()))
1036        });
1037        let mut ctx = MockCtx::default();
1038        assert!(!eval_pred(&ir, root, &mut ctx));
1039        assert_eq!(ctx.la_calls, 0, "false gate must short-circuit la()");
1040
1041        let (ir, root) = build(|ir| {
1042            let gate = ir.expr(PExpr::Bool(true));
1043            let la = ir.expr(PExpr::La(1));
1044            let one = ir.expr(PExpr::Int(1));
1045            let la_check = ir.expr(PExpr::Cmp(CmpOp::Eq, la, one));
1046            ir.expr(PExpr::Or([gate, la_check].into()))
1047        });
1048        let mut ctx = MockCtx::default();
1049        assert!(eval_pred(&ir, root, &mut ctx));
1050        assert_eq!(ctx.la_calls, 0, "true gate must short-circuit la()");
1051    }
1052
1053    #[test]
1054    fn token_index_adjacency_and_lookahead_type() {
1055        let (ir, root) = build(|ir| ir.expr(PExpr::TokenIndexAdjacent));
1056        let mut ctx = MockCtx {
1057            adjacent: true,
1058            ..MockCtx::default()
1059        };
1060        assert!(eval_pred(&ir, root, &mut ctx));
1061        ctx.adjacent = false;
1062        assert!(!eval_pred(&ir, root, &mut ctx));
1063
1064        let (ir, root) = build(|ir| {
1065            let la = ir.expr(PExpr::La(-1));
1066            let expected = ir.expr(PExpr::Int(12));
1067            ir.expr(PExpr::Cmp(CmpOp::Ne, la, expected))
1068        });
1069        let mut ctx = MockCtx {
1070            tokens: vec![(12, None)],
1071            ..MockCtx::default()
1072        };
1073        assert!(!eval_pred(&ir, root, &mut ctx));
1074        ctx.tokens = vec![(13, None)];
1075        assert!(eval_pred(&ir, root, &mut ctx));
1076    }
1077
1078    #[test]
1079    fn lexer_column_predicates() {
1080        let (ir, root) = build(|ir| {
1081            let column = ir.expr(PExpr::Column);
1082            let limit = ir.expr(PExpr::Int(4));
1083            ir.expr(PExpr::Cmp(CmpOp::Ge, column, limit))
1084        });
1085        let mut ctx = MockCtx {
1086            column: Some(5),
1087            ..MockCtx::default()
1088        };
1089        assert!(eval_pred(&ir, root, &mut ctx));
1090        ctx.column = Some(3);
1091        assert!(!eval_pred(&ir, root, &mut ctx));
1092        // Unknown column: ordering against Null is false.
1093        ctx.column = None;
1094        assert!(!eval_pred(&ir, root, &mut ctx));
1095
1096        let (ir, root) = build(|ir| {
1097            let start = ir.expr(PExpr::TokenStartColumn);
1098            let zero = ir.expr(PExpr::Int(0));
1099            ir.expr(PExpr::Cmp(CmpOp::Eq, start, zero))
1100        });
1101        let mut ctx = MockCtx {
1102            token_start_column: Some(0),
1103            ..MockCtx::default()
1104        };
1105        assert!(eval_pred(&ir, root, &mut ctx));
1106    }
1107
1108    #[test]
1109    fn lexer_text_so_far_comparison() {
1110        let (ir, root) = build(|ir| {
1111            let text = ir.expr(PExpr::TokenTextSoFar);
1112            let literal = ir.intern("aa");
1113            let literal = ir.expr(PExpr::Str(literal));
1114            ir.expr(PExpr::Cmp(CmpOp::Eq, text, literal))
1115        });
1116        let mut ctx = MockCtx {
1117            text_so_far: Some("aa".to_owned()),
1118            ..MockCtx::default()
1119        };
1120        assert!(eval_pred(&ir, root, &mut ctx));
1121        ctx.text_so_far = Some("ab".to_owned());
1122        assert!(!eval_pred(&ir, root, &mut ctx));
1123    }
1124
1125    #[test]
1126    fn hooks_defer_to_context() {
1127        let (ir, root) = build(|ir| ir.expr(PExpr::Hook(HookId(0))));
1128        let mut ctx = MockCtx {
1129            hook_results: vec![true],
1130            ..MockCtx::default()
1131        };
1132        assert!(eval_pred(&ir, root, &mut ctx));
1133        assert_eq!(ctx.hook_calls, vec![HookId(0)]);
1134    }
1135
1136    #[test]
1137    fn statements_mutate_members_and_returns() {
1138        let mut ir = SemIr::new();
1139        let five = ir.expr(PExpr::Int(5));
1140        let set = ir.stmt(AStmt::SetMember(1, five));
1141        let two = ir.expr(PExpr::Int(2));
1142        let add = ir.stmt(AStmt::AddMember(1, two));
1143        let member = ir.expr(PExpr::Member(1));
1144        let name = ir.intern("y");
1145        let ret = ir.stmt(AStmt::SetReturn(name, member));
1146        let seq = ir.stmt(AStmt::Seq([set, add, ret].into()));
1147
1148        let mut ctx = MockCtx::default();
1149        exec_stmt(&ir, seq, &mut ctx);
1150
1151        assert_eq!(ctx.members.get(&1), Some(&7));
1152        assert_eq!(ctx.returns.get("y"), Some(&7));
1153    }
1154
1155    /// The C# interpolation idiom: `Push`/`Pop` a nesting stack and read its
1156    /// top as a boolean guard. `MemberTop` on an empty stack must be falsy
1157    /// rather than panic — the grammar writes
1158    /// `Count > 0 ? Peek() : false` and relies on exactly that.
1159    #[test]
1160    fn stack_member_push_pop_and_empty_reads() {
1161        let mut ir = SemIr::new();
1162        let verbatium = ir.expr(PExpr::Bool(true));
1163        let push_true = ir.stmt(AStmt::PushMember(0, verbatium));
1164        let regular = ir.expr(PExpr::Bool(false));
1165        let push_false = ir.stmt(AStmt::PushMember(0, regular));
1166        let pop = ir.stmt(AStmt::PopMember(0));
1167        let top = ir.expr(PExpr::MemberTop(0));
1168        let depth = ir.expr(PExpr::MemberLen(0));
1169
1170        let mut ctx = MockCtx::default();
1171
1172        // Never pushed: top is Null (falsy), depth is 0.
1173        assert!(!eval_pred(&ir, top, &mut ctx));
1174        assert_eq!(eval_value(&ir, depth, &mut ctx), Value::Int(0));
1175
1176        exec_stmt(&ir, push_true, &mut ctx);
1177        assert!(
1178            eval_pred(&ir, top, &mut ctx),
1179            "pushed true reads back truthy"
1180        );
1181        assert_eq!(eval_value(&ir, depth, &mut ctx), Value::Int(1));
1182
1183        // A `false` push must shadow the `true` beneath it, not vanish.
1184        exec_stmt(&ir, push_false, &mut ctx);
1185        assert!(!eval_pred(&ir, top, &mut ctx));
1186        assert_eq!(eval_value(&ir, depth, &mut ctx), Value::Int(2));
1187
1188        // Popping restores the enclosing frame's value.
1189        exec_stmt(&ir, pop, &mut ctx);
1190        assert!(eval_pred(&ir, top, &mut ctx));
1191        assert_eq!(eval_value(&ir, depth, &mut ctx), Value::Int(1));
1192
1193        exec_stmt(&ir, pop, &mut ctx);
1194        assert_eq!(eval_value(&ir, top, &mut ctx), Value::Null);
1195        assert_eq!(eval_value(&ir, depth, &mut ctx), Value::Int(0));
1196
1197        // Underflow is a defined no-op, not a panic.
1198        exec_stmt(&ir, pop, &mut ctx);
1199        assert_eq!(eval_value(&ir, top, &mut ctx), Value::Null);
1200        assert_eq!(eval_value(&ir, depth, &mut ctx), Value::Int(0));
1201    }
1202
1203    /// Slots hold integers, so a boolean assignment must round-trip through
1204    /// truthiness — `{ verbatium = true; }` then `{ verbatium }?` passes.
1205    #[test]
1206    fn bool_member_assignment_round_trips_through_truthiness() {
1207        let mut ir = SemIr::new();
1208        let yes = ir.expr(PExpr::Bool(true));
1209        let set_true = ir.stmt(AStmt::SetMember(3, yes));
1210        let no = ir.expr(PExpr::Bool(false));
1211        let set_false = ir.stmt(AStmt::SetMember(3, no));
1212        let read = ir.expr(PExpr::Member(3));
1213
1214        let mut ctx = MockCtx::default();
1215        exec_stmt(&ir, set_true, &mut ctx);
1216        assert!(eval_pred(&ir, read, &mut ctx));
1217        exec_stmt(&ir, set_false, &mut ctx);
1218        assert!(!eval_pred(&ir, read, &mut ctx));
1219    }
1220
1221    /// Emptying a stack must return the env to a state that compares equal to
1222    /// one never pushed. The parser's memo key contains this env, so a
1223    /// lingering empty `Vec` would silently stop matching equivalent paths.
1224    #[test]
1225    fn emptied_stack_slot_compares_equal_to_untouched_env() {
1226        let mut env = MemberEnv::new();
1227        env.push_stack(1, 7);
1228        assert_ne!(env, MemberEnv::new());
1229        assert_eq!(env.pop_stack(1), Some(7));
1230        assert_eq!(env, MemberEnv::new(), "emptied stack must canonicalize");
1231        assert!(env.is_empty());
1232        // Underflow leaves it canonical too.
1233        assert_eq!(env.pop_stack(1), None);
1234        assert_eq!(env, MemberEnv::new());
1235    }
1236
1237    /// Scalar and stack slots are separate namespaces: slot 0 as a counter and
1238    /// slot 0 as a stack must not alias.
1239    #[test]
1240    fn scalar_and_stack_slots_do_not_alias() {
1241        let mut env = MemberEnv::new();
1242        env.set_scalar(0, 5);
1243        env.push_stack(0, 9);
1244        assert_eq!(env.scalar(0), Some(5));
1245        assert_eq!(env.stack_top(0), Some(9));
1246        assert_eq!(env.pop_stack(0), Some(9));
1247        assert_eq!(env.scalar(0), Some(5), "popping a stack leaves scalars");
1248    }
1249
1250    #[test]
1251    fn string_interning_deduplicates() {
1252        let mut ir = SemIr::new();
1253        let first = ir.intern("of");
1254        let second = ir.intern("of");
1255        let third = ir.intern("in");
1256        assert_eq!(first, second);
1257        assert_ne!(first, third);
1258        assert_eq!(ir.text(third), "in");
1259    }
1260}