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                        items.get(i as usize).cloned().ok_or_else(|| {
534                            ConstEvalError::runtime(node.span, format!("index {i} out of bounds"))
535                        })
536                    }
537                    (ConstValue::Dict(entries), ConstValue::String(k)) => entries
538                        .into_iter()
539                        .find(|(name, _)| *name == k)
540                        .map(|(_, v)| v)
541                        .ok_or_else(|| {
542                            ConstEvalError::runtime(node.span, format!("unknown key `{k}`"))
543                        }),
544                    _ => Err(ConstEvalError::runtime(
545                        node.span,
546                        "subscript receiver and index types are incompatible",
547                    )),
548                }
549            }
550            Node::Block(_) => Err(ConstEvalError::disallowed(
551                node.span,
552                "block expression is not const-evaluable",
553            )),
554            Node::Closure { .. } => Err(ConstEvalError::disallowed(
555                node.span,
556                "closure is not const-evaluable",
557            )),
558
559            // ----- Loud sandbox violators: runtime/concurrency surface -----
560            Node::SpawnExpr { .. }
561            | Node::SelectExpr { .. }
562            | Node::Parallel { .. }
563            | Node::MutexBlock { .. }
564            | Node::DeferStmt { .. }
565            | Node::YieldExpr { .. }
566            | Node::EmitExpr { .. }
567            | Node::TryCatch { .. }
568            | Node::TryExpr { .. }
569            | Node::TryOperator { .. }
570            | Node::TryStar { .. }
571            | Node::DeadlineBlock { .. }
572            | Node::CostRoute { .. }
573            | Node::WhileLoop { .. }
574            | Node::ForIn { .. }
575            | Node::Retry { .. }
576            | Node::GuardStmt { .. }
577            | Node::RequireStmt { .. }
578            | Node::Assignment { .. }
579            | Node::ThrowStmt { .. }
580            | Node::ReturnStmt { .. }
581            | Node::BreakStmt
582            | Node::ContinueStmt => Err(ConstEvalError::sandbox(
583                node.span,
584                "runtime construct is not permitted in a const initializer",
585            )),
586
587            // Anything else: be conservative and disallow.
588            _ => Err(ConstEvalError::disallowed(
589                node.span,
590                "expression shape is not on the const-eval allowlist",
591            )),
592        }
593    }
594
595    fn dict_key_name(&self, entry: &DictEntry) -> Result<String, ConstEvalError> {
596        match &entry.key.node {
597            Node::Identifier(name) => Ok(name.clone()),
598            Node::StringLiteral(s) | Node::RawStringLiteral(s) => Ok(s.clone()),
599            _ => Err(ConstEvalError::disallowed(
600                entry.key.span,
601                "dict keys in a const dict literal must be identifiers or string literals",
602            )),
603        }
604    }
605
606    fn apply_binary(
607        &self,
608        op: BinaryOperator,
609        lhs: ConstValue,
610        rhs: ConstValue,
611        span: Span,
612    ) -> Result<ConstValue, ConstEvalError> {
613        use ConstValue::*;
614
615        // Special cases first.
616        if op.is_logical() {
617            let (Bool(l), Bool(r)) = (&lhs, &rhs) else {
618                return Err(ConstEvalError::runtime(
619                    span,
620                    format!("`{op}` requires bool operands"),
621                ));
622            };
623            return Ok(Bool(if op == BinaryOperator::And {
624                *l && *r
625            } else {
626                *l || *r
627            }));
628        }
629        if op == BinaryOperator::NilCoalesce {
630            return Ok(match lhs {
631                Nil => rhs,
632                other => other,
633            });
634        }
635        if op == BinaryOperator::Eq {
636            return Ok(Bool(lhs == rhs));
637        }
638        if op == BinaryOperator::Neq {
639            return Ok(Bool(lhs != rhs));
640        }
641
642        // String concat via `+`.
643        if op == BinaryOperator::Add {
644            if let (String(a), String(b)) = (&lhs, &rhs) {
645                return Ok(String(format!("{a}{b}")));
646            }
647        }
648
649        // Numeric arithmetic. Promote to float when either side is float.
650        let (lhs_num, rhs_num) = match (&lhs, &rhs) {
651            (Int(_) | Float(_), Int(_) | Float(_)) => (lhs.clone(), rhs.clone()),
652            _ => {
653                return Err(ConstEvalError::runtime(
654                    span,
655                    format!(
656                        "`{op}` requires numeric operands, got {} and {}",
657                        value_kind(&lhs),
658                        value_kind(&rhs)
659                    ),
660                ))
661            }
662        };
663
664        // Comparisons.
665        if op.precedence() == crate::operators::Precedence::Comparison {
666            let (l, r) = (as_float(&lhs_num), as_float(&rhs_num));
667            let out = match op {
668                BinaryOperator::Lt => l < r,
669                BinaryOperator::Gt => l > r,
670                BinaryOperator::Lte => l <= r,
671                BinaryOperator::Gte => l >= r,
672                _ => unreachable!(),
673            };
674            return Ok(Bool(out));
675        }
676
677        // Arithmetic.
678        if let (Int(a), Int(b)) = (&lhs_num, &rhs_num) {
679            let result = match op {
680                BinaryOperator::Add => a.checked_add(*b),
681                BinaryOperator::Sub => a.checked_sub(*b),
682                BinaryOperator::Mul => a.checked_mul(*b),
683                BinaryOperator::Div => {
684                    if *b == 0 {
685                        return Err(ConstEvalError::runtime(span, "division by zero"));
686                    }
687                    a.checked_div(*b)
688                }
689                BinaryOperator::Mod => {
690                    if *b == 0 {
691                        return Err(ConstEvalError::runtime(span, "modulo by zero"));
692                    }
693                    a.checked_rem(*b)
694                }
695                BinaryOperator::Pow => {
696                    if *b < 0 || *b > u32::MAX as i64 {
697                        return Err(ConstEvalError::runtime(
698                            span,
699                            "exponent must be a non-negative i64 within u32 range",
700                        ));
701                    }
702                    a.checked_pow(*b as u32)
703                }
704                _ => unreachable!(),
705            };
706            return result
707                .map(Int)
708                .ok_or_else(|| ConstEvalError::runtime(span, "integer overflow"));
709        }
710
711        let (l, r) = (as_float(&lhs_num), as_float(&rhs_num));
712        let value = match op {
713            BinaryOperator::Add => l + r,
714            BinaryOperator::Sub => l - r,
715            BinaryOperator::Mul => l * r,
716            BinaryOperator::Div => {
717                if r == 0.0 {
718                    return Err(ConstEvalError::runtime(span, "division by zero"));
719                }
720                l / r
721            }
722            BinaryOperator::Mod => {
723                if r == 0.0 {
724                    return Err(ConstEvalError::runtime(span, "modulo by zero"));
725                }
726                l % r
727            }
728            BinaryOperator::Pow => l.powf(r),
729            _ => unreachable!(),
730        };
731        Ok(Float(value))
732    }
733
734    fn apply_builtin(
735        &self,
736        name: &str,
737        args: Vec<ConstValue>,
738        span: Span,
739    ) -> Result<ConstValue, ConstEvalError> {
740        match name {
741            "len" => match args.as_slice() {
742                [ConstValue::String(s)] => Ok(ConstValue::Int(s.chars().count() as i64)),
743                [ConstValue::List(items)] => Ok(ConstValue::Int(items.len() as i64)),
744                [ConstValue::Dict(entries)] => Ok(ConstValue::Int(entries.len() as i64)),
745                _ => Err(ConstEvalError::runtime(
746                    span,
747                    "len() expects a single string / list / dict argument",
748                )),
749            },
750            "format" => format_call(span, args),
751            "concat" => {
752                let mut out = String::new();
753                for arg in &args {
754                    match arg {
755                        ConstValue::String(s) => out.push_str(s),
756                        _ => {
757                            return Err(ConstEvalError::runtime(
758                                span,
759                                "concat() expects string arguments",
760                            ))
761                        }
762                    }
763                }
764                Ok(ConstValue::String(out))
765            }
766            "join" => match args.as_slice() {
767                [ConstValue::List(items), ConstValue::String(sep)] => {
768                    let mut parts = Vec::with_capacity(items.len());
769                    for item in items {
770                        match item {
771                            ConstValue::String(s) => parts.push(s.clone()),
772                            other => parts.push(other.display()),
773                        }
774                    }
775                    Ok(ConstValue::String(parts.join(sep)))
776                }
777                _ => Err(ConstEvalError::runtime(
778                    span,
779                    "join() expects (list, string)",
780                )),
781            },
782            "min" | "max" => apply_min_max(name, &args, span),
783            "abs" => match args.as_slice() {
784                [ConstValue::Int(n)] => {
785                    Ok(ConstValue::Int(n.checked_abs().ok_or_else(|| {
786                        ConstEvalError::runtime(span, "integer overflow in abs()")
787                    })?))
788                }
789                [ConstValue::Float(f)] => Ok(ConstValue::Float(f.abs())),
790                _ => Err(ConstEvalError::runtime(
791                    span,
792                    "abs() expects a single numeric argument",
793                )),
794            },
795            "floor" => unary_float(span, &args, |f| f.floor()),
796            "ceil" => unary_float(span, &args, |f| f.ceil()),
797            "round" => match args.as_slice() {
798                // 2-arg form mirrors the runtime builtin: round to `digits`
799                // decimal places (half away from zero); negative digits round
800                // to power-of-ten buckets; ints stay ints when they fit.
801                [ConstValue::Float(f), ConstValue::Int(digits)] => {
802                    Ok(ConstValue::Float(round_float_to_digits(*f, *digits)))
803                }
804                [ConstValue::Int(n), ConstValue::Int(digits)] => {
805                    Ok(round_int_to_digits(*n, *digits))
806                }
807                _ => unary_float(span, &args, |f| f.round()),
808            },
809            "lowercase" => match args.as_slice() {
810                [ConstValue::String(s)] => Ok(ConstValue::String(s.to_lowercase())),
811                _ => Err(ConstEvalError::runtime(
812                    span,
813                    "lowercase() expects a string",
814                )),
815            },
816            "uppercase" => match args.as_slice() {
817                [ConstValue::String(s)] => Ok(ConstValue::String(s.to_uppercase())),
818                _ => Err(ConstEvalError::runtime(
819                    span,
820                    "uppercase() expects a string",
821                )),
822            },
823            "trim" => match args.as_slice() {
824                [ConstValue::String(s)] => Ok(ConstValue::String(s.trim().to_string())),
825                _ => Err(ConstEvalError::runtime(span, "trim() expects a string")),
826            },
827            // PURE_BUILTINS is the source of truth; if you reach this
828            // arm a name was added to the allowlist without an
829            // implementation here. Treat as a sandbox violation so the
830            // caller still gets an actionable diagnostic.
831            _ => Err(ConstEvalError::sandbox(
832                span,
833                format!("`{name}(...)` lacks a const-eval implementation"),
834            )),
835        }
836    }
837}
838
839/// Walk down a receiver chain (`Identifier` → `PropertyAccess` → … ) and
840/// return the leftmost identifier name. Used by the method-call sandbox
841/// probe so `harness.clock.now()` reports a precise sandbox diagnostic.
842fn leftmost_receiver_identifier(node: &SNode) -> Option<&str> {
843    let mut current = node;
844    loop {
845        match &current.node {
846            Node::Identifier(name) => return Some(name.as_str()),
847            Node::PropertyAccess { object, .. }
848            | Node::OptionalPropertyAccess { object, .. }
849            | Node::SubscriptAccess { object, .. }
850            | Node::OptionalSubscriptAccess { object, .. } => {
851                current = object;
852            }
853            _ => return None,
854        }
855    }
856}
857
858fn value_kind(v: &ConstValue) -> &'static str {
859    match v {
860        ConstValue::Int(_) => "int",
861        ConstValue::Float(_) => "float",
862        ConstValue::Bool(_) => "bool",
863        ConstValue::String(_) => "string",
864        ConstValue::List(_) => "list",
865        ConstValue::Dict(_) => "dict",
866        ConstValue::Nil => "nil",
867    }
868}
869
870fn as_float(v: &ConstValue) -> f64 {
871    match v {
872        ConstValue::Int(n) => *n as f64,
873        ConstValue::Float(f) => *f,
874        _ => 0.0,
875    }
876}
877
878fn format_call(span: Span, args: Vec<ConstValue>) -> Result<ConstValue, ConstEvalError> {
879    let mut iter = args.into_iter();
880    let template = match iter.next() {
881        Some(ConstValue::String(s)) => s,
882        Some(_) => {
883            return Err(ConstEvalError::runtime(
884                span,
885                "format() template must be a string literal",
886            ))
887        }
888        None => {
889            return Err(ConstEvalError::runtime(
890                span,
891                "format() requires at least a template argument",
892            ))
893        }
894    };
895    let rest: Vec<ConstValue> = iter.collect();
896
897    // Mirror runtime semantics: a single dict arg substitutes named
898    // `{key}` placeholders; otherwise positional `{}`.
899    if let [ConstValue::Dict(entries)] = rest.as_slice() {
900        let mut result = String::with_capacity(template.len());
901        let mut rest_str = template.as_str();
902        while let Some((head, after_open)) = rest_str.split_once('{') {
903            result.push_str(head);
904            if let Some((key, after_close)) = after_open.split_once('}') {
905                if let Some((_, val)) = entries.iter().find(|(k, _)| k == key) {
906                    result.push_str(&val.display());
907                } else {
908                    result.push('{');
909                    result.push_str(key);
910                    result.push('}');
911                }
912                rest_str = after_close;
913            } else {
914                result.push('{');
915                result.push_str(after_open);
916                rest_str = "";
917                break;
918            }
919        }
920        result.push_str(rest_str);
921        return Ok(ConstValue::String(result));
922    }
923
924    let mut result = String::with_capacity(template.len());
925    let mut rest_iter = rest.iter();
926    let mut tail = template.as_str();
927    while let Some((head, rest_of_template)) = tail.split_once("{}") {
928        result.push_str(head);
929        if let Some(arg) = rest_iter.next() {
930            result.push_str(&arg.display());
931        } else {
932            result.push_str("{}");
933        }
934        tail = rest_of_template;
935    }
936    result.push_str(tail);
937    Ok(ConstValue::String(result))
938}
939
940fn apply_min_max(
941    name: &str,
942    args: &[ConstValue],
943    span: Span,
944) -> Result<ConstValue, ConstEvalError> {
945    if args.is_empty() {
946        return Err(ConstEvalError::runtime(
947            span,
948            format!("{name}() requires at least one argument"),
949        ));
950    }
951    let mut all_int = true;
952    for arg in args {
953        match arg {
954            ConstValue::Int(_) => {}
955            ConstValue::Float(_) => all_int = false,
956            _ => {
957                return Err(ConstEvalError::runtime(
958                    span,
959                    format!("{name}() expects numeric arguments"),
960                ))
961            }
962        }
963    }
964    if all_int {
965        let nums: Vec<i64> = args
966            .iter()
967            .map(|v| match v {
968                ConstValue::Int(n) => *n,
969                _ => unreachable!(),
970            })
971            .collect();
972        let pick = if name == "min" {
973            nums.iter().copied().min().unwrap()
974        } else {
975            nums.iter().copied().max().unwrap()
976        };
977        Ok(ConstValue::Int(pick))
978    } else {
979        let nums: Vec<f64> = args.iter().map(as_float).collect();
980        let pick = if name == "min" {
981            nums.iter().copied().fold(f64::INFINITY, f64::min)
982        } else {
983            nums.iter().copied().fold(f64::NEG_INFINITY, f64::max)
984        };
985        Ok(ConstValue::Float(pick))
986    }
987}
988
989/// `round(x, digits)` for floats: half-away-from-zero at the requested
990/// decimal place. Mirrors `round_float_to_digits` in
991/// `crates/harn-vm/src/stdlib/math.rs` so const folding matches runtime.
992fn round_float_to_digits(x: f64, digits: i64) -> f64 {
993    if !x.is_finite() {
994        return x;
995    }
996    if digits == 0 {
997        return x.round();
998    }
999    if digits > 308 {
1000        return x;
1001    }
1002    if digits < -308 {
1003        return 0.0 * x.signum();
1004    }
1005    let factor = 10f64.powi(digits as i32);
1006    let scaled = x * factor;
1007    if !scaled.is_finite() {
1008        return x;
1009    }
1010    scaled.round() / factor
1011}
1012
1013/// `round(n, digits)` for ints. Mirrors `round_int_to_digits` in
1014/// `crates/harn-vm/src/stdlib/math.rs`: identity for `digits >= 0`, negative
1015/// digits round to the nearest power-of-ten bucket (halves away from zero),
1016/// and an out-of-range result promotes to float.
1017fn round_int_to_digits(n: i64, digits: i64) -> ConstValue {
1018    if digits >= 0 || n == 0 {
1019        return ConstValue::Int(n);
1020    }
1021    if digits <= -19 {
1022        return ConstValue::Int(0);
1023    }
1024    let factor = 10i128.pow((-digits) as u32);
1025    let n128 = n as i128;
1026    let rem = n128 % factor;
1027    let base = n128 - rem;
1028    let rounded = if rem.abs() * 2 >= factor {
1029        base + factor * n128.signum()
1030    } else {
1031        base
1032    };
1033    match i64::try_from(rounded) {
1034        Ok(v) => ConstValue::Int(v),
1035        Err(_) => ConstValue::Float(rounded as f64),
1036    }
1037}
1038
1039fn unary_float(
1040    span: Span,
1041    args: &[ConstValue],
1042    op: impl Fn(f64) -> f64,
1043) -> Result<ConstValue, ConstEvalError> {
1044    match args {
1045        [ConstValue::Int(n)] => Ok(ConstValue::Float(op(*n as f64))),
1046        [ConstValue::Float(f)] => Ok(ConstValue::Float(op(*f))),
1047        _ => Err(ConstEvalError::runtime(
1048            span,
1049            "expected a single numeric argument",
1050        )),
1051    }
1052}
1053
1054#[cfg(test)]
1055mod tests {
1056    use super::*;
1057    use crate::parse_source;
1058
1059    fn fold(source: &str) -> Result<ConstValue, ConstEvalError> {
1060        // Wrap as a const binding so the parser produces our node, then
1061        // extract the right-hand side and run const_eval on it with a
1062        // fresh environment seeded from any earlier const decls.
1063        let program = parse_source(source).expect("parse");
1064        let mut env = ConstEnv::new();
1065        let mut last = None;
1066        for snode in &program {
1067            if let Node::ConstBinding {
1068                pattern: crate::ast::BindingPattern::Identifier(name),
1069                value,
1070                ..
1071            } = &snode.node
1072            {
1073                let folded = const_eval(value, &env)?;
1074                env.insert(name.clone(), folded.clone());
1075                last = Some(folded);
1076            }
1077        }
1078        Ok(last.expect("no const binding in source"))
1079    }
1080
1081    #[test]
1082    fn arithmetic_literals_fold() {
1083        assert_eq!(fold("const X = 1 + 2").unwrap(), ConstValue::Int(3));
1084        assert_eq!(fold("const Y = 5 * (3 + 2)").unwrap(), ConstValue::Int(25));
1085        assert_eq!(fold("const Z = 2 ** 10").unwrap(), ConstValue::Int(1024));
1086    }
1087
1088    #[test]
1089    fn string_concat_folds() {
1090        assert_eq!(
1091            fold(r#"const S = "foo" + "-" + "bar""#).unwrap(),
1092            ConstValue::String("foo-bar".to_string())
1093        );
1094    }
1095
1096    #[test]
1097    fn earlier_const_visible_to_later() {
1098        let src = "const A = 10\nconst B = A * 2";
1099        assert_eq!(fold(src).unwrap(), ConstValue::Int(20));
1100    }
1101
1102    #[test]
1103    fn len_of_literal_list() {
1104        assert_eq!(
1105            fold("const N = len([1, 2, 3, 4])").unwrap(),
1106            ConstValue::Int(4)
1107        );
1108    }
1109
1110    #[test]
1111    fn format_positional_placeholders() {
1112        let src = r#"const G = format("{}-{}", "hello", 42)"#;
1113        assert_eq!(
1114            fold(src).unwrap(),
1115            ConstValue::String("hello-42".to_string())
1116        );
1117    }
1118
1119    #[test]
1120    fn host_property_access_is_sandboxed() {
1121        let err = fold("const Z = harness.clock.now()").unwrap_err();
1122        assert!(matches!(
1123            err.kind,
1124            ConstEvalErrorKind::SandboxViolation | ConstEvalErrorKind::Disallowed
1125        ));
1126    }
1127
1128    #[test]
1129    fn division_by_zero_is_runtime_error() {
1130        let err = fold("const Z = 1 / 0").unwrap_err();
1131        assert!(matches!(err.kind, ConstEvalErrorKind::RuntimeError));
1132    }
1133
1134    #[test]
1135    fn unknown_identifier_is_runtime_error() {
1136        let err = fold("const Z = NOPE + 1").unwrap_err();
1137        assert!(matches!(err.kind, ConstEvalErrorKind::RuntimeError));
1138    }
1139
1140    #[test]
1141    fn spawn_is_sandbox_violation() {
1142        let err = fold("const Z = spawn { 1 }").unwrap_err();
1143        assert!(matches!(err.kind, ConstEvalErrorKind::SandboxViolation));
1144    }
1145
1146    #[test]
1147    fn user_function_call_is_sandboxed() {
1148        let err = fold("const Z = some_user_fn()").unwrap_err();
1149        assert!(matches!(err.kind, ConstEvalErrorKind::SandboxViolation));
1150    }
1151
1152    #[test]
1153    fn ternary_picks_branch() {
1154        assert_eq!(fold("const T = true ? 1 : 2").unwrap(), ConstValue::Int(1));
1155        assert_eq!(fold("const T = false ? 1 : 2").unwrap(), ConstValue::Int(2));
1156    }
1157
1158    #[test]
1159    fn list_subscript_folds() {
1160        assert_eq!(
1161            fold("const N = [10, 20, 30][1]").unwrap(),
1162            ConstValue::Int(20)
1163        );
1164    }
1165
1166    #[test]
1167    fn list_subscript_out_of_bounds_is_runtime_error() {
1168        let err = fold("const N = [1, 2][9]").unwrap_err();
1169        assert!(matches!(err.kind, ConstEvalErrorKind::RuntimeError));
1170    }
1171
1172    #[test]
1173    fn recursion_depth_is_bounded() {
1174        // Drive the depth guard directly without building a deep AST —
1175        // a deep `Box<SNode>` chain would stack-overflow Rust's default
1176        // recursive `Drop` long before the const-evaluator's own guard
1177        // tripped, which masked the unit being tested. The same guard
1178        // fires from production code paths regardless of how the depth
1179        // is reached.
1180        let env = ConstEnv::new();
1181        let mut ctx = EvalCtx {
1182            resolve: &|name| env.get(name).cloned(),
1183            steps: 0,
1184            depth: MAX_DEPTH,
1185        };
1186        let err = ctx.enter(Span::dummy()).unwrap_err();
1187        assert!(matches!(err.kind, ConstEvalErrorKind::RecursionLimit));
1188        // Guard cleanup: enter() must not have left the depth counter
1189        // bumped past the cap on the error path (otherwise repeated
1190        // tripping would saturate the counter and starve future calls).
1191        assert_eq!(ctx.depth, MAX_DEPTH);
1192    }
1193
1194    #[test]
1195    fn step_budget_is_bounded() {
1196        // The step counter is checked on every `eval_node` call, not
1197        // amortized — flip the counter near the cap and confirm the
1198        // very next reduction trips. Driving the counter directly keeps
1199        // the test fast and avoids constructing an AST large enough to
1200        // approach the 100k-step budget.
1201        let env = ConstEnv::new();
1202        let mut ctx = EvalCtx {
1203            resolve: &|name| env.get(name).cloned(),
1204            steps: MAX_STEPS,
1205            depth: 0,
1206        };
1207        let err = ctx.step(Span::dummy()).unwrap_err();
1208        assert!(matches!(err.kind, ConstEvalErrorKind::StepLimit));
1209    }
1210
1211    #[test]
1212    fn step_counter_is_not_amortized() {
1213        // A defensive end-to-end check: cap steps to a small fixed
1214        // budget via a hand-built ConstBinding-shaped expression and
1215        // observe that the very next call fails. The intent is to
1216        // prevent an accidental refactor that batches the cap check
1217        // (e.g. only every N steps).
1218        let env = ConstEnv::new();
1219        // Five back-to-back literal lookups consume exactly five steps.
1220        // Pre-loading the counter to MAX_STEPS - 4 then folding a
1221        // 5-step expression must trip exactly once.
1222        let mut ctx = EvalCtx {
1223            resolve: &|name| env.get(name).cloned(),
1224            steps: MAX_STEPS - 4,
1225            depth: 0,
1226        };
1227        let span = Span::dummy();
1228        for _ in 0..4 {
1229            ctx.step(span).expect("inside budget");
1230        }
1231        let err = ctx.step(span).unwrap_err();
1232        assert!(matches!(err.kind, ConstEvalErrorKind::StepLimit));
1233    }
1234}