Skip to main content

antlr4_runtime/
semir.rs

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