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    type TokenText<'a>: AsRef<str>
249    where
250        Self: 'a;
251
252    /// Token type (parser) or character (lexer) at the given lookahead.
253    fn la(&mut self, offset: isize) -> i64;
254    /// Text of the token at the given lookahead, if present.
255    fn token_text(&mut self, offset: isize) -> Option<Self::TokenText<'_>>;
256    /// Whether `LT(-2)` and `LT(-1)` are adjacent token-stream entries.
257    fn token_index_adjacent(&mut self) -> bool;
258    /// Text of the current context's first child with this rule index.
259    fn ctx_rule_text(&self, rule_index: usize) -> Option<String>;
260    /// Integer member slot value.
261    fn member(&self, member: usize) -> Option<i64>;
262    /// Integer argument of the current rule invocation.
263    fn local_arg(&self) -> Option<i64>;
264    /// Lexer current character position within the line.
265    fn column(&self) -> Option<i64>;
266    /// Lexer character position of the current token's start.
267    fn token_start_column(&self) -> Option<i64>;
268    /// Lexer text matched so far for the in-progress token.
269    fn token_text_so_far(&self) -> Option<String>;
270    /// Evaluates an externally implemented predicate hook.
271    fn hook(&mut self, hook: HookId) -> bool;
272    /// Reports an observable predicate-evaluation template and returns `value`.
273    fn trace_bool(&mut self, value: bool) -> bool {
274        value
275    }
276}
277
278/// Mutations the action evaluator needs, on top of predicate queries.
279pub trait ActContext: PredContext {
280    /// Writes an integer member slot.
281    fn set_member(&mut self, member: usize, value: i64);
282    /// Assigns a rule return field by name.
283    fn set_return(&mut self, name: &str, value: i64);
284    /// Runs an externally implemented action hook.
285    fn action_hook(&mut self, hook: HookId);
286}
287
288/// Flat expression/statement arena with an interned string pool.
289///
290/// Producers append nodes through the builder methods and hand the finished
291/// arena plus root ids to the runtime; evaluation never mutates the arena.
292#[derive(Clone, Debug, Default, Eq, PartialEq)]
293pub struct SemIr {
294    exprs: Vec<PExpr>,
295    stmts: Vec<AStmt>,
296    strings: Vec<Box<str>>,
297}
298
299impl SemIr {
300    #[must_use]
301    pub fn new() -> Self {
302        Self::default()
303    }
304
305    /// Appends an expression node and returns its id.
306    pub fn expr(&mut self, node: PExpr) -> ExprId {
307        let id = ExprId(u32::try_from(self.exprs.len()).expect("expression arena fits in u32"));
308        self.exprs.push(node);
309        id
310    }
311
312    /// Appends a statement node and returns its id.
313    pub fn stmt(&mut self, node: AStmt) -> StmtId {
314        let id = StmtId(u32::try_from(self.stmts.len()).expect("statement arena fits in u32"));
315        self.stmts.push(node);
316        id
317    }
318
319    /// Interns a string literal, reusing an existing pool entry when equal.
320    pub fn intern(&mut self, value: &str) -> StrId {
321        if let Some(position) = self.strings.iter().position(|entry| &**entry == value) {
322            return StrId(u32::try_from(position).expect("string pool fits in u32"));
323        }
324        let id = StrId(u32::try_from(self.strings.len()).expect("string pool fits in u32"));
325        self.strings.push(value.into());
326        id
327    }
328
329    /// Resolves an interned string.
330    #[must_use]
331    pub fn text(&self, id: StrId) -> &str {
332        &self.strings[id.0 as usize]
333    }
334
335    fn node(&self, id: ExprId) -> &PExpr {
336        &self.exprs[id.0 as usize]
337    }
338
339    fn stmt_node(&self, id: StmtId) -> &AStmt {
340        &self.stmts[id.0 as usize]
341    }
342}
343
344/// Evaluates a predicate expression to its truthiness.
345///
346/// This is the runtime entry point for semantic predicate transitions; it is
347/// side-effect-free except for [`PExpr::Hook`] nodes, whose implementations
348/// own their replay-safety (they may run repeatedly on speculative paths).
349pub fn eval_pred<C: PredContext>(ir: &SemIr, expr: ExprId, ctx: &mut C) -> bool {
350    eval_value(ir, expr, ctx).truthy()
351}
352
353/// Executes an action statement against a mutable context.
354pub fn exec_stmt<C: ActContext>(ir: &SemIr, stmt: StmtId, ctx: &mut C) {
355    match ir.stmt_node(stmt) {
356        AStmt::SetMember(member, value) => {
357            let value = int_or_zero(eval_value(ir, *value, ctx));
358            ctx.set_member(*member, value);
359        }
360        AStmt::AddMember(member, delta) => {
361            let delta = int_or_zero(eval_value(ir, *delta, ctx));
362            let current = ctx.member(*member).unwrap_or_default();
363            ctx.set_member(*member, current + delta);
364        }
365        AStmt::SetReturn(name, value) => {
366            let value = int_or_zero(eval_value(ir, *value, ctx));
367            let name = ir.text(*name).to_owned();
368            ctx.set_return(&name, value);
369        }
370        AStmt::Seq(stmts) => {
371            for stmt in stmts {
372                exec_stmt(ir, *stmt, ctx);
373            }
374        }
375        AStmt::Hook(hook) => ctx.action_hook(*hook),
376    }
377}
378
379const fn int_or_zero(value: Value) -> i64 {
380    match value {
381        Value::Int(value) => value,
382        Value::Null | Value::Bool(_) => 0,
383    }
384}
385
386fn eval_value<C: PredContext>(ir: &SemIr, expr: ExprId, ctx: &mut C) -> Value {
387    match ir.node(expr) {
388        // Text-valued nodes are comparison operands; anywhere else they have
389        // no defined value.
390        PExpr::Str(_) | PExpr::TokenText(_) | PExpr::CtxRuleText(_) | PExpr::TokenTextSoFar => {
391            debug_assert!(false, "text-valued node evaluated outside a comparison");
392            Value::Null
393        }
394        PExpr::Bool(value) => Value::Bool(*value),
395        PExpr::Int(value) => Value::Int(*value),
396        PExpr::La(offset) => Value::Int(ctx.la(*offset)),
397        PExpr::TokenIndexAdjacent => Value::Bool(ctx.token_index_adjacent()),
398        PExpr::Member(member) => ctx.member(*member).map_or(Value::Null, Value::Int),
399        PExpr::LocalArg => ctx.local_arg().map_or(Value::Null, Value::Int),
400        PExpr::Column => ctx.column().map_or(Value::Null, Value::Int),
401        PExpr::TokenStartColumn => ctx.token_start_column().map_or(Value::Null, Value::Int),
402        PExpr::IsNull(inner) => Value::Bool(eval_is_null(ir, *inner, ctx)),
403        PExpr::Not(inner) => Value::Bool(!eval_value(ir, *inner, ctx).truthy()),
404        PExpr::And(children) => Value::Bool(
405            children
406                .iter()
407                .all(|child| eval_value(ir, *child, ctx).truthy()),
408        ),
409        PExpr::Or(children) => Value::Bool(
410            children
411                .iter()
412                .any(|child| eval_value(ir, *child, ctx).truthy()),
413        ),
414        PExpr::Cmp(op, lhs, rhs) => eval_cmp(ir, *op, *lhs, *rhs, ctx),
415        PExpr::Arith(op, lhs, rhs) => eval_arith(ir, *op, *lhs, *rhs, ctx),
416        PExpr::Hook(hook) => Value::Bool(ctx.hook(*hook)),
417        PExpr::EvalTrace(value) => Value::Bool(ctx.trace_bool(*value)),
418    }
419}
420
421fn eval_is_null<C: PredContext>(ir: &SemIr, inner: ExprId, ctx: &mut C) -> bool {
422    if let Some(source) = text_source(ir, inner) {
423        return resolve_owned_text(ir, source, ctx).is_none();
424    }
425    eval_value(ir, inner, ctx) == Value::Null
426}
427
428fn eval_cmp<C: PredContext>(ir: &SemIr, op: CmpOp, lhs: ExprId, rhs: ExprId, ctx: &mut C) -> Value {
429    let left_source = text_source(ir, lhs);
430    let right_source = text_source(ir, rhs);
431    if left_source.is_some() || right_source.is_some() {
432        return eval_text_cmp(ir, op, (lhs, left_source), (rhs, right_source), ctx);
433    }
434    let left = eval_value(ir, lhs, ctx);
435    let right = eval_value(ir, rhs, ctx);
436    Value::Bool(match (left, right) {
437        (Value::Null, Value::Null) => cmp_on_equality(op, true),
438        (Value::Null, _) | (_, Value::Null) => cmp_on_equality(op, false),
439        (Value::Bool(left), Value::Bool(right)) => cmp_on_equality(op, left == right),
440        (Value::Int(left), Value::Int(right)) => cmp_ints(op, left, right),
441        (Value::Bool(_), Value::Int(_)) | (Value::Int(_), Value::Bool(_)) => {
442            cmp_on_equality(op, false)
443        }
444    })
445}
446
447/// Comparison outcome for operands that only carry equality (Null, Bool,
448/// mismatched kinds): ordering operators are false.
449const fn cmp_on_equality(op: CmpOp, equal: bool) -> bool {
450    match op {
451        CmpOp::Eq => equal,
452        CmpOp::Ne => !equal,
453        CmpOp::Lt | CmpOp::Le | CmpOp::Gt | CmpOp::Ge => false,
454    }
455}
456
457const fn cmp_ints(op: CmpOp, left: i64, right: i64) -> bool {
458    match op {
459        CmpOp::Eq => left == right,
460        CmpOp::Ne => left != right,
461        CmpOp::Lt => left < right,
462        CmpOp::Le => left <= right,
463        CmpOp::Gt => left > right,
464        CmpOp::Ge => left >= right,
465    }
466}
467
468/// Where a text-valued operand's characters come from.
469///
470/// Only [`Self::Lookahead`] holds a borrow of the context while its `&str`
471/// is alive; the other sources either borrow the IR string pool or return an
472/// owned `String`. `eval_text_cmp` resolves the non-lookahead side first so
473/// the common `token-text == literal` comparison stays allocation-free.
474#[derive(Clone, Copy, Debug)]
475enum TextSource {
476    Literal(StrId),
477    Lookahead(isize),
478    CtxRule(usize),
479    SoFar,
480}
481
482fn text_source(ir: &SemIr, expr: ExprId) -> Option<TextSource> {
483    match ir.node(expr) {
484        PExpr::Str(id) => Some(TextSource::Literal(*id)),
485        PExpr::TokenText(offset) => Some(TextSource::Lookahead(*offset)),
486        PExpr::CtxRuleText(rule_index) => Some(TextSource::CtxRule(*rule_index)),
487        PExpr::TokenTextSoFar => Some(TextSource::SoFar),
488        _ => None,
489    }
490}
491
492/// Resolves a non-lookahead text operand without holding a context borrow.
493fn resolve_static_text<'ir, C: PredContext>(
494    ir: &'ir SemIr,
495    source: TextSource,
496    ctx: &C,
497) -> Option<Cow<'ir, str>> {
498    match source {
499        TextSource::Literal(id) => Some(Cow::Borrowed(ir.text(id))),
500        TextSource::Lookahead(_) => unreachable!("lookahead operands are resolved last"),
501        TextSource::CtxRule(rule_index) => ctx.ctx_rule_text(rule_index).map(Cow::Owned),
502        TextSource::SoFar => ctx.token_text_so_far().map(Cow::Owned),
503    }
504}
505
506/// Owned resolution used by [`PExpr::IsNull`] over text operands.
507fn resolve_owned_text<C: PredContext>(
508    ir: &SemIr,
509    source: TextSource,
510    ctx: &mut C,
511) -> Option<String> {
512    match source {
513        TextSource::Lookahead(offset) => {
514            ctx.token_text(offset).map(|text| text.as_ref().to_owned())
515        }
516        other => resolve_static_text(ir, other, ctx).map(Cow::into_owned),
517    }
518}
519
520fn eval_text_cmp<C: PredContext>(
521    ir: &SemIr,
522    op: CmpOp,
523    (lhs, left_source): (ExprId, Option<TextSource>),
524    (rhs, right_source): (ExprId, Option<TextSource>),
525    ctx: &mut C,
526) -> Value {
527    // A text operand compared against a non-text operand has no defined
528    // value relationship; only equality semantics apply (never equal).
529    let (Some(left_source), Some(right_source)) = (left_source, right_source) else {
530        debug_assert!(false, "text operand compared with non-text operand");
531        let _ = (lhs, rhs);
532        return Value::Bool(cmp_on_equality(op, false));
533    };
534    Value::Bool(match (left_source, right_source) {
535        (TextSource::Lookahead(left), TextSource::Lookahead(right)) => {
536            // Holding the first token-text borrow would keep `ctx` borrowed,
537            // so own this unsupported producer shape's first operand.
538            let left = ctx.token_text(left).map(|text| text.as_ref().to_owned());
539            let right = ctx.token_text(right);
540            cmp_texts(op, left.as_deref(), right.as_ref().map(AsRef::as_ref))
541        }
542        (TextSource::Lookahead(offset), other) => {
543            let right = resolve_static_text(ir, other, ctx);
544            let left = ctx.token_text(offset);
545            cmp_texts(op, left.as_ref().map(AsRef::as_ref), right.as_deref())
546        }
547        (other, TextSource::Lookahead(offset)) => {
548            let left = resolve_static_text(ir, other, ctx);
549            let right = ctx.token_text(offset);
550            cmp_texts(op, left.as_deref(), right.as_ref().map(AsRef::as_ref))
551        }
552        (left, right) => {
553            let left = resolve_static_text(ir, left, ctx);
554            let right = resolve_static_text(ir, right, ctx);
555            cmp_texts(op, left.as_deref(), right.as_deref())
556        }
557    })
558}
559
560fn cmp_texts(op: CmpOp, left: Option<&str>, right: Option<&str>) -> bool {
561    match (left, right) {
562        (None, None) => cmp_on_equality(op, true),
563        (None, Some(_)) | (Some(_), None) => cmp_on_equality(op, false),
564        (Some(left), Some(right)) => match op {
565            CmpOp::Eq => left == right,
566            CmpOp::Ne => left != right,
567            CmpOp::Lt => left < right,
568            CmpOp::Le => left <= right,
569            CmpOp::Gt => left > right,
570            CmpOp::Ge => left >= right,
571        },
572    }
573}
574
575fn eval_arith<C: PredContext>(
576    ir: &SemIr,
577    op: ArithOp,
578    lhs: ExprId,
579    rhs: ExprId,
580    ctx: &mut C,
581) -> Value {
582    let (Value::Int(left), Value::Int(right)) =
583        (eval_value(ir, lhs, ctx), eval_value(ir, rhs, ctx))
584    else {
585        return Value::Null;
586    };
587    let result = match op {
588        ArithOp::Add => left.checked_add(right),
589        ArithOp::Sub => left.checked_sub(right),
590        ArithOp::Mul => left.checked_mul(right),
591        ArithOp::Div => left.checked_div(right),
592        ArithOp::Mod => left.checked_rem(right),
593    };
594    result.map_or(Value::Null, Value::Int)
595}
596
597#[cfg(test)]
598mod tests {
599    use super::{
600        AStmt, ActContext, ArithOp, CmpOp, ExprId, HookId, PExpr, PredContext, SemIr, eval_pred,
601        exec_stmt,
602    };
603    use std::collections::BTreeMap;
604
605    /// Scriptable recognizer stand-in for evaluator tests.
606    #[derive(Debug, Default)]
607    struct MockCtx {
608        tokens: Vec<(i64, Option<&'static str>)>,
609        adjacent: bool,
610        ctx_rule_texts: BTreeMap<usize, String>,
611        members: BTreeMap<usize, i64>,
612        local_arg: Option<i64>,
613        column: Option<i64>,
614        token_start_column: Option<i64>,
615        text_so_far: Option<String>,
616        hook_results: Vec<bool>,
617        hook_calls: Vec<HookId>,
618        la_calls: usize,
619        returns: BTreeMap<String, i64>,
620    }
621
622    impl PredContext for MockCtx {
623        type TokenText<'a>
624            = &'a str
625        where
626            Self: 'a;
627
628        fn la(&mut self, offset: isize) -> i64 {
629            self.la_calls += 1;
630            self.lookup(offset).map_or(-1, |(token_type, _)| token_type)
631        }
632
633        fn token_text(&mut self, offset: isize) -> Option<Self::TokenText<'_>> {
634            self.lookup(offset).and_then(|(_, text)| text)
635        }
636
637        fn token_index_adjacent(&mut self) -> bool {
638            self.adjacent
639        }
640
641        fn ctx_rule_text(&self, rule_index: usize) -> Option<String> {
642            self.ctx_rule_texts.get(&rule_index).cloned()
643        }
644
645        fn member(&self, member: usize) -> Option<i64> {
646            self.members.get(&member).copied()
647        }
648
649        fn local_arg(&self) -> Option<i64> {
650            self.local_arg
651        }
652
653        fn column(&self) -> Option<i64> {
654            self.column
655        }
656
657        fn token_start_column(&self) -> Option<i64> {
658            self.token_start_column
659        }
660
661        fn token_text_so_far(&self) -> Option<String> {
662            self.text_so_far.clone()
663        }
664
665        fn hook(&mut self, hook: HookId) -> bool {
666            self.hook_calls.push(hook);
667            self.hook_results[hook.index()]
668        }
669    }
670
671    impl ActContext for MockCtx {
672        fn set_member(&mut self, member: usize, value: i64) {
673            self.members.insert(member, value);
674        }
675
676        fn set_return(&mut self, name: &str, value: i64) {
677            self.returns.insert(name.to_owned(), value);
678        }
679
680        fn action_hook(&mut self, hook: HookId) {
681            self.hook_calls.push(hook);
682        }
683    }
684
685    impl MockCtx {
686        fn lookup(&self, offset: isize) -> Option<(i64, Option<&'static str>)> {
687            // Offset 1 is the first entry, -1 the last, mirroring LT(k).
688            let index = if offset > 0 {
689                usize::try_from(offset - 1).ok()?
690            } else {
691                self.tokens.len().checked_sub(offset.unsigned_abs())?
692            };
693            self.tokens.get(index).copied()
694        }
695    }
696
697    fn build(build: impl FnOnce(&mut SemIr) -> ExprId) -> (SemIr, ExprId) {
698        let mut ir = SemIr::new();
699        let root = build(&mut ir);
700        (ir, root)
701    }
702
703    #[test]
704    fn literals_and_truthiness() {
705        for (value, expected) in [(true, true), (false, false)] {
706            let (ir, root) = build(|ir| ir.expr(PExpr::Bool(value)));
707            assert_eq!(eval_pred(&ir, root, &mut MockCtx::default()), expected);
708        }
709        let (ir, root) = build(|ir| ir.expr(PExpr::Int(2)));
710        assert!(eval_pred(&ir, root, &mut MockCtx::default()));
711        let (ir, root) = build(|ir| ir.expr(PExpr::Int(0)));
712        assert!(!eval_pred(&ir, root, &mut MockCtx::default()));
713    }
714
715    #[test]
716    fn lookahead_text_equals_literal_and_absent_token_fails() {
717        let (ir, root) = build(|ir| {
718            let text = ir.expr(PExpr::TokenText(1));
719            let literal = ir.intern("of");
720            let literal = ir.expr(PExpr::Str(literal));
721            ir.expr(PExpr::Cmp(CmpOp::Eq, text, literal))
722        });
723
724        let mut ctx = MockCtx {
725            tokens: vec![(7, Some("of"))],
726            ..MockCtx::default()
727        };
728        assert!(eval_pred(&ir, root, &mut ctx));
729
730        ctx.tokens = vec![(7, Some("in"))];
731        assert!(!eval_pred(&ir, root, &mut ctx));
732
733        // Absent token: Eq against a present literal is false.
734        ctx.tokens = Vec::new();
735        assert!(!eval_pred(&ir, root, &mut ctx));
736    }
737
738    #[test]
739    fn ctx_rule_text_not_equals_passes_when_child_absent() {
740        let (ir, root) = build(|ir| {
741            let child = ir.expr(PExpr::CtxRuleText(4));
742            let literal = ir.intern("static");
743            let literal = ir.expr(PExpr::Str(literal));
744            ir.expr(PExpr::Cmp(CmpOp::Ne, child, literal))
745        });
746
747        // Child absent: non-restrictive, passes.
748        assert!(eval_pred(&ir, root, &mut MockCtx::default()));
749
750        let mut ctx = MockCtx {
751            ctx_rule_texts: std::iter::once((4, "static".to_owned())).collect(),
752            ..MockCtx::default()
753        };
754        assert!(!eval_pred(&ir, root, &mut ctx));
755
756        ctx.ctx_rule_texts = std::iter::once((4, "dynamic".to_owned())).collect();
757        assert!(eval_pred(&ir, root, &mut ctx));
758    }
759
760    #[test]
761    fn absent_local_arg_composes_non_restrictive_guard() {
762        // Legacy `LocalIntEquals` semantics: pass when the rule has no
763        // argument, compare when it does.
764        let (ir, root) = build(|ir| {
765            let arg = ir.expr(PExpr::LocalArg);
766            let absent = ir.expr(PExpr::IsNull(arg));
767            let value = ir.expr(PExpr::Int(2));
768            let equals = ir.expr(PExpr::Cmp(CmpOp::Eq, arg, value));
769            ir.expr(PExpr::Or([absent, equals].into()))
770        });
771
772        assert!(eval_pred(&ir, root, &mut MockCtx::default()));
773        let mut ctx = MockCtx {
774            local_arg: Some(2),
775            ..MockCtx::default()
776        };
777        assert!(eval_pred(&ir, root, &mut ctx));
778        ctx.local_arg = Some(3);
779        assert!(!eval_pred(&ir, root, &mut ctx));
780    }
781
782    #[test]
783    fn member_modulo_comparison() {
784        let (ir, root) = build(|ir| {
785            let member = ir.expr(PExpr::Member(0));
786            let modulus = ir.expr(PExpr::Int(2));
787            let remainder = ir.expr(PExpr::Arith(ArithOp::Mod, member, modulus));
788            let expected = ir.expr(PExpr::Int(0));
789            ir.expr(PExpr::Cmp(CmpOp::Eq, remainder, expected))
790        });
791
792        let mut ctx = MockCtx {
793            members: std::iter::once((0, 4)).collect(),
794            ..MockCtx::default()
795        };
796        assert!(eval_pred(&ir, root, &mut ctx));
797        ctx.members.insert(0, 5);
798        assert!(!eval_pred(&ir, root, &mut ctx));
799        // Absent member is Null; Eq with a present value is false.
800        ctx.members.clear();
801        assert!(!eval_pred(&ir, root, &mut ctx));
802    }
803
804    #[test]
805    fn arithmetic_null_propagation_and_division_by_zero() {
806        let (ir, root) = build(|ir| {
807            let member = ir.expr(PExpr::Member(9));
808            let zero = ir.expr(PExpr::Int(0));
809            let modulo = ir.expr(PExpr::Arith(ArithOp::Mod, member, zero));
810            ir.expr(PExpr::IsNull(modulo))
811        });
812        // member(9) present, but % 0 is Null.
813        let mut ctx = MockCtx {
814            members: std::iter::once((9, 3)).collect(),
815            ..MockCtx::default()
816        };
817        assert!(eval_pred(&ir, root, &mut ctx));
818    }
819
820    #[test]
821    fn and_or_short_circuit_left_to_right() {
822        let (ir, root) = build(|ir| {
823            let gate = ir.expr(PExpr::Bool(false));
824            let la = ir.expr(PExpr::La(1));
825            let one = ir.expr(PExpr::Int(1));
826            let la_check = ir.expr(PExpr::Cmp(CmpOp::Eq, la, one));
827            ir.expr(PExpr::And([gate, la_check].into()))
828        });
829        let mut ctx = MockCtx::default();
830        assert!(!eval_pred(&ir, root, &mut ctx));
831        assert_eq!(ctx.la_calls, 0, "false gate must short-circuit la()");
832
833        let (ir, root) = build(|ir| {
834            let gate = ir.expr(PExpr::Bool(true));
835            let la = ir.expr(PExpr::La(1));
836            let one = ir.expr(PExpr::Int(1));
837            let la_check = ir.expr(PExpr::Cmp(CmpOp::Eq, la, one));
838            ir.expr(PExpr::Or([gate, la_check].into()))
839        });
840        let mut ctx = MockCtx::default();
841        assert!(eval_pred(&ir, root, &mut ctx));
842        assert_eq!(ctx.la_calls, 0, "true gate must short-circuit la()");
843    }
844
845    #[test]
846    fn token_index_adjacency_and_lookahead_type() {
847        let (ir, root) = build(|ir| ir.expr(PExpr::TokenIndexAdjacent));
848        let mut ctx = MockCtx {
849            adjacent: true,
850            ..MockCtx::default()
851        };
852        assert!(eval_pred(&ir, root, &mut ctx));
853        ctx.adjacent = false;
854        assert!(!eval_pred(&ir, root, &mut ctx));
855
856        let (ir, root) = build(|ir| {
857            let la = ir.expr(PExpr::La(-1));
858            let expected = ir.expr(PExpr::Int(12));
859            ir.expr(PExpr::Cmp(CmpOp::Ne, la, expected))
860        });
861        let mut ctx = MockCtx {
862            tokens: vec![(12, None)],
863            ..MockCtx::default()
864        };
865        assert!(!eval_pred(&ir, root, &mut ctx));
866        ctx.tokens = vec![(13, None)];
867        assert!(eval_pred(&ir, root, &mut ctx));
868    }
869
870    #[test]
871    fn lexer_column_predicates() {
872        let (ir, root) = build(|ir| {
873            let column = ir.expr(PExpr::Column);
874            let limit = ir.expr(PExpr::Int(4));
875            ir.expr(PExpr::Cmp(CmpOp::Ge, column, limit))
876        });
877        let mut ctx = MockCtx {
878            column: Some(5),
879            ..MockCtx::default()
880        };
881        assert!(eval_pred(&ir, root, &mut ctx));
882        ctx.column = Some(3);
883        assert!(!eval_pred(&ir, root, &mut ctx));
884        // Unknown column: ordering against Null is false.
885        ctx.column = None;
886        assert!(!eval_pred(&ir, root, &mut ctx));
887
888        let (ir, root) = build(|ir| {
889            let start = ir.expr(PExpr::TokenStartColumn);
890            let zero = ir.expr(PExpr::Int(0));
891            ir.expr(PExpr::Cmp(CmpOp::Eq, start, zero))
892        });
893        let mut ctx = MockCtx {
894            token_start_column: Some(0),
895            ..MockCtx::default()
896        };
897        assert!(eval_pred(&ir, root, &mut ctx));
898    }
899
900    #[test]
901    fn lexer_text_so_far_comparison() {
902        let (ir, root) = build(|ir| {
903            let text = ir.expr(PExpr::TokenTextSoFar);
904            let literal = ir.intern("aa");
905            let literal = ir.expr(PExpr::Str(literal));
906            ir.expr(PExpr::Cmp(CmpOp::Eq, text, literal))
907        });
908        let mut ctx = MockCtx {
909            text_so_far: Some("aa".to_owned()),
910            ..MockCtx::default()
911        };
912        assert!(eval_pred(&ir, root, &mut ctx));
913        ctx.text_so_far = Some("ab".to_owned());
914        assert!(!eval_pred(&ir, root, &mut ctx));
915    }
916
917    #[test]
918    fn hooks_defer_to_context() {
919        let (ir, root) = build(|ir| ir.expr(PExpr::Hook(HookId(0))));
920        let mut ctx = MockCtx {
921            hook_results: vec![true],
922            ..MockCtx::default()
923        };
924        assert!(eval_pred(&ir, root, &mut ctx));
925        assert_eq!(ctx.hook_calls, vec![HookId(0)]);
926    }
927
928    #[test]
929    fn statements_mutate_members_and_returns() {
930        let mut ir = SemIr::new();
931        let five = ir.expr(PExpr::Int(5));
932        let set = ir.stmt(AStmt::SetMember(1, five));
933        let two = ir.expr(PExpr::Int(2));
934        let add = ir.stmt(AStmt::AddMember(1, two));
935        let member = ir.expr(PExpr::Member(1));
936        let name = ir.intern("y");
937        let ret = ir.stmt(AStmt::SetReturn(name, member));
938        let seq = ir.stmt(AStmt::Seq([set, add, ret].into()));
939
940        let mut ctx = MockCtx::default();
941        exec_stmt(&ir, seq, &mut ctx);
942
943        assert_eq!(ctx.members.get(&1), Some(&7));
944        assert_eq!(ctx.returns.get("y"), Some(&7));
945    }
946
947    #[test]
948    fn string_interning_deduplicates() {
949        let mut ir = SemIr::new();
950        let first = ir.intern("of");
951        let second = ir.intern("of");
952        let third = ir.intern("in");
953        assert_eq!(first, second);
954        assert_ne!(first, third);
955        assert_eq!(ir.text(third), "in");
956    }
957}