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
41use std::borrow::Cow;
42use std::fmt::Debug;
43
44/// Index of an expression node inside a [`SemIr`] arena.
45#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
46pub struct ExprId(u32);
47
48impl ExprId {
49    /// Builds an expression id from a producer-assigned arena index.
50    #[must_use]
51    pub const fn new(index: u32) -> Self {
52        Self(index)
53    }
54
55    /// Returns this id's arena index.
56    #[must_use]
57    pub const fn index(self) -> usize {
58        self.0 as usize
59    }
60}
61
62/// Index of a statement node inside a [`SemIr`] arena.
63#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
64pub struct StmtId(u32);
65
66impl StmtId {
67    /// Builds a statement id from a producer-assigned arena index.
68    #[must_use]
69    pub const fn new(index: u32) -> Self {
70        Self(index)
71    }
72
73    /// Returns this id's arena index.
74    #[must_use]
75    pub const fn index(self) -> usize {
76        self.0 as usize
77    }
78}
79
80/// Index of an interned string inside a [`SemIr`] arena.
81#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
82pub struct StrId(u32);
83
84impl StrId {
85    /// Builds an interned-string id from a producer-assigned pool index.
86    #[must_use]
87    pub const fn new(index: u32) -> Self {
88        Self(index)
89    }
90
91    /// Returns this id's string-pool index.
92    #[must_use]
93    pub const fn index(self) -> usize {
94        self.0 as usize
95    }
96}
97
98/// Opaque identifier of an externally implemented hook.
99///
100/// The IR deliberately cannot express arbitrary target code; a hook node
101/// defers one predicate or action to the evaluation context, which maps the
102/// id to grammar-specific behavior (a user trait method, or a runtime shim
103/// such as the conformance suite's evaluation-reporting predicates).
104#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
105pub struct HookId(u32);
106
107impl HookId {
108    /// Builds a hook id from a producer-assigned side-table index.
109    #[must_use]
110    pub const fn new(index: u32) -> Self {
111        Self(index)
112    }
113
114    /// Position of this hook in the producer's hook side table.
115    #[must_use]
116    pub const fn index(self) -> usize {
117        self.0 as usize
118    }
119}
120
121/// Comparison operator for [`PExpr::Cmp`].
122#[derive(Clone, Copy, Debug, Eq, PartialEq)]
123pub enum CmpOp {
124    Eq,
125    Ne,
126    Lt,
127    Le,
128    Gt,
129    Ge,
130}
131
132/// Arithmetic operator for [`PExpr::Arith`].
133#[derive(Clone, Copy, Debug, Eq, PartialEq)]
134pub enum ArithOp {
135    Add,
136    Sub,
137    Mul,
138    Div,
139    Mod,
140}
141
142/// Pure predicate expression node.
143///
144/// Text-valued nodes ([`Self::Str`], [`Self::TokenText`],
145/// [`Self::CtxRuleText`], [`Self::TokenTextSoFar`]) are only meaningful as
146/// operands of [`Self::Cmp`] or [`Self::IsNull`]; evaluating one in any other
147/// position yields [`Value::Null`].
148#[derive(Clone, Debug, Eq, PartialEq)]
149pub enum PExpr {
150    /// Boolean literal.
151    Bool(bool),
152    /// Integer literal.
153    Int(i64),
154    /// Interned text literal (comparison operand only).
155    Str(StrId),
156    /// Token type of `LT(offset)` (parser) or lookahead char (lexer).
157    La(isize),
158    /// Text of the token at `LT(offset)`; Null when the token is absent.
159    TokenText(isize),
160    /// Whether the two most recently consumed tokens were adjacent in the
161    /// token stream (`LT(-2).index + 1 == LT(-1).index`); false when either
162    /// is absent.
163    TokenIndexAdjacent,
164    /// Text of the current rule context's first child with this rule index;
165    /// Null when the context or child is absent.
166    CtxRuleText(usize),
167    /// Integer state slot declared by the grammar (`@members` counters).
168    Member(usize),
169    /// Integer argument of the current rule invocation; Null when the rule
170    /// was invoked without one.
171    LocalArg,
172    /// Lexer: current character position within the line.
173    Column,
174    /// Lexer: character position of the current token's first character.
175    TokenStartColumn,
176    /// Lexer: text matched so far for the in-progress token.
177    TokenTextSoFar,
178    /// True when the operand evaluates to Null (or, for a text-valued
179    /// operand, when its text is absent).
180    IsNull(ExprId),
181    /// Logical negation of the operand's truthiness.
182    Not(ExprId),
183    /// Short-circuit conjunction, evaluated left to right.
184    And(Box<[ExprId]>),
185    /// Short-circuit disjunction, evaluated left to right.
186    Or(Box<[ExprId]>),
187    /// Comparison; text operands take the text-comparison path.
188    Cmp(CmpOp, ExprId, ExprId),
189    /// Integer arithmetic with Null propagation.
190    Arith(ArithOp, ExprId, ExprId),
191    /// Defer to the context's hook table.
192    Hook(HookId),
193    /// Return a boolean while letting the recognizer report the evaluation.
194    ///
195    /// This keeps ANTLR runtime-testsuite `Invoke_pred` templates data-driven
196    /// without making ordinary predicates effectful.
197    EvalTrace(bool),
198}
199
200/// Effectful action statement node.
201///
202/// Statements never run during prediction unless the runtime explicitly
203/// classifies them as speculation-eligible (member-only mutations evaluated
204/// against a transactional member environment).
205#[derive(Clone, Debug, Eq, PartialEq)]
206pub enum AStmt {
207    /// `member = expr`.
208    SetMember(usize, ExprId),
209    /// `member += expr`.
210    AddMember(usize, ExprId),
211    /// Assign a rule return field by name.
212    SetReturn(StrId, ExprId),
213    /// Execute statements in order.
214    Seq(Box<[StmtId]>),
215    /// Defer to the context's action hook table.
216    Hook(HookId),
217}
218
219/// Evaluation result of a non-text expression.
220#[allow(variant_size_differences)]
221#[derive(Clone, Copy, Debug, Eq, PartialEq)]
222pub enum Value {
223    /// An absent recognizer value (missing token, member, argument, …).
224    Null,
225    Bool(bool),
226    Int(i64),
227}
228
229impl Value {
230    /// Truthiness used by logical nodes and by [`eval_pred`]'s final result.
231    #[must_use]
232    pub const fn truthy(self) -> bool {
233        match self {
234            Self::Null => false,
235            Self::Bool(value) => value,
236            Self::Int(value) => value != 0,
237        }
238    }
239}
240
241/// Recognizer-state queries the predicate evaluator needs.
242///
243/// Implementations are thin adapters over a lexer or parser; queries that do
244/// not exist for the implementing recognizer return `None` (evaluating to
245/// Null). Lookahead methods take `&mut self` because token streams buffer
246/// lazily.
247pub trait PredContext {
248    /// Token type (parser) or character (lexer) at the given lookahead.
249    fn la(&mut self, offset: isize) -> i64;
250    /// Text of the token at the given lookahead, if present.
251    fn token_text(&mut self, offset: isize) -> Option<&str>;
252    /// Whether `LT(-2)` and `LT(-1)` are adjacent token-stream entries.
253    fn token_index_adjacent(&mut self) -> bool;
254    /// Text of the current context's first child with this rule index.
255    fn ctx_rule_text(&self, rule_index: usize) -> Option<String>;
256    /// Integer member slot value.
257    fn member(&self, member: usize) -> Option<i64>;
258    /// Integer argument of the current rule invocation.
259    fn local_arg(&self) -> Option<i64>;
260    /// Lexer current character position within the line.
261    fn column(&self) -> Option<i64>;
262    /// Lexer character position of the current token's start.
263    fn token_start_column(&self) -> Option<i64>;
264    /// Lexer text matched so far for the in-progress token.
265    fn token_text_so_far(&self) -> Option<String>;
266    /// Evaluates an externally implemented predicate hook.
267    fn hook(&mut self, hook: HookId) -> bool;
268    /// Reports an observable predicate-evaluation template and returns `value`.
269    fn trace_bool(&mut self, value: bool) -> bool {
270        value
271    }
272}
273
274/// Mutations the action evaluator needs, on top of predicate queries.
275pub trait ActContext: PredContext {
276    /// Writes an integer member slot.
277    fn set_member(&mut self, member: usize, value: i64);
278    /// Assigns a rule return field by name.
279    fn set_return(&mut self, name: &str, value: i64);
280    /// Runs an externally implemented action hook.
281    fn action_hook(&mut self, hook: HookId);
282}
283
284/// Flat expression/statement arena with an interned string pool.
285///
286/// Producers append nodes through the builder methods and hand the finished
287/// arena plus root ids to the runtime; evaluation never mutates the arena.
288#[derive(Clone, Debug, Default, Eq, PartialEq)]
289pub struct SemIr {
290    exprs: Vec<PExpr>,
291    stmts: Vec<AStmt>,
292    strings: Vec<Box<str>>,
293}
294
295impl SemIr {
296    #[must_use]
297    pub fn new() -> Self {
298        Self::default()
299    }
300
301    /// Appends an expression node and returns its id.
302    pub fn expr(&mut self, node: PExpr) -> ExprId {
303        let id = ExprId(u32::try_from(self.exprs.len()).expect("expression arena fits in u32"));
304        self.exprs.push(node);
305        id
306    }
307
308    /// Appends a statement node and returns its id.
309    pub fn stmt(&mut self, node: AStmt) -> StmtId {
310        let id = StmtId(u32::try_from(self.stmts.len()).expect("statement arena fits in u32"));
311        self.stmts.push(node);
312        id
313    }
314
315    /// Interns a string literal, reusing an existing pool entry when equal.
316    pub fn intern(&mut self, value: &str) -> StrId {
317        if let Some(position) = self.strings.iter().position(|entry| &**entry == value) {
318            return StrId(u32::try_from(position).expect("string pool fits in u32"));
319        }
320        let id = StrId(u32::try_from(self.strings.len()).expect("string pool fits in u32"));
321        self.strings.push(value.into());
322        id
323    }
324
325    /// Resolves an interned string.
326    #[must_use]
327    pub fn text(&self, id: StrId) -> &str {
328        &self.strings[id.0 as usize]
329    }
330
331    fn node(&self, id: ExprId) -> &PExpr {
332        &self.exprs[id.0 as usize]
333    }
334
335    fn stmt_node(&self, id: StmtId) -> &AStmt {
336        &self.stmts[id.0 as usize]
337    }
338}
339
340/// Evaluates a predicate expression to its truthiness.
341///
342/// This is the runtime entry point for semantic predicate transitions; it is
343/// side-effect-free except for [`PExpr::Hook`] nodes, whose implementations
344/// own their replay-safety (they may run repeatedly on speculative paths).
345pub fn eval_pred<C: PredContext>(ir: &SemIr, expr: ExprId, ctx: &mut C) -> bool {
346    eval_value(ir, expr, ctx).truthy()
347}
348
349/// Executes an action statement against a mutable context.
350pub fn exec_stmt<C: ActContext>(ir: &SemIr, stmt: StmtId, ctx: &mut C) {
351    match ir.stmt_node(stmt) {
352        AStmt::SetMember(member, value) => {
353            let value = int_or_zero(eval_value(ir, *value, ctx));
354            ctx.set_member(*member, value);
355        }
356        AStmt::AddMember(member, delta) => {
357            let delta = int_or_zero(eval_value(ir, *delta, ctx));
358            let current = ctx.member(*member).unwrap_or_default();
359            ctx.set_member(*member, current + delta);
360        }
361        AStmt::SetReturn(name, value) => {
362            let value = int_or_zero(eval_value(ir, *value, ctx));
363            let name = ir.text(*name).to_owned();
364            ctx.set_return(&name, value);
365        }
366        AStmt::Seq(stmts) => {
367            for stmt in stmts {
368                exec_stmt(ir, *stmt, ctx);
369            }
370        }
371        AStmt::Hook(hook) => ctx.action_hook(*hook),
372    }
373}
374
375const fn int_or_zero(value: Value) -> i64 {
376    match value {
377        Value::Int(value) => value,
378        Value::Null | Value::Bool(_) => 0,
379    }
380}
381
382fn eval_value<C: PredContext>(ir: &SemIr, expr: ExprId, ctx: &mut C) -> Value {
383    match ir.node(expr) {
384        // Text-valued nodes are comparison operands; anywhere else they have
385        // no defined value.
386        PExpr::Str(_) | PExpr::TokenText(_) | PExpr::CtxRuleText(_) | PExpr::TokenTextSoFar => {
387            debug_assert!(false, "text-valued node evaluated outside a comparison");
388            Value::Null
389        }
390        PExpr::Bool(value) => Value::Bool(*value),
391        PExpr::Int(value) => Value::Int(*value),
392        PExpr::La(offset) => Value::Int(ctx.la(*offset)),
393        PExpr::TokenIndexAdjacent => Value::Bool(ctx.token_index_adjacent()),
394        PExpr::Member(member) => ctx.member(*member).map_or(Value::Null, Value::Int),
395        PExpr::LocalArg => ctx.local_arg().map_or(Value::Null, Value::Int),
396        PExpr::Column => ctx.column().map_or(Value::Null, Value::Int),
397        PExpr::TokenStartColumn => ctx.token_start_column().map_or(Value::Null, Value::Int),
398        PExpr::IsNull(inner) => Value::Bool(eval_is_null(ir, *inner, ctx)),
399        PExpr::Not(inner) => Value::Bool(!eval_value(ir, *inner, ctx).truthy()),
400        PExpr::And(children) => Value::Bool(
401            children
402                .iter()
403                .all(|child| eval_value(ir, *child, ctx).truthy()),
404        ),
405        PExpr::Or(children) => Value::Bool(
406            children
407                .iter()
408                .any(|child| eval_value(ir, *child, ctx).truthy()),
409        ),
410        PExpr::Cmp(op, lhs, rhs) => eval_cmp(ir, *op, *lhs, *rhs, ctx),
411        PExpr::Arith(op, lhs, rhs) => eval_arith(ir, *op, *lhs, *rhs, ctx),
412        PExpr::Hook(hook) => Value::Bool(ctx.hook(*hook)),
413        PExpr::EvalTrace(value) => Value::Bool(ctx.trace_bool(*value)),
414    }
415}
416
417fn eval_is_null<C: PredContext>(ir: &SemIr, inner: ExprId, ctx: &mut C) -> bool {
418    if let Some(source) = text_source(ir, inner) {
419        return resolve_owned_text(ir, source, ctx).is_none();
420    }
421    eval_value(ir, inner, ctx) == Value::Null
422}
423
424fn eval_cmp<C: PredContext>(ir: &SemIr, op: CmpOp, lhs: ExprId, rhs: ExprId, ctx: &mut C) -> Value {
425    let left_source = text_source(ir, lhs);
426    let right_source = text_source(ir, rhs);
427    if left_source.is_some() || right_source.is_some() {
428        return eval_text_cmp(ir, op, (lhs, left_source), (rhs, right_source), ctx);
429    }
430    let left = eval_value(ir, lhs, ctx);
431    let right = eval_value(ir, rhs, ctx);
432    Value::Bool(match (left, right) {
433        (Value::Null, Value::Null) => cmp_on_equality(op, true),
434        (Value::Null, _) | (_, Value::Null) => cmp_on_equality(op, false),
435        (Value::Bool(left), Value::Bool(right)) => cmp_on_equality(op, left == right),
436        (Value::Int(left), Value::Int(right)) => cmp_ints(op, left, right),
437        (Value::Bool(_), Value::Int(_)) | (Value::Int(_), Value::Bool(_)) => {
438            cmp_on_equality(op, false)
439        }
440    })
441}
442
443/// Comparison outcome for operands that only carry equality (Null, Bool,
444/// mismatched kinds): ordering operators are false.
445const fn cmp_on_equality(op: CmpOp, equal: bool) -> bool {
446    match op {
447        CmpOp::Eq => equal,
448        CmpOp::Ne => !equal,
449        CmpOp::Lt | CmpOp::Le | CmpOp::Gt | CmpOp::Ge => false,
450    }
451}
452
453const fn cmp_ints(op: CmpOp, left: i64, right: i64) -> bool {
454    match op {
455        CmpOp::Eq => left == right,
456        CmpOp::Ne => left != right,
457        CmpOp::Lt => left < right,
458        CmpOp::Le => left <= right,
459        CmpOp::Gt => left > right,
460        CmpOp::Ge => left >= right,
461    }
462}
463
464/// Where a text-valued operand's characters come from.
465///
466/// Only [`Self::Lookahead`] holds a borrow of the context while its `&str`
467/// is alive; the other sources either borrow the IR string pool or return an
468/// owned `String`. `eval_text_cmp` resolves the non-lookahead side first so
469/// the common `token-text == literal` comparison stays allocation-free.
470#[derive(Clone, Copy, Debug)]
471enum TextSource {
472    Literal(StrId),
473    Lookahead(isize),
474    CtxRule(usize),
475    SoFar,
476}
477
478fn text_source(ir: &SemIr, expr: ExprId) -> Option<TextSource> {
479    match ir.node(expr) {
480        PExpr::Str(id) => Some(TextSource::Literal(*id)),
481        PExpr::TokenText(offset) => Some(TextSource::Lookahead(*offset)),
482        PExpr::CtxRuleText(rule_index) => Some(TextSource::CtxRule(*rule_index)),
483        PExpr::TokenTextSoFar => Some(TextSource::SoFar),
484        _ => None,
485    }
486}
487
488/// Resolves a non-lookahead text operand without holding a context borrow.
489fn resolve_static_text<'ir, C: PredContext>(
490    ir: &'ir SemIr,
491    source: TextSource,
492    ctx: &C,
493) -> Option<Cow<'ir, str>> {
494    match source {
495        TextSource::Literal(id) => Some(Cow::Borrowed(ir.text(id))),
496        TextSource::Lookahead(_) => unreachable!("lookahead operands are resolved last"),
497        TextSource::CtxRule(rule_index) => ctx.ctx_rule_text(rule_index).map(Cow::Owned),
498        TextSource::SoFar => ctx.token_text_so_far().map(Cow::Owned),
499    }
500}
501
502/// Owned resolution used by [`PExpr::IsNull`] over text operands.
503fn resolve_owned_text<C: PredContext>(
504    ir: &SemIr,
505    source: TextSource,
506    ctx: &mut C,
507) -> Option<String> {
508    match source {
509        TextSource::Lookahead(offset) => ctx.token_text(offset).map(str::to_owned),
510        other => resolve_static_text(ir, other, ctx).map(Cow::into_owned),
511    }
512}
513
514fn eval_text_cmp<C: PredContext>(
515    ir: &SemIr,
516    op: CmpOp,
517    (lhs, left_source): (ExprId, Option<TextSource>),
518    (rhs, right_source): (ExprId, Option<TextSource>),
519    ctx: &mut C,
520) -> Value {
521    // A text operand compared against a non-text operand has no defined
522    // value relationship; only equality semantics apply (never equal).
523    let (Some(left_source), Some(right_source)) = (left_source, right_source) else {
524        debug_assert!(false, "text operand compared with non-text operand");
525        let _ = (lhs, rhs);
526        return Value::Bool(cmp_on_equality(op, false));
527    };
528    Value::Bool(match (left_source, right_source) {
529        (TextSource::Lookahead(left), TextSource::Lookahead(right)) => {
530            // Two live lookahead borrows cannot coexist; own the left side.
531            // No current producer emits this shape, so the allocation is
532            // acceptable.
533            let left = ctx.token_text(left).map(str::to_owned);
534            let right = ctx.token_text(right);
535            cmp_texts(op, left.as_deref(), right)
536        }
537        (TextSource::Lookahead(offset), other) => {
538            let right = resolve_static_text(ir, other, ctx);
539            let left = ctx.token_text(offset);
540            cmp_texts(op, left, right.as_deref())
541        }
542        (other, TextSource::Lookahead(offset)) => {
543            let left = resolve_static_text(ir, other, ctx);
544            let right = ctx.token_text(offset);
545            cmp_texts(op, left.as_deref(), right)
546        }
547        (left, right) => {
548            let left = resolve_static_text(ir, left, ctx);
549            let right = resolve_static_text(ir, right, ctx);
550            cmp_texts(op, left.as_deref(), right.as_deref())
551        }
552    })
553}
554
555fn cmp_texts(op: CmpOp, left: Option<&str>, right: Option<&str>) -> bool {
556    match (left, right) {
557        (None, None) => cmp_on_equality(op, true),
558        (None, Some(_)) | (Some(_), None) => cmp_on_equality(op, false),
559        (Some(left), Some(right)) => match op {
560            CmpOp::Eq => left == right,
561            CmpOp::Ne => left != right,
562            CmpOp::Lt => left < right,
563            CmpOp::Le => left <= right,
564            CmpOp::Gt => left > right,
565            CmpOp::Ge => left >= right,
566        },
567    }
568}
569
570fn eval_arith<C: PredContext>(
571    ir: &SemIr,
572    op: ArithOp,
573    lhs: ExprId,
574    rhs: ExprId,
575    ctx: &mut C,
576) -> Value {
577    let (Value::Int(left), Value::Int(right)) =
578        (eval_value(ir, lhs, ctx), eval_value(ir, rhs, ctx))
579    else {
580        return Value::Null;
581    };
582    let result = match op {
583        ArithOp::Add => left.checked_add(right),
584        ArithOp::Sub => left.checked_sub(right),
585        ArithOp::Mul => left.checked_mul(right),
586        ArithOp::Div => left.checked_div(right),
587        ArithOp::Mod => left.checked_rem(right),
588    };
589    result.map_or(Value::Null, Value::Int)
590}
591
592#[cfg(test)]
593mod tests {
594    use super::{
595        AStmt, ActContext, ArithOp, CmpOp, ExprId, HookId, PExpr, PredContext, SemIr, eval_pred,
596        exec_stmt,
597    };
598    use std::collections::BTreeMap;
599
600    /// Scriptable recognizer stand-in for evaluator tests.
601    #[derive(Debug, Default)]
602    struct MockCtx {
603        tokens: Vec<(i64, Option<&'static str>)>,
604        adjacent: bool,
605        ctx_rule_texts: BTreeMap<usize, String>,
606        members: BTreeMap<usize, i64>,
607        local_arg: Option<i64>,
608        column: Option<i64>,
609        token_start_column: Option<i64>,
610        text_so_far: Option<String>,
611        hook_results: Vec<bool>,
612        hook_calls: Vec<HookId>,
613        la_calls: usize,
614        returns: BTreeMap<String, i64>,
615    }
616
617    impl PredContext for MockCtx {
618        fn la(&mut self, offset: isize) -> i64 {
619            self.la_calls += 1;
620            self.lookup(offset).map_or(-1, |(token_type, _)| token_type)
621        }
622
623        fn token_text(&mut self, offset: isize) -> Option<&str> {
624            self.lookup(offset).and_then(|(_, text)| text)
625        }
626
627        fn token_index_adjacent(&mut self) -> bool {
628            self.adjacent
629        }
630
631        fn ctx_rule_text(&self, rule_index: usize) -> Option<String> {
632            self.ctx_rule_texts.get(&rule_index).cloned()
633        }
634
635        fn member(&self, member: usize) -> Option<i64> {
636            self.members.get(&member).copied()
637        }
638
639        fn local_arg(&self) -> Option<i64> {
640            self.local_arg
641        }
642
643        fn column(&self) -> Option<i64> {
644            self.column
645        }
646
647        fn token_start_column(&self) -> Option<i64> {
648            self.token_start_column
649        }
650
651        fn token_text_so_far(&self) -> Option<String> {
652            self.text_so_far.clone()
653        }
654
655        fn hook(&mut self, hook: HookId) -> bool {
656            self.hook_calls.push(hook);
657            self.hook_results[hook.index()]
658        }
659    }
660
661    impl ActContext for MockCtx {
662        fn set_member(&mut self, member: usize, value: i64) {
663            self.members.insert(member, value);
664        }
665
666        fn set_return(&mut self, name: &str, value: i64) {
667            self.returns.insert(name.to_owned(), value);
668        }
669
670        fn action_hook(&mut self, hook: HookId) {
671            self.hook_calls.push(hook);
672        }
673    }
674
675    impl MockCtx {
676        fn lookup(&self, offset: isize) -> Option<(i64, Option<&'static str>)> {
677            // Offset 1 is the first entry, -1 the last, mirroring LT(k).
678            let index = if offset > 0 {
679                usize::try_from(offset - 1).ok()?
680            } else {
681                self.tokens.len().checked_sub(offset.unsigned_abs())?
682            };
683            self.tokens.get(index).copied()
684        }
685    }
686
687    fn build(build: impl FnOnce(&mut SemIr) -> ExprId) -> (SemIr, ExprId) {
688        let mut ir = SemIr::new();
689        let root = build(&mut ir);
690        (ir, root)
691    }
692
693    #[test]
694    fn literals_and_truthiness() {
695        for (value, expected) in [(true, true), (false, false)] {
696            let (ir, root) = build(|ir| ir.expr(PExpr::Bool(value)));
697            assert_eq!(eval_pred(&ir, root, &mut MockCtx::default()), expected);
698        }
699        let (ir, root) = build(|ir| ir.expr(PExpr::Int(2)));
700        assert!(eval_pred(&ir, root, &mut MockCtx::default()));
701        let (ir, root) = build(|ir| ir.expr(PExpr::Int(0)));
702        assert!(!eval_pred(&ir, root, &mut MockCtx::default()));
703    }
704
705    #[test]
706    fn lookahead_text_equals_literal_and_absent_token_fails() {
707        let (ir, root) = build(|ir| {
708            let text = ir.expr(PExpr::TokenText(1));
709            let literal = ir.intern("of");
710            let literal = ir.expr(PExpr::Str(literal));
711            ir.expr(PExpr::Cmp(CmpOp::Eq, text, literal))
712        });
713
714        let mut ctx = MockCtx {
715            tokens: vec![(7, Some("of"))],
716            ..MockCtx::default()
717        };
718        assert!(eval_pred(&ir, root, &mut ctx));
719
720        ctx.tokens = vec![(7, Some("in"))];
721        assert!(!eval_pred(&ir, root, &mut ctx));
722
723        // Absent token: Eq against a present literal is false.
724        ctx.tokens = Vec::new();
725        assert!(!eval_pred(&ir, root, &mut ctx));
726    }
727
728    #[test]
729    fn ctx_rule_text_not_equals_passes_when_child_absent() {
730        let (ir, root) = build(|ir| {
731            let child = ir.expr(PExpr::CtxRuleText(4));
732            let literal = ir.intern("static");
733            let literal = ir.expr(PExpr::Str(literal));
734            ir.expr(PExpr::Cmp(CmpOp::Ne, child, literal))
735        });
736
737        // Child absent: non-restrictive, passes.
738        assert!(eval_pred(&ir, root, &mut MockCtx::default()));
739
740        let mut ctx = MockCtx {
741            ctx_rule_texts: std::iter::once((4, "static".to_owned())).collect(),
742            ..MockCtx::default()
743        };
744        assert!(!eval_pred(&ir, root, &mut ctx));
745
746        ctx.ctx_rule_texts = std::iter::once((4, "dynamic".to_owned())).collect();
747        assert!(eval_pred(&ir, root, &mut ctx));
748    }
749
750    #[test]
751    fn absent_local_arg_composes_non_restrictive_guard() {
752        // Legacy `LocalIntEquals` semantics: pass when the rule has no
753        // argument, compare when it does.
754        let (ir, root) = build(|ir| {
755            let arg = ir.expr(PExpr::LocalArg);
756            let absent = ir.expr(PExpr::IsNull(arg));
757            let value = ir.expr(PExpr::Int(2));
758            let equals = ir.expr(PExpr::Cmp(CmpOp::Eq, arg, value));
759            ir.expr(PExpr::Or([absent, equals].into()))
760        });
761
762        assert!(eval_pred(&ir, root, &mut MockCtx::default()));
763        let mut ctx = MockCtx {
764            local_arg: Some(2),
765            ..MockCtx::default()
766        };
767        assert!(eval_pred(&ir, root, &mut ctx));
768        ctx.local_arg = Some(3);
769        assert!(!eval_pred(&ir, root, &mut ctx));
770    }
771
772    #[test]
773    fn member_modulo_comparison() {
774        let (ir, root) = build(|ir| {
775            let member = ir.expr(PExpr::Member(0));
776            let modulus = ir.expr(PExpr::Int(2));
777            let remainder = ir.expr(PExpr::Arith(ArithOp::Mod, member, modulus));
778            let expected = ir.expr(PExpr::Int(0));
779            ir.expr(PExpr::Cmp(CmpOp::Eq, remainder, expected))
780        });
781
782        let mut ctx = MockCtx {
783            members: std::iter::once((0, 4)).collect(),
784            ..MockCtx::default()
785        };
786        assert!(eval_pred(&ir, root, &mut ctx));
787        ctx.members.insert(0, 5);
788        assert!(!eval_pred(&ir, root, &mut ctx));
789        // Absent member is Null; Eq with a present value is false.
790        ctx.members.clear();
791        assert!(!eval_pred(&ir, root, &mut ctx));
792    }
793
794    #[test]
795    fn arithmetic_null_propagation_and_division_by_zero() {
796        let (ir, root) = build(|ir| {
797            let member = ir.expr(PExpr::Member(9));
798            let zero = ir.expr(PExpr::Int(0));
799            let modulo = ir.expr(PExpr::Arith(ArithOp::Mod, member, zero));
800            ir.expr(PExpr::IsNull(modulo))
801        });
802        // member(9) present, but % 0 is Null.
803        let mut ctx = MockCtx {
804            members: std::iter::once((9, 3)).collect(),
805            ..MockCtx::default()
806        };
807        assert!(eval_pred(&ir, root, &mut ctx));
808    }
809
810    #[test]
811    fn and_or_short_circuit_left_to_right() {
812        let (ir, root) = build(|ir| {
813            let gate = ir.expr(PExpr::Bool(false));
814            let la = ir.expr(PExpr::La(1));
815            let one = ir.expr(PExpr::Int(1));
816            let la_check = ir.expr(PExpr::Cmp(CmpOp::Eq, la, one));
817            ir.expr(PExpr::And([gate, la_check].into()))
818        });
819        let mut ctx = MockCtx::default();
820        assert!(!eval_pred(&ir, root, &mut ctx));
821        assert_eq!(ctx.la_calls, 0, "false gate must short-circuit la()");
822
823        let (ir, root) = build(|ir| {
824            let gate = ir.expr(PExpr::Bool(true));
825            let la = ir.expr(PExpr::La(1));
826            let one = ir.expr(PExpr::Int(1));
827            let la_check = ir.expr(PExpr::Cmp(CmpOp::Eq, la, one));
828            ir.expr(PExpr::Or([gate, la_check].into()))
829        });
830        let mut ctx = MockCtx::default();
831        assert!(eval_pred(&ir, root, &mut ctx));
832        assert_eq!(ctx.la_calls, 0, "true gate must short-circuit la()");
833    }
834
835    #[test]
836    fn token_index_adjacency_and_lookahead_type() {
837        let (ir, root) = build(|ir| ir.expr(PExpr::TokenIndexAdjacent));
838        let mut ctx = MockCtx {
839            adjacent: true,
840            ..MockCtx::default()
841        };
842        assert!(eval_pred(&ir, root, &mut ctx));
843        ctx.adjacent = false;
844        assert!(!eval_pred(&ir, root, &mut ctx));
845
846        let (ir, root) = build(|ir| {
847            let la = ir.expr(PExpr::La(-1));
848            let expected = ir.expr(PExpr::Int(12));
849            ir.expr(PExpr::Cmp(CmpOp::Ne, la, expected))
850        });
851        let mut ctx = MockCtx {
852            tokens: vec![(12, None)],
853            ..MockCtx::default()
854        };
855        assert!(!eval_pred(&ir, root, &mut ctx));
856        ctx.tokens = vec![(13, None)];
857        assert!(eval_pred(&ir, root, &mut ctx));
858    }
859
860    #[test]
861    fn lexer_column_predicates() {
862        let (ir, root) = build(|ir| {
863            let column = ir.expr(PExpr::Column);
864            let limit = ir.expr(PExpr::Int(4));
865            ir.expr(PExpr::Cmp(CmpOp::Ge, column, limit))
866        });
867        let mut ctx = MockCtx {
868            column: Some(5),
869            ..MockCtx::default()
870        };
871        assert!(eval_pred(&ir, root, &mut ctx));
872        ctx.column = Some(3);
873        assert!(!eval_pred(&ir, root, &mut ctx));
874        // Unknown column: ordering against Null is false.
875        ctx.column = None;
876        assert!(!eval_pred(&ir, root, &mut ctx));
877
878        let (ir, root) = build(|ir| {
879            let start = ir.expr(PExpr::TokenStartColumn);
880            let zero = ir.expr(PExpr::Int(0));
881            ir.expr(PExpr::Cmp(CmpOp::Eq, start, zero))
882        });
883        let mut ctx = MockCtx {
884            token_start_column: Some(0),
885            ..MockCtx::default()
886        };
887        assert!(eval_pred(&ir, root, &mut ctx));
888    }
889
890    #[test]
891    fn lexer_text_so_far_comparison() {
892        let (ir, root) = build(|ir| {
893            let text = ir.expr(PExpr::TokenTextSoFar);
894            let literal = ir.intern("aa");
895            let literal = ir.expr(PExpr::Str(literal));
896            ir.expr(PExpr::Cmp(CmpOp::Eq, text, literal))
897        });
898        let mut ctx = MockCtx {
899            text_so_far: Some("aa".to_owned()),
900            ..MockCtx::default()
901        };
902        assert!(eval_pred(&ir, root, &mut ctx));
903        ctx.text_so_far = Some("ab".to_owned());
904        assert!(!eval_pred(&ir, root, &mut ctx));
905    }
906
907    #[test]
908    fn hooks_defer_to_context() {
909        let (ir, root) = build(|ir| ir.expr(PExpr::Hook(HookId(0))));
910        let mut ctx = MockCtx {
911            hook_results: vec![true],
912            ..MockCtx::default()
913        };
914        assert!(eval_pred(&ir, root, &mut ctx));
915        assert_eq!(ctx.hook_calls, vec![HookId(0)]);
916    }
917
918    #[test]
919    fn statements_mutate_members_and_returns() {
920        let mut ir = SemIr::new();
921        let five = ir.expr(PExpr::Int(5));
922        let set = ir.stmt(AStmt::SetMember(1, five));
923        let two = ir.expr(PExpr::Int(2));
924        let add = ir.stmt(AStmt::AddMember(1, two));
925        let member = ir.expr(PExpr::Member(1));
926        let name = ir.intern("y");
927        let ret = ir.stmt(AStmt::SetReturn(name, member));
928        let seq = ir.stmt(AStmt::Seq([set, add, ret].into()));
929
930        let mut ctx = MockCtx::default();
931        exec_stmt(&ir, seq, &mut ctx);
932
933        assert_eq!(ctx.members.get(&1), Some(&7));
934        assert_eq!(ctx.returns.get("y"), Some(&7));
935    }
936
937    #[test]
938    fn string_interning_deduplicates() {
939        let mut ir = SemIr::new();
940        let first = ir.intern("of");
941        let second = ir.intern("of");
942        let third = ir.intern("in");
943        assert_eq!(first, second);
944        assert_ne!(first, third);
945        assert_eq!(ir.text(third), "in");
946    }
947}