Skip to main content

harn_parser/const_eval/
mod.rs

1//! Bounded, sandboxed compile-time evaluator for `const` initializers.
2//!
3//! This module is the entire surface added by issue
4//! [burin-labs/harn#1791](https://github.com/burin-labs/harn/issues/1791). It
5//! takes a Harn AST expression that appears on the right-hand side of a
6//! `const NAME = ...` binding and either returns a [`ConstValue`] or a
7//! [`ConstEvalError`]. The evaluator runs entirely inside the parser
8//! crate, has zero access to the host or the runtime VM, and enforces
9//! three hard caps on every call:
10//!
11//! 1. **Step budget** — every reduction increments a step counter. When
12//!    the counter exceeds [`MAX_STEPS`] (default `100_000`), evaluation
13//!    aborts with [`ConstEvalErrorKind::StepLimit`]. The check is
14//!    performed on every step, not amortized.
15//! 2. **Recursion depth** — every recursive call into the interpreter
16//!    increments a depth counter. Exceeding [`MAX_DEPTH`] (default
17//!    `256`) aborts with [`ConstEvalErrorKind::RecursionLimit`].
18//! 3. **Sandbox denylist** — any expression that reaches `harness`,
19//!    spawns concurrency, mutates state, performs I/O, calls into a
20//!    non-allowlisted builtin, references an unknown identifier, or
21//!    invokes a user-defined function is rejected with
22//!    [`ConstEvalErrorKind::SandboxViolation`] or
23//!    [`ConstEvalErrorKind::Disallowed`].
24//!
25//! The evaluator is **allowlist-based**: only explicitly permitted node
26//! shapes evaluate. Newly added stdlib surface is sandboxed by default.
27//!
28//! ## Cache key shape
29//!
30//! Each successful fold is keyed by:
31//!
32//! - the SHA-256 of the binding's source-text expression (mirrors what
33//!   downstream prompt-template specialization would consume), and
34//! - the tuple `(MAX_STEPS, MAX_DEPTH, evaluator_version)`.
35//!
36//! The cache itself is not implemented here — this module just exposes
37//! the inputs so a downstream consumer (e.g. compile-time prompt
38//! rendering) can wire it up without re-deriving the contract.
39
40use std::collections::HashMap;
41
42use harn_lexer::{Span, StringSegment};
43
44use crate::ast::{BinaryOperator, DictEntry, Node, SNode, UnaryOperator};
45
46/// Hard cap on the number of reduction steps performed by a single
47/// `const_eval` call. Checked on every step.
48pub const MAX_STEPS: u32 = 100_000;
49
50/// Hard cap on recursion depth into the interpreter. Each
51/// `eval_node` invocation increments the depth counter.
52pub const MAX_DEPTH: u32 = 256;
53
54/// Stable version tag participating in the cache key. Bump when any
55/// observable semantic of the const-evaluator changes.
56/// A fully folded compile-time value.
57///
58/// Mirrors the small subset of runtime `VmValue` shapes that pure
59/// expressions can produce. Equality is structural so the same expression
60/// always folds to the same value, which is what makes constant folding
61/// safe to embed into prompt templates and schema fingerprints.
62#[derive(Debug, Clone, PartialEq)]
63pub enum ConstValue {
64    Int(i64),
65    Float(f64),
66    Bool(bool),
67    String(String),
68    List(Vec<ConstValue>),
69    Dict(Vec<(String, ConstValue)>),
70    Nil,
71}
72
73impl ConstValue {
74    /// Render the value the way the runtime would for string
75    /// concatenation / interpolation, so const-time and runtime renders
76    /// stay byte-identical.
77    pub fn display(&self) -> String {
78        match self {
79            ConstValue::Int(n) => n.to_string(),
80            ConstValue::Float(f) => format_float(*f),
81            ConstValue::Bool(b) => b.to_string(),
82            ConstValue::String(s) => s.clone(),
83            ConstValue::Nil => "nil".to_string(),
84            ConstValue::List(items) => {
85                let parts: Vec<String> = items.iter().map(|v| v.display()).collect();
86                format!("[{}]", parts.join(", "))
87            }
88            ConstValue::Dict(entries) => {
89                let parts: Vec<String> = entries
90                    .iter()
91                    .map(|(k, v)| format!("{k}: {}", v.display()))
92                    .collect();
93                format!("{{{}}}", parts.join(", "))
94            }
95        }
96    }
97}
98
99fn format_float(f: f64) -> String {
100    if f.fract() == 0.0 && f.is_finite() {
101        format!("{f:.1}")
102    } else {
103        format!("{f}")
104    }
105}
106
107/// Reason a const-eval call failed. Mapped 1:1 to diagnostic codes:
108///
109/// - [`ConstEvalErrorKind::Disallowed`] → `HARN-MET-001`
110/// - [`ConstEvalErrorKind::StepLimit`] → `HARN-CST-001`
111/// - [`ConstEvalErrorKind::RecursionLimit`] → `HARN-CST-002`
112/// - [`ConstEvalErrorKind::SandboxViolation`] → `HARN-CST-003`
113/// - [`ConstEvalErrorKind::RuntimeError`] → `HARN-CST-004`
114#[derive(Debug, Clone, PartialEq, Eq)]
115pub enum ConstEvalErrorKind {
116    /// The expression shape is not in the const-friendly allowlist.
117    Disallowed,
118    /// Reduction count exceeded `MAX_STEPS`.
119    StepLimit,
120    /// Recursion depth exceeded `MAX_DEPTH`.
121    RecursionLimit,
122    /// The expression named a sandboxed capability (fs / net / env /
123    /// process / host) the evaluator refuses to mediate.
124    SandboxViolation,
125    /// A value-level error: division by zero, overflow on a literal,
126    /// out-of-bounds index, unknown identifier, type mismatch.
127    RuntimeError,
128}
129
130/// A const-eval failure carries a span (so the typechecker can attribute
131/// the diagnostic to the offending sub-expression) and a human-friendly
132/// detail.
133#[derive(Debug, Clone)]
134pub struct ConstEvalError {
135    pub kind: ConstEvalErrorKind,
136    pub span: Span,
137    pub detail: String,
138}
139
140impl ConstEvalError {
141    fn disallowed(span: Span, detail: impl Into<String>) -> Self {
142        Self {
143            kind: ConstEvalErrorKind::Disallowed,
144            span,
145            detail: detail.into(),
146        }
147    }
148
149    fn sandbox(span: Span, detail: impl Into<String>) -> Self {
150        Self {
151            kind: ConstEvalErrorKind::SandboxViolation,
152            span,
153            detail: detail.into(),
154        }
155    }
156
157    fn runtime(span: Span, detail: impl Into<String>) -> Self {
158        Self {
159            kind: ConstEvalErrorKind::RuntimeError,
160            span,
161            detail: detail.into(),
162        }
163    }
164
165    fn step_limit(span: Span) -> Self {
166        Self {
167            kind: ConstEvalErrorKind::StepLimit,
168            span,
169            detail: format!("const-eval exceeded the {MAX_STEPS}-step budget"),
170        }
171    }
172
173    fn recursion_limit(span: Span) -> Self {
174        Self {
175            kind: ConstEvalErrorKind::RecursionLimit,
176            span,
177            detail: format!("const-eval exceeded the {MAX_DEPTH}-deep recursion budget"),
178        }
179    }
180}
181
182/// Names of host objects, runtime keywords, and other surfaces the
183/// const-evaluator refuses to dereference. Used by the property-access
184/// path to give the most precise sandbox diagnostic possible — anything
185/// not on this list still falls back to a generic disallowed-expression
186/// rejection because the allowlist is the source of truth.
187const SANDBOXED_OBJECT_ROOTS: &[&str] = &[
188    "harness",
189    "host",
190    "transcript",
191    "registry",
192    "process",
193    "fs",
194    "net",
195    "env",
196    "stdio",
197    "log",
198    "agent",
199    "session",
200];
201
202/// Pure stdlib builtins whose result is deterministic and side-effect
203/// free. Allowlisted explicitly so newly added stdlib surface is
204/// sandboxed by default. Each entry is matched on the exact `FunctionCall`
205/// name produced by the parser.
206const PURE_BUILTINS: &[&str] = &[
207    "len",
208    "format",
209    "min",
210    "max",
211    "abs",
212    "floor",
213    "ceil",
214    "round",
215    "lowercase",
216    "uppercase",
217    "trim",
218    "concat",
219    "join",
220];
221
222/// Const-friendly binary operators. Mirror of the runtime set that has no
223/// side effects and well-defined value semantics on the
224/// [`ConstValue`] subset.
225const PURE_BINARY_OPS: &[BinaryOperator] = &[
226    BinaryOperator::Add,
227    BinaryOperator::Sub,
228    BinaryOperator::Mul,
229    BinaryOperator::Div,
230    BinaryOperator::Mod,
231    BinaryOperator::Pow,
232    BinaryOperator::Eq,
233    BinaryOperator::Neq,
234    BinaryOperator::Lt,
235    BinaryOperator::Gt,
236    BinaryOperator::Lte,
237    BinaryOperator::Gte,
238    BinaryOperator::And,
239    BinaryOperator::Or,
240    BinaryOperator::NilCoalesce,
241];
242
243/// Environment mapping a `const` name to its already-folded value. The
244/// typechecker primes this with bindings encountered earlier in the same
245/// file (i.e. `const X: int = 1` lets `const Y: int = X + 2` resolve).
246pub type ConstEnv = HashMap<String, ConstValue>;
247
248/// Public entry point: fold a single AST node into a [`ConstValue`] or
249/// return a [`ConstEvalError`]. The `env` argument supplies earlier
250/// `const` bindings visible to this expression.
251pub fn const_eval(node: &SNode, env: &ConstEnv) -> Result<ConstValue, ConstEvalError> {
252    const_eval_with_resolver(node, &|name| env.get(name).cloned())
253}
254
255/// Fold against lexical scope without materializing all visible bindings.
256pub(crate) fn const_eval_with_resolver(
257    node: &SNode,
258    resolve: &dyn Fn(&str) -> Option<ConstValue>,
259) -> Result<ConstValue, ConstEvalError> {
260    let mut ctx = EvalCtx {
261        resolve,
262        steps: 0,
263        depth: 0,
264    };
265    ctx.eval_node(node)
266}
267
268struct EvalCtx<'a> {
269    resolve: &'a dyn Fn(&str) -> Option<ConstValue>,
270    steps: u32,
271    depth: u32,
272}
273
274impl<'a> EvalCtx<'a> {
275    fn step(&mut self, span: Span) -> Result<(), ConstEvalError> {
276        self.steps = self.steps.saturating_add(1);
277        if self.steps > MAX_STEPS {
278            return Err(ConstEvalError::step_limit(span));
279        }
280        Ok(())
281    }
282
283    fn enter(&mut self, span: Span) -> Result<(), ConstEvalError> {
284        self.depth = self.depth.saturating_add(1);
285        if self.depth > MAX_DEPTH {
286            self.depth -= 1;
287            return Err(ConstEvalError::recursion_limit(span));
288        }
289        Ok(())
290    }
291
292    fn leave(&mut self) {
293        self.depth = self.depth.saturating_sub(1);
294    }
295
296    fn eval_node(&mut self, node: &SNode) -> Result<ConstValue, ConstEvalError> {
297        self.step(node.span)?;
298        self.enter(node.span)?;
299        let result = self.eval_node_inner(node);
300        self.leave();
301        result
302    }
303
304    fn eval_node_inner(&mut self, node: &SNode) -> Result<ConstValue, ConstEvalError> {
305        let ctx = self;
306        match &node.node {
307            Node::IntLiteral(n) => Ok(ConstValue::Int(*n)),
308            Node::FloatLiteral(f) => Ok(ConstValue::Float(*f)),
309            Node::BoolLiteral(b) => Ok(ConstValue::Bool(*b)),
310            Node::StringLiteral(s) | Node::RawStringLiteral(s) => Ok(ConstValue::String(s.clone())),
311            Node::NilLiteral => Ok(ConstValue::Nil),
312
313            Node::Identifier(name) => (ctx.resolve)(name).ok_or_else(|| {
314                ConstEvalError::runtime(
315                    node.span,
316                    format!("`{name}` is not a const-known identifier"),
317                )
318            }),
319
320            Node::ListLiteral(items) => {
321                let mut out = Vec::with_capacity(items.len());
322                for item in items {
323                    if matches!(&item.node, Node::Spread(_)) {
324                        return Err(ConstEvalError::disallowed(
325                            item.span,
326                            "spread in a const list literal is not supported",
327                        ));
328                    }
329                    out.push(ctx.eval_node(item)?);
330                }
331                Ok(ConstValue::List(out))
332            }
333
334            Node::DictLiteral(entries) => {
335                let mut out: Vec<(String, ConstValue)> = Vec::with_capacity(entries.len());
336                for entry in entries {
337                    let key = ctx.dict_key_name(entry)?;
338                    let value = ctx.eval_node(&entry.value)?;
339                    out.push((key, value));
340                }
341                Ok(ConstValue::Dict(out))
342            }
343
344            Node::InterpolatedString(segments) => {
345                let mut buf = String::new();
346                for seg in segments {
347                    match seg {
348                        StringSegment::Literal(lit) => buf.push_str(lit),
349                        StringSegment::Expression(src, _, _) => {
350                            // The interpolated expression is stored as
351                            // raw source text. The const-evaluator never
352                            // recursively re-parses host source, so we
353                            // refuse to fold dynamic interpolation. The
354                            // typechecker can still surface this as a
355                            // disallowed expression at the binding
356                            // span — interpolation is the one case where
357                            // const-eval treats the inner content as
358                            // opaque.
359                            return Err(ConstEvalError::disallowed(
360                                node.span,
361                                format!("interpolated expression `${{{src}}}` is not supported in a const initializer; use `format(...)` or string concatenation"),
362                            ));
363                        }
364                    }
365                }
366                Ok(ConstValue::String(buf))
367            }
368
369            Node::UnaryOp { op, operand } => {
370                let value = ctx.eval_node(operand)?;
371                match (op, &value) {
372                    (UnaryOperator::Neg, ConstValue::Int(n)) => {
373                        Ok(ConstValue::Int(n.checked_neg().ok_or_else(|| {
374                            ConstEvalError::runtime(node.span, "integer overflow in unary minus")
375                        })?))
376                    }
377                    (UnaryOperator::Neg, ConstValue::Float(f)) => Ok(ConstValue::Float(-f)),
378                    (UnaryOperator::Not, ConstValue::Bool(b)) => Ok(ConstValue::Bool(!b)),
379                    _ => Err(ConstEvalError::runtime(
380                        node.span,
381                        format!("unary `{op}` is not defined for the operand"),
382                    )),
383                }
384            }
385
386            Node::BinaryOp { op, left, right } => {
387                if !PURE_BINARY_OPS.contains(op) {
388                    return Err(ConstEvalError::disallowed(
389                        node.span,
390                        format!("binary operator `{op}` is not const-evaluable"),
391                    ));
392                }
393                let lhs = ctx.eval_node(left)?;
394                let rhs = ctx.eval_node(right)?;
395                ctx.apply_binary(*op, lhs, rhs, node.span)
396            }
397
398            Node::Ternary {
399                condition,
400                true_expr,
401                false_expr,
402            } => {
403                let cond = ctx.eval_node(condition)?;
404                let pick = match cond {
405                    ConstValue::Bool(b) => b,
406                    _ => {
407                        return Err(ConstEvalError::runtime(
408                            condition.span,
409                            "ternary condition must fold to a bool",
410                        ))
411                    }
412                };
413                if pick {
414                    ctx.eval_node(true_expr)
415                } else {
416                    ctx.eval_node(false_expr)
417                }
418            }
419
420            Node::IfElse {
421                condition,
422                then_body,
423                else_body,
424                ..
425            } => {
426                let cond = ctx.eval_node(condition)?;
427                let pick = match cond {
428                    ConstValue::Bool(b) => b,
429                    _ => {
430                        return Err(ConstEvalError::runtime(
431                            condition.span,
432                            "if-expression condition must fold to a bool",
433                        ))
434                    }
435                };
436                let branch =
437                    if pick {
438                        then_body.as_slice()
439                    } else {
440                        match else_body {
441                            Some(body) => body.as_slice(),
442                            None => return Err(ConstEvalError::disallowed(
443                                node.span,
444                                "if-expression without an else branch cannot be const-evaluated",
445                            )),
446                        }
447                    };
448                let Some(last) = branch.last() else {
449                    return Err(ConstEvalError::disallowed(
450                        node.span,
451                        "if-expression branch must produce a value",
452                    ));
453                };
454                if let Some(first_pre) = branch[..branch.len().saturating_sub(1)].first() {
455                    // The only branch shape that folds is a single
456                    // value expression. Anything before the final
457                    // expression would require statement-level side
458                    // effects the sandbox cannot model.
459                    return Err(ConstEvalError::disallowed(
460                        first_pre.span,
461                        "multi-statement if-branch is not const-evaluable",
462                    ));
463                }
464                ctx.eval_node(last)
465            }
466
467            Node::FunctionCall { name, args, .. } => {
468                if !PURE_BUILTINS.contains(&name.as_str()) {
469                    return Err(ConstEvalError::sandbox(
470                        node.span,
471                        format!(
472                            "`{name}(...)` is not on the const-eval allowlist (only pure stdlib builtins may be called from a const initializer)"
473                        ),
474                    ));
475                }
476                let mut folded = Vec::with_capacity(args.len());
477                for arg in args {
478                    folded.push(ctx.eval_node(arg)?);
479                }
480                ctx.apply_builtin(name, folded, node.span)
481            }
482
483            // ----- Explicit sandbox-violating shapes -----
484            //
485            // Each of these has a more useful diagnostic than the
486            // catch-all "expression not allowed" because the user almost
487            // certainly tried something with side effects.
488            Node::PropertyAccess { object, .. } | Node::OptionalPropertyAccess { object, .. } => {
489                if let Node::Identifier(root) = &object.node {
490                    if SANDBOXED_OBJECT_ROOTS.contains(&root.as_str()) {
491                        return Err(ConstEvalError::sandbox(
492                            node.span,
493                            format!(
494                                "`{root}.*` is a sandboxed capability surface; const-eval refuses fs/net/env/process/host access"
495                            ),
496                        ));
497                    }
498                }
499                Err(ConstEvalError::disallowed(
500                    node.span,
501                    "property access is not const-evaluable",
502                ))
503            }
504            Node::MethodCall { object, .. } | Node::OptionalMethodCall { object, .. } => {
505                // Probe the receiver chain for a sandboxed host root so
506                // `harness.clock.now()` reports the dedicated sandbox
507                // diagnostic instead of the generic disallowed-method
508                // catch-all. The receiver of `harness.clock.now()` is
509                // parsed as `PropertyAccess { object: harness, "clock" }`,
510                // and a deeper chain would wrap it in more
511                // `PropertyAccess` nodes. Walk down through any chain
512                // and pick out the leftmost identifier.
513                if let Some(root) = leftmost_receiver_identifier(object) {
514                    if SANDBOXED_OBJECT_ROOTS.contains(&root) {
515                        return Err(ConstEvalError::sandbox(
516                            node.span,
517                            format!(
518                                "`{root}.*(...)` is a sandboxed capability surface; const-eval refuses fs/net/env/process/host access"
519                            ),
520                        ));
521                    }
522                }
523                Err(ConstEvalError::disallowed(
524                    node.span,
525                    "method call is not const-evaluable",
526                ))
527            }
528            Node::SubscriptAccess { object, index } => {
529                let recv = ctx.eval_node(object)?;
530                let idx = ctx.eval_node(index)?;
531                match (recv, idx) {
532                    (ConstValue::List(items), ConstValue::Int(i)) => {
533                        let pos = if i < 0 { i + items.len() as i64 } else { i };
534                        usize::try_from(pos)
535                            .ok()
536                            .and_then(|p| items.get(p).cloned())
537                            .ok_or_else(|| {
538                                ConstEvalError::runtime(
539                                    node.span,
540                                    format!("index {i} out of bounds"),
541                                )
542                            })
543                    }
544                    (ConstValue::Dict(entries), ConstValue::String(k)) => entries
545                        .into_iter()
546                        .find(|(name, _)| *name == k)
547                        .map(|(_, v)| v)
548                        .ok_or_else(|| {
549                            ConstEvalError::runtime(node.span, format!("unknown key `{k}`"))
550                        }),
551                    _ => Err(ConstEvalError::runtime(
552                        node.span,
553                        "subscript receiver and index types are incompatible",
554                    )),
555                }
556            }
557            Node::Block(_) => Err(ConstEvalError::disallowed(
558                node.span,
559                "block expression is not const-evaluable",
560            )),
561            Node::Closure { .. } => Err(ConstEvalError::disallowed(
562                node.span,
563                "closure is not const-evaluable",
564            )),
565
566            // ----- Loud sandbox violators: runtime/concurrency surface -----
567            Node::SpawnExpr { .. }
568            | Node::SelectExpr { .. }
569            | Node::Parallel { .. }
570            | Node::MutexBlock { .. }
571            | Node::DeferStmt { .. }
572            | Node::YieldExpr { .. }
573            | Node::EmitExpr { .. }
574            | Node::TryCatch { .. }
575            | Node::TryExpr { .. }
576            | Node::TryOperator { .. }
577            | Node::TryStar { .. }
578            | Node::DeadlineBlock { .. }
579            | Node::CostRoute { .. }
580            | Node::WhileLoop { .. }
581            | Node::ForIn { .. }
582            | Node::Retry { .. }
583            | Node::GuardStmt { .. }
584            | Node::RequireStmt { .. }
585            | Node::Assignment { .. }
586            | Node::ThrowStmt { .. }
587            | Node::ReturnStmt { .. }
588            | Node::BreakStmt
589            | Node::ContinueStmt => Err(ConstEvalError::sandbox(
590                node.span,
591                "runtime construct is not permitted in a const initializer",
592            )),
593
594            // Anything else: be conservative and disallow.
595            _ => Err(ConstEvalError::disallowed(
596                node.span,
597                "expression shape is not on the const-eval allowlist",
598            )),
599        }
600    }
601
602    fn dict_key_name(&self, entry: &DictEntry) -> Result<String, ConstEvalError> {
603        match &entry.key.node {
604            Node::Identifier(name) => Ok(name.clone()),
605            Node::StringLiteral(s) | Node::RawStringLiteral(s) => Ok(s.clone()),
606            _ => Err(ConstEvalError::disallowed(
607                entry.key.span,
608                "dict keys in a const dict literal must be identifiers or string literals",
609            )),
610        }
611    }
612
613    fn apply_binary(
614        &self,
615        op: BinaryOperator,
616        lhs: ConstValue,
617        rhs: ConstValue,
618        span: Span,
619    ) -> Result<ConstValue, ConstEvalError> {
620        use ConstValue::*;
621
622        // Special cases first.
623        if op.is_logical() {
624            let (Bool(l), Bool(r)) = (&lhs, &rhs) else {
625                return Err(ConstEvalError::runtime(
626                    span,
627                    format!("`{op}` requires bool operands"),
628                ));
629            };
630            return Ok(Bool(if op == BinaryOperator::And {
631                *l && *r
632            } else {
633                *l || *r
634            }));
635        }
636        if op == BinaryOperator::NilCoalesce {
637            return Ok(match lhs {
638                Nil => rhs,
639                other => other,
640            });
641        }
642        if op == BinaryOperator::Eq {
643            return Ok(Bool(lhs == rhs));
644        }
645        if op == BinaryOperator::Neq {
646            return Ok(Bool(lhs != rhs));
647        }
648
649        // String concat via `+`.
650        if op == BinaryOperator::Add {
651            if let (String(a), String(b)) = (&lhs, &rhs) {
652                return Ok(String(format!("{a}{b}")));
653            }
654        }
655
656        // Numeric arithmetic. Promote to float when either side is float.
657        let (lhs_num, rhs_num) = match (&lhs, &rhs) {
658            (Int(_) | Float(_), Int(_) | Float(_)) => (lhs.clone(), rhs.clone()),
659            _ => {
660                return Err(ConstEvalError::runtime(
661                    span,
662                    format!(
663                        "`{op}` requires numeric operands, got {} and {}",
664                        value_kind(&lhs),
665                        value_kind(&rhs)
666                    ),
667                ))
668            }
669        };
670
671        // Comparisons.
672        if op.precedence() == crate::operators::Precedence::Comparison {
673            let (l, r) = (as_float(&lhs_num), as_float(&rhs_num));
674            let out = match op {
675                BinaryOperator::Lt => l < r,
676                BinaryOperator::Gt => l > r,
677                BinaryOperator::Lte => l <= r,
678                BinaryOperator::Gte => l >= r,
679                _ => unreachable!(),
680            };
681            return Ok(Bool(out));
682        }
683
684        // Arithmetic.
685        if let (Int(a), Int(b)) = (&lhs_num, &rhs_num) {
686            let result = match op {
687                BinaryOperator::Add => a.checked_add(*b),
688                BinaryOperator::Sub => a.checked_sub(*b),
689                BinaryOperator::Mul => a.checked_mul(*b),
690                BinaryOperator::Div => {
691                    if *b == 0 {
692                        return Err(ConstEvalError::runtime(span, "division by zero"));
693                    }
694                    a.checked_div(*b)
695                }
696                BinaryOperator::Mod => {
697                    if *b == 0 {
698                        return Err(ConstEvalError::runtime(span, "modulo by zero"));
699                    }
700                    a.checked_rem(*b)
701                }
702                BinaryOperator::Pow => {
703                    if *b < 0 || *b > u32::MAX as i64 {
704                        return Err(ConstEvalError::runtime(
705                            span,
706                            "exponent must be a non-negative i64 within u32 range",
707                        ));
708                    }
709                    a.checked_pow(*b as u32)
710                }
711                _ => unreachable!(),
712            };
713            return result
714                .map(Int)
715                .ok_or_else(|| ConstEvalError::runtime(span, "integer overflow"));
716        }
717
718        let (l, r) = (as_float(&lhs_num), as_float(&rhs_num));
719        let value = match op {
720            BinaryOperator::Add => l + r,
721            BinaryOperator::Sub => l - r,
722            BinaryOperator::Mul => l * r,
723            BinaryOperator::Div => {
724                if r == 0.0 {
725                    return Err(ConstEvalError::runtime(span, "division by zero"));
726                }
727                l / r
728            }
729            BinaryOperator::Mod => {
730                if r == 0.0 {
731                    return Err(ConstEvalError::runtime(span, "modulo by zero"));
732                }
733                l % r
734            }
735            BinaryOperator::Pow => l.powf(r),
736            _ => unreachable!(),
737        };
738        Ok(Float(value))
739    }
740
741    fn apply_builtin(
742        &self,
743        name: &str,
744        args: Vec<ConstValue>,
745        span: Span,
746    ) -> Result<ConstValue, ConstEvalError> {
747        match name {
748            "len" => match args.as_slice() {
749                [ConstValue::String(s)] => Ok(ConstValue::Int(s.chars().count() as i64)),
750                [ConstValue::List(items)] => Ok(ConstValue::Int(items.len() as i64)),
751                [ConstValue::Dict(entries)] => Ok(ConstValue::Int(entries.len() as i64)),
752                _ => Err(ConstEvalError::runtime(
753                    span,
754                    "len() expects a single string / list / dict argument",
755                )),
756            },
757            "format" => format_call(span, args),
758            "concat" => {
759                let mut out = String::new();
760                for arg in &args {
761                    match arg {
762                        ConstValue::String(s) => out.push_str(s),
763                        _ => {
764                            return Err(ConstEvalError::runtime(
765                                span,
766                                "concat() expects string arguments",
767                            ))
768                        }
769                    }
770                }
771                Ok(ConstValue::String(out))
772            }
773            "join" => match args.as_slice() {
774                [ConstValue::List(items), ConstValue::String(sep)] => {
775                    let mut parts = Vec::with_capacity(items.len());
776                    for item in items {
777                        match item {
778                            ConstValue::String(s) => parts.push(s.clone()),
779                            other => parts.push(other.display()),
780                        }
781                    }
782                    Ok(ConstValue::String(parts.join(sep)))
783                }
784                _ => Err(ConstEvalError::runtime(
785                    span,
786                    "join() expects (list, string)",
787                )),
788            },
789            "min" | "max" => apply_min_max(name, &args, span),
790            "abs" => match args.as_slice() {
791                [ConstValue::Int(n)] => {
792                    Ok(ConstValue::Int(n.checked_abs().ok_or_else(|| {
793                        ConstEvalError::runtime(span, "integer overflow in abs()")
794                    })?))
795                }
796                [ConstValue::Float(f)] => Ok(ConstValue::Float(f.abs())),
797                _ => Err(ConstEvalError::runtime(
798                    span,
799                    "abs() expects a single numeric argument",
800                )),
801            },
802            "floor" => unary_float(span, &args, |f| f.floor()),
803            "ceil" => unary_float(span, &args, |f| f.ceil()),
804            "round" => match args.as_slice() {
805                // 2-arg form mirrors the runtime builtin: round to `digits`
806                // decimal places (half away from zero); negative digits round
807                // to power-of-ten buckets; ints stay ints when they fit.
808                [ConstValue::Float(f), ConstValue::Int(digits)] => {
809                    Ok(ConstValue::Float(round_float_to_digits(*f, *digits)))
810                }
811                [ConstValue::Int(n), ConstValue::Int(digits)] => {
812                    Ok(round_int_to_digits(*n, *digits)
813                        .map_or_else(ConstValue::Float, ConstValue::Int))
814                }
815                _ => unary_float(span, &args, |f| f.round()),
816            },
817            "lowercase" => match args.as_slice() {
818                [ConstValue::String(s)] => Ok(ConstValue::String(s.to_lowercase())),
819                _ => Err(ConstEvalError::runtime(
820                    span,
821                    "lowercase() expects a string",
822                )),
823            },
824            "uppercase" => match args.as_slice() {
825                [ConstValue::String(s)] => Ok(ConstValue::String(s.to_uppercase())),
826                _ => Err(ConstEvalError::runtime(
827                    span,
828                    "uppercase() expects a string",
829                )),
830            },
831            "trim" => match args.as_slice() {
832                [ConstValue::String(s)] => Ok(ConstValue::String(s.trim().to_string())),
833                _ => Err(ConstEvalError::runtime(span, "trim() expects a string")),
834            },
835            // PURE_BUILTINS is the source of truth; if you reach this
836            // arm a name was added to the allowlist without an
837            // implementation here. Treat as a sandbox violation so the
838            // caller still gets an actionable diagnostic.
839            _ => Err(ConstEvalError::sandbox(
840                span,
841                format!("`{name}(...)` lacks a const-eval implementation"),
842            )),
843        }
844    }
845}
846
847/// Walk down a receiver chain (`Identifier` → `PropertyAccess` → … ) and
848/// return the leftmost identifier name. Used by the method-call sandbox
849/// probe so `harness.clock.now()` reports a precise sandbox diagnostic.
850fn leftmost_receiver_identifier(node: &SNode) -> Option<&str> {
851    let mut current = node;
852    loop {
853        match &current.node {
854            Node::Identifier(name) => return Some(name.as_str()),
855            Node::PropertyAccess { object, .. }
856            | Node::OptionalPropertyAccess { object, .. }
857            | Node::SubscriptAccess { object, .. }
858            | Node::OptionalSubscriptAccess { object, .. } => {
859                current = object;
860            }
861            _ => return None,
862        }
863    }
864}
865
866fn value_kind(v: &ConstValue) -> &'static str {
867    match v {
868        ConstValue::Int(_) => "int",
869        ConstValue::Float(_) => "float",
870        ConstValue::Bool(_) => "bool",
871        ConstValue::String(_) => "string",
872        ConstValue::List(_) => "list",
873        ConstValue::Dict(_) => "dict",
874        ConstValue::Nil => "nil",
875    }
876}
877
878fn as_float(v: &ConstValue) -> f64 {
879    match v {
880        ConstValue::Int(n) => *n as f64,
881        ConstValue::Float(f) => *f,
882        _ => 0.0,
883    }
884}
885
886fn format_call(span: Span, args: Vec<ConstValue>) -> Result<ConstValue, ConstEvalError> {
887    let mut iter = args.into_iter();
888    let template = match iter.next() {
889        Some(ConstValue::String(s)) => s,
890        Some(_) => {
891            return Err(ConstEvalError::runtime(
892                span,
893                "format() template must be a string literal",
894            ))
895        }
896        None => {
897            return Err(ConstEvalError::runtime(
898                span,
899                "format() requires at least a template argument",
900            ))
901        }
902    };
903    let rest: Vec<ConstValue> = iter.collect();
904
905    // Mirror runtime semantics: a single dict arg substitutes named
906    // `{key}` placeholders; otherwise positional `{}`.
907    if let [ConstValue::Dict(entries)] = rest.as_slice() {
908        let mut result = String::with_capacity(template.len());
909        let mut rest_str = template.as_str();
910        while let Some((head, after_open)) = rest_str.split_once('{') {
911            result.push_str(head);
912            let hit = after_open.split_once('}').and_then(|(key, after_close)| {
913                let (_, val) = entries.iter().find(|(k, _)| k == key)?;
914                Some((val, after_close))
915            });
916            if let Some((val, after_close)) = hit {
917                result.push_str(&val.display());
918                rest_str = after_close;
919            } else {
920                result.push('{');
921                rest_str = after_open;
922            }
923        }
924        result.push_str(rest_str);
925        return Ok(ConstValue::String(result));
926    }
927
928    let mut result = String::with_capacity(template.len());
929    let mut rest_iter = rest.iter();
930    let mut tail = template.as_str();
931    while let Some((head, rest_of_template)) = tail.split_once("{}") {
932        result.push_str(head);
933        if let Some(arg) = rest_iter.next() {
934            result.push_str(&arg.display());
935        } else {
936            result.push_str("{}");
937        }
938        tail = rest_of_template;
939    }
940    result.push_str(tail);
941    Ok(ConstValue::String(result))
942}
943
944fn apply_min_max(
945    name: &str,
946    args: &[ConstValue],
947    span: Span,
948) -> Result<ConstValue, ConstEvalError> {
949    if args.is_empty() {
950        return Err(ConstEvalError::runtime(
951            span,
952            format!("{name}() requires at least one argument"),
953        ));
954    }
955    let mut all_int = true;
956    for arg in args {
957        match arg {
958            ConstValue::Int(_) => {}
959            ConstValue::Float(_) => all_int = false,
960            _ => {
961                return Err(ConstEvalError::runtime(
962                    span,
963                    format!("{name}() expects numeric arguments"),
964                ))
965            }
966        }
967    }
968    if all_int {
969        let nums: Vec<i64> = args
970            .iter()
971            .map(|v| match v {
972                ConstValue::Int(n) => *n,
973                _ => unreachable!(),
974            })
975            .collect();
976        let pick = if name == "min" {
977            nums.iter().copied().min().unwrap()
978        } else {
979            nums.iter().copied().max().unwrap()
980        };
981        Ok(ConstValue::Int(pick))
982    } else {
983        let nums: Vec<f64> = args.iter().map(as_float).collect();
984        let pick = if name == "min" {
985            nums.iter().copied().fold(f64::INFINITY, f64::min)
986        } else {
987            nums.iter().copied().fold(f64::NEG_INFINITY, f64::max)
988        };
989        Ok(ConstValue::Float(pick))
990    }
991}
992
993/// `round(x, digits)` for floats: half-away-from-zero at the requested
994/// decimal place, implemented by scale → `f64::round` → unscale. Non-finite
995/// inputs pass through unchanged. Shared by const folding and the runtime.
996pub fn round_float_to_digits(x: f64, digits: i64) -> f64 {
997    if !x.is_finite() {
998        return x;
999    }
1000    if digits == 0 {
1001        return x.round();
1002    }
1003    // f64 decimal exponents span roughly ±308; beyond that the scale factor
1004    // is not representable, and the answer is degenerate anyway: rounding to
1005    // more fractional digits than the value resolves is the identity, and
1006    // rounding to a coarser bucket than the largest float magnitude is zero.
1007    if digits > 308 {
1008        return x;
1009    }
1010    if digits < -308 {
1011        return 0.0 * x.signum();
1012    }
1013    if digits < 0 {
1014        // Divide by the exact power of ten rather than multiplying by its
1015        // inexact reciprocal: `1e-5` unscales `1.0` to `99999.99999999999`.
1016        let bucket = 10f64.powi((-digits) as i32);
1017        return (x / bucket).round() * bucket;
1018    }
1019    let factor = 10f64.powi(digits as i32);
1020    let scaled = x * factor;
1021    if !scaled.is_finite() {
1022        // Scaling overflowed (huge value, large positive `digits`): every
1023        // such value is already integral well past `digits` places.
1024        return x;
1025    }
1026    scaled.round() / factor
1027}
1028
1029/// `round(n, digits)` for ints: identity for `digits >= 0`; negative digits
1030/// round to the nearest power-of-ten bucket, halves away from zero. Uses
1031/// exact i128 arithmetic; a result outside the i64 range promotes to float,
1032/// matching integer arithmetic overflow behavior elsewhere. Shared by const
1033/// folding and the runtime `round` builtin so the two cannot drift.
1034pub fn round_int_to_digits(n: i64, digits: i64) -> Result<i64, f64> {
1035    if digits >= 0 || n == 0 {
1036        return Ok(n);
1037    }
1038    if digits <= -20 {
1039        // 2 * |i64::MIN| < 10^20, so every i64 rounds to the zero bucket;
1040        // at -19 values of 5e18 and beyond still round out to ±1e19.
1041        return Ok(0);
1042    }
1043    let factor = 10i128.pow((-digits) as u32);
1044    let n128 = n as i128;
1045    let rem = n128 % factor;
1046    let base = n128 - rem;
1047    let rounded = if rem.abs() * 2 >= factor {
1048        base + factor * n128.signum()
1049    } else {
1050        base
1051    };
1052    i64::try_from(rounded).map_err(|_| rounded as f64)
1053}
1054
1055fn unary_float(
1056    span: Span,
1057    args: &[ConstValue],
1058    op: impl Fn(f64) -> f64,
1059) -> Result<ConstValue, ConstEvalError> {
1060    match args {
1061        [ConstValue::Int(n)] => Ok(ConstValue::Float(op(*n as f64))),
1062        [ConstValue::Float(f)] => Ok(ConstValue::Float(op(*f))),
1063        _ => Err(ConstEvalError::runtime(
1064            span,
1065            "expected a single numeric argument",
1066        )),
1067    }
1068}
1069
1070#[cfg(test)]
1071mod tests {
1072    use super::*;
1073    use crate::parse_source;
1074
1075    fn fold(source: &str) -> Result<ConstValue, ConstEvalError> {
1076        // Wrap as a const binding so the parser produces our node, then
1077        // extract the right-hand side and run const_eval on it with a
1078        // fresh environment seeded from any earlier const decls.
1079        let program = parse_source(source).expect("parse");
1080        let mut env = ConstEnv::new();
1081        let mut last = None;
1082        for snode in &program {
1083            if let Node::ConstBinding {
1084                pattern: crate::ast::BindingPattern::Identifier(name),
1085                value,
1086                ..
1087            } = &snode.node
1088            {
1089                let folded = const_eval(value, &env)?;
1090                env.insert(name.clone(), folded.clone());
1091                last = Some(folded);
1092            }
1093        }
1094        Ok(last.expect("no const binding in source"))
1095    }
1096
1097    #[test]
1098    fn arithmetic_literals_fold() {
1099        assert_eq!(fold("const X = 1 + 2").unwrap(), ConstValue::Int(3));
1100        assert_eq!(fold("const Y = 5 * (3 + 2)").unwrap(), ConstValue::Int(25));
1101        assert_eq!(fold("const Z = 2 ** 10").unwrap(), ConstValue::Int(1024));
1102    }
1103
1104    #[test]
1105    fn string_concat_folds() {
1106        assert_eq!(
1107            fold(r#"const S = "foo" + "-" + "bar""#).unwrap(),
1108            ConstValue::String("foo-bar".to_string())
1109        );
1110    }
1111
1112    #[test]
1113    fn earlier_const_visible_to_later() {
1114        let src = "const A = 10\nconst B = A * 2";
1115        assert_eq!(fold(src).unwrap(), ConstValue::Int(20));
1116    }
1117
1118    #[test]
1119    fn len_of_literal_list() {
1120        assert_eq!(
1121            fold("const N = len([1, 2, 3, 4])").unwrap(),
1122            ConstValue::Int(4)
1123        );
1124    }
1125
1126    #[test]
1127    fn format_positional_placeholders() {
1128        let src = r#"const G = format("{}-{}", "hello", 42)"#;
1129        assert_eq!(
1130            fold(src).unwrap(),
1131            ConstValue::String("hello-42".to_string())
1132        );
1133    }
1134
1135    #[test]
1136    fn host_property_access_is_sandboxed() {
1137        let err = fold("const Z = harness.clock.now()").unwrap_err();
1138        assert!(matches!(
1139            err.kind,
1140            ConstEvalErrorKind::SandboxViolation | ConstEvalErrorKind::Disallowed
1141        ));
1142    }
1143
1144    #[test]
1145    fn division_by_zero_is_runtime_error() {
1146        let err = fold("const Z = 1 / 0").unwrap_err();
1147        assert!(matches!(err.kind, ConstEvalErrorKind::RuntimeError));
1148    }
1149
1150    #[test]
1151    fn unknown_identifier_is_runtime_error() {
1152        let err = fold("const Z = NOPE + 1").unwrap_err();
1153        assert!(matches!(err.kind, ConstEvalErrorKind::RuntimeError));
1154    }
1155
1156    #[test]
1157    fn spawn_is_sandbox_violation() {
1158        let err = fold("const Z = spawn { 1 }").unwrap_err();
1159        assert!(matches!(err.kind, ConstEvalErrorKind::SandboxViolation));
1160    }
1161
1162    #[test]
1163    fn user_function_call_is_sandboxed() {
1164        let err = fold("const Z = some_user_fn()").unwrap_err();
1165        assert!(matches!(err.kind, ConstEvalErrorKind::SandboxViolation));
1166    }
1167
1168    #[test]
1169    fn ternary_picks_branch() {
1170        assert_eq!(fold("const T = true ? 1 : 2").unwrap(), ConstValue::Int(1));
1171        assert_eq!(fold("const T = false ? 1 : 2").unwrap(), ConstValue::Int(2));
1172    }
1173
1174    #[test]
1175    fn list_subscript_folds() {
1176        assert_eq!(
1177            fold("const N = [10, 20, 30][1]").unwrap(),
1178            ConstValue::Int(20)
1179        );
1180    }
1181
1182    #[test]
1183    fn list_subscript_out_of_bounds_is_runtime_error() {
1184        let err = fold("const N = [1, 2][9]").unwrap_err();
1185        assert!(matches!(err.kind, ConstEvalErrorKind::RuntimeError));
1186    }
1187
1188    #[test]
1189    fn recursion_depth_is_bounded() {
1190        // Drive the depth guard directly without building a deep AST —
1191        // a deep `Box<SNode>` chain would stack-overflow Rust's default
1192        // recursive `Drop` long before the const-evaluator's own guard
1193        // tripped, which masked the unit being tested. The same guard
1194        // fires from production code paths regardless of how the depth
1195        // is reached.
1196        let env = ConstEnv::new();
1197        let mut ctx = EvalCtx {
1198            resolve: &|name| env.get(name).cloned(),
1199            steps: 0,
1200            depth: MAX_DEPTH,
1201        };
1202        let err = ctx.enter(Span::dummy()).unwrap_err();
1203        assert!(matches!(err.kind, ConstEvalErrorKind::RecursionLimit));
1204        // Guard cleanup: enter() must not have left the depth counter
1205        // bumped past the cap on the error path (otherwise repeated
1206        // tripping would saturate the counter and starve future calls).
1207        assert_eq!(ctx.depth, MAX_DEPTH);
1208    }
1209
1210    #[test]
1211    fn step_budget_is_bounded() {
1212        // The step counter is checked on every `eval_node` call, not
1213        // amortized — flip the counter near the cap and confirm the
1214        // very next reduction trips. Driving the counter directly keeps
1215        // the test fast and avoids constructing an AST large enough to
1216        // approach the 100k-step budget.
1217        let env = ConstEnv::new();
1218        let mut ctx = EvalCtx {
1219            resolve: &|name| env.get(name).cloned(),
1220            steps: MAX_STEPS,
1221            depth: 0,
1222        };
1223        let err = ctx.step(Span::dummy()).unwrap_err();
1224        assert!(matches!(err.kind, ConstEvalErrorKind::StepLimit));
1225    }
1226
1227    #[test]
1228    fn step_counter_is_not_amortized() {
1229        // A defensive end-to-end check: cap steps to a small fixed
1230        // budget via a hand-built ConstBinding-shaped expression and
1231        // observe that the very next call fails. The intent is to
1232        // prevent an accidental refactor that batches the cap check
1233        // (e.g. only every N steps).
1234        let env = ConstEnv::new();
1235        // Five back-to-back literal lookups consume exactly five steps.
1236        // Pre-loading the counter to MAX_STEPS - 4 then folding a
1237        // 5-step expression must trip exactly once.
1238        let mut ctx = EvalCtx {
1239            resolve: &|name| env.get(name).cloned(),
1240            steps: MAX_STEPS - 4,
1241            depth: 0,
1242        };
1243        let span = Span::dummy();
1244        for _ in 0..4 {
1245            ctx.step(span).expect("inside budget");
1246        }
1247        let err = ctx.step(span).unwrap_err();
1248        assert!(matches!(err.kind, ConstEvalErrorKind::StepLimit));
1249    }
1250
1251    #[test]
1252    fn index_and_round_fold_like_the_runtime() {
1253        assert_eq!(
1254            fold("const A = [10, 20, 30][-1]").unwrap(),
1255            ConstValue::Int(30)
1256        );
1257        assert!(fold("const B = [10, 20, 30][-4]").is_err());
1258        assert_eq!(
1259            fold("const C = round(123456.0, -5)").unwrap(),
1260            ConstValue::Float(100000.0)
1261        );
1262        assert_eq!(
1263            fold("const D = round(9000000000000000000, -19)").unwrap(),
1264            ConstValue::Float(1e19)
1265        );
1266    }
1267}