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::{DictEntry, Node, SNode};
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: &[&str] = &[
226    "+", "-", "*", "/", "%", "**", "==", "!=", "<", ">", "<=", ">=", "&&", "||", "??",
227];
228
229/// Environment mapping a `const` name to its already-folded value. The
230/// typechecker primes this with bindings encountered earlier in the same
231/// file (i.e. `const X: int = 1` lets `const Y: int = X + 2` resolve).
232pub type ConstEnv = HashMap<String, ConstValue>;
233
234/// Public entry point: fold a single AST node into a [`ConstValue`] or
235/// return a [`ConstEvalError`]. The `env` argument supplies earlier
236/// `const` bindings visible to this expression.
237pub fn const_eval(node: &SNode, env: &ConstEnv) -> Result<ConstValue, ConstEvalError> {
238    let mut ctx = EvalCtx {
239        env,
240        steps: 0,
241        depth: 0,
242    };
243    ctx.eval_node(node)
244}
245
246struct EvalCtx<'a> {
247    env: &'a ConstEnv,
248    steps: u32,
249    depth: u32,
250}
251
252impl<'a> EvalCtx<'a> {
253    fn step(&mut self, span: Span) -> Result<(), ConstEvalError> {
254        self.steps = self.steps.saturating_add(1);
255        if self.steps > MAX_STEPS {
256            return Err(ConstEvalError::step_limit(span));
257        }
258        Ok(())
259    }
260
261    fn enter(&mut self, span: Span) -> Result<(), ConstEvalError> {
262        self.depth = self.depth.saturating_add(1);
263        if self.depth > MAX_DEPTH {
264            self.depth -= 1;
265            return Err(ConstEvalError::recursion_limit(span));
266        }
267        Ok(())
268    }
269
270    fn leave(&mut self) {
271        self.depth = self.depth.saturating_sub(1);
272    }
273
274    fn eval_node(&mut self, node: &SNode) -> Result<ConstValue, ConstEvalError> {
275        self.step(node.span)?;
276        self.enter(node.span)?;
277        let result = self.eval_node_inner(node);
278        self.leave();
279        result
280    }
281
282    fn eval_node_inner(&mut self, node: &SNode) -> Result<ConstValue, ConstEvalError> {
283        let ctx = self;
284        match &node.node {
285            Node::IntLiteral(n) => Ok(ConstValue::Int(*n)),
286            Node::FloatLiteral(f) => Ok(ConstValue::Float(*f)),
287            Node::BoolLiteral(b) => Ok(ConstValue::Bool(*b)),
288            Node::StringLiteral(s) | Node::RawStringLiteral(s) => Ok(ConstValue::String(s.clone())),
289            Node::NilLiteral => Ok(ConstValue::Nil),
290
291            Node::Identifier(name) => ctx.env.get(name).cloned().ok_or_else(|| {
292                ConstEvalError::runtime(
293                    node.span,
294                    format!("`{name}` is not a const-known identifier"),
295                )
296            }),
297
298            Node::ListLiteral(items) => {
299                let mut out = Vec::with_capacity(items.len());
300                for item in items {
301                    if matches!(&item.node, Node::Spread(_)) {
302                        return Err(ConstEvalError::disallowed(
303                            item.span,
304                            "spread in a const list literal is not supported",
305                        ));
306                    }
307                    out.push(ctx.eval_node(item)?);
308                }
309                Ok(ConstValue::List(out))
310            }
311
312            Node::DictLiteral(entries) => {
313                let mut out: Vec<(String, ConstValue)> = Vec::with_capacity(entries.len());
314                for entry in entries {
315                    let key = ctx.dict_key_name(entry)?;
316                    let value = ctx.eval_node(&entry.value)?;
317                    out.push((key, value));
318                }
319                Ok(ConstValue::Dict(out))
320            }
321
322            Node::InterpolatedString(segments) => {
323                let mut buf = String::new();
324                for seg in segments {
325                    match seg {
326                        StringSegment::Literal(lit) => buf.push_str(lit),
327                        StringSegment::Expression(src, _, _) => {
328                            // The interpolated expression is stored as
329                            // raw source text. The const-evaluator never
330                            // recursively re-parses host source, so we
331                            // refuse to fold dynamic interpolation. The
332                            // typechecker can still surface this as a
333                            // disallowed expression at the binding
334                            // span — interpolation is the one case where
335                            // const-eval treats the inner content as
336                            // opaque.
337                            return Err(ConstEvalError::disallowed(
338                                node.span,
339                                format!("interpolated expression `${{{src}}}` is not supported in a const initializer; use `format(...)` or string concatenation"),
340                            ));
341                        }
342                    }
343                }
344                Ok(ConstValue::String(buf))
345            }
346
347            Node::UnaryOp { op, operand } => {
348                let value = ctx.eval_node(operand)?;
349                match (op.as_str(), &value) {
350                    ("-", ConstValue::Int(n)) => {
351                        Ok(ConstValue::Int(n.checked_neg().ok_or_else(|| {
352                            ConstEvalError::runtime(node.span, "integer overflow in unary minus")
353                        })?))
354                    }
355                    ("-", ConstValue::Float(f)) => Ok(ConstValue::Float(-f)),
356                    ("!", ConstValue::Bool(b)) => Ok(ConstValue::Bool(!b)),
357                    _ => Err(ConstEvalError::runtime(
358                        node.span,
359                        format!("unary `{op}` is not defined for the operand"),
360                    )),
361                }
362            }
363
364            Node::BinaryOp { op, left, right } => {
365                if !PURE_BINARY_OPS.contains(&op.as_str()) {
366                    return Err(ConstEvalError::disallowed(
367                        node.span,
368                        format!("binary operator `{op}` is not const-evaluable"),
369                    ));
370                }
371                let lhs = ctx.eval_node(left)?;
372                let rhs = ctx.eval_node(right)?;
373                ctx.apply_binary(op, lhs, rhs, node.span)
374            }
375
376            Node::Ternary {
377                condition,
378                true_expr,
379                false_expr,
380            } => {
381                let cond = ctx.eval_node(condition)?;
382                let pick = match cond {
383                    ConstValue::Bool(b) => b,
384                    _ => {
385                        return Err(ConstEvalError::runtime(
386                            condition.span,
387                            "ternary condition must fold to a bool",
388                        ))
389                    }
390                };
391                if pick {
392                    ctx.eval_node(true_expr)
393                } else {
394                    ctx.eval_node(false_expr)
395                }
396            }
397
398            Node::IfElse {
399                condition,
400                then_body,
401                else_body,
402                ..
403            } => {
404                let cond = ctx.eval_node(condition)?;
405                let pick = match cond {
406                    ConstValue::Bool(b) => b,
407                    _ => {
408                        return Err(ConstEvalError::runtime(
409                            condition.span,
410                            "if-expression condition must fold to a bool",
411                        ))
412                    }
413                };
414                let branch =
415                    if pick {
416                        then_body.as_slice()
417                    } else {
418                        match else_body {
419                            Some(body) => body.as_slice(),
420                            None => return Err(ConstEvalError::disallowed(
421                                node.span,
422                                "if-expression without an else branch cannot be const-evaluated",
423                            )),
424                        }
425                    };
426                let Some(last) = branch.last() else {
427                    return Err(ConstEvalError::disallowed(
428                        node.span,
429                        "if-expression branch must produce a value",
430                    ));
431                };
432                if let Some(first_pre) = branch[..branch.len().saturating_sub(1)].first() {
433                    // The only branch shape that folds is a single
434                    // value expression. Anything before the final
435                    // expression would require statement-level side
436                    // effects the sandbox cannot model.
437                    return Err(ConstEvalError::disallowed(
438                        first_pre.span,
439                        "multi-statement if-branch is not const-evaluable",
440                    ));
441                }
442                ctx.eval_node(last)
443            }
444
445            Node::FunctionCall { name, args, .. } => {
446                if !PURE_BUILTINS.contains(&name.as_str()) {
447                    return Err(ConstEvalError::sandbox(
448                        node.span,
449                        format!(
450                            "`{name}(...)` is not on the const-eval allowlist (only pure stdlib builtins may be called from a const initializer)"
451                        ),
452                    ));
453                }
454                let mut folded = Vec::with_capacity(args.len());
455                for arg in args {
456                    folded.push(ctx.eval_node(arg)?);
457                }
458                ctx.apply_builtin(name, folded, node.span)
459            }
460
461            // ----- Explicit sandbox-violating shapes -----
462            //
463            // Each of these has a more useful diagnostic than the
464            // catch-all "expression not allowed" because the user almost
465            // certainly tried something with side effects.
466            Node::PropertyAccess { object, .. } | Node::OptionalPropertyAccess { object, .. } => {
467                if let Node::Identifier(root) = &object.node {
468                    if SANDBOXED_OBJECT_ROOTS.contains(&root.as_str()) {
469                        return Err(ConstEvalError::sandbox(
470                            node.span,
471                            format!(
472                                "`{root}.*` is a sandboxed capability surface; const-eval refuses fs/net/env/process/host access"
473                            ),
474                        ));
475                    }
476                }
477                Err(ConstEvalError::disallowed(
478                    node.span,
479                    "property access is not const-evaluable",
480                ))
481            }
482            Node::MethodCall { object, .. } | Node::OptionalMethodCall { object, .. } => {
483                // Probe the receiver chain for a sandboxed host root so
484                // `harness.clock.now()` reports the dedicated sandbox
485                // diagnostic instead of the generic disallowed-method
486                // catch-all. The receiver of `harness.clock.now()` is
487                // parsed as `PropertyAccess { object: harness, "clock" }`,
488                // and a deeper chain would wrap it in more
489                // `PropertyAccess` nodes. Walk down through any chain
490                // and pick out the leftmost identifier.
491                if let Some(root) = leftmost_receiver_identifier(object) {
492                    if SANDBOXED_OBJECT_ROOTS.contains(&root) {
493                        return Err(ConstEvalError::sandbox(
494                            node.span,
495                            format!(
496                                "`{root}.*(...)` is a sandboxed capability surface; const-eval refuses fs/net/env/process/host access"
497                            ),
498                        ));
499                    }
500                }
501                Err(ConstEvalError::disallowed(
502                    node.span,
503                    "method call is not const-evaluable",
504                ))
505            }
506            Node::SubscriptAccess { object, index } => {
507                let recv = ctx.eval_node(object)?;
508                let idx = ctx.eval_node(index)?;
509                match (recv, idx) {
510                    (ConstValue::List(items), ConstValue::Int(i)) => {
511                        items.get(i as usize).cloned().ok_or_else(|| {
512                            ConstEvalError::runtime(node.span, format!("index {i} out of bounds"))
513                        })
514                    }
515                    (ConstValue::Dict(entries), ConstValue::String(k)) => entries
516                        .into_iter()
517                        .find(|(name, _)| *name == k)
518                        .map(|(_, v)| v)
519                        .ok_or_else(|| {
520                            ConstEvalError::runtime(node.span, format!("unknown key `{k}`"))
521                        }),
522                    _ => Err(ConstEvalError::runtime(
523                        node.span,
524                        "subscript receiver and index types are incompatible",
525                    )),
526                }
527            }
528            Node::Block(_) => Err(ConstEvalError::disallowed(
529                node.span,
530                "block expression is not const-evaluable",
531            )),
532            Node::Closure { .. } => Err(ConstEvalError::disallowed(
533                node.span,
534                "closure is not const-evaluable",
535            )),
536
537            // ----- Loud sandbox violators: runtime/concurrency surface -----
538            Node::SpawnExpr { .. }
539            | Node::SelectExpr { .. }
540            | Node::Parallel { .. }
541            | Node::MutexBlock { .. }
542            | Node::DeferStmt { .. }
543            | Node::YieldExpr { .. }
544            | Node::EmitExpr { .. }
545            | Node::TryCatch { .. }
546            | Node::TryExpr { .. }
547            | Node::TryOperator { .. }
548            | Node::TryStar { .. }
549            | Node::DeadlineBlock { .. }
550            | Node::CostRoute { .. }
551            | Node::WhileLoop { .. }
552            | Node::ForIn { .. }
553            | Node::Retry { .. }
554            | Node::GuardStmt { .. }
555            | Node::RequireStmt { .. }
556            | Node::Assignment { .. }
557            | Node::ThrowStmt { .. }
558            | Node::ReturnStmt { .. }
559            | Node::BreakStmt
560            | Node::ContinueStmt => Err(ConstEvalError::sandbox(
561                node.span,
562                "runtime construct is not permitted in a const initializer",
563            )),
564
565            // Anything else: be conservative and disallow.
566            _ => Err(ConstEvalError::disallowed(
567                node.span,
568                "expression shape is not on the const-eval allowlist",
569            )),
570        }
571    }
572
573    fn dict_key_name(&self, entry: &DictEntry) -> Result<String, ConstEvalError> {
574        match &entry.key.node {
575            Node::Identifier(name) => Ok(name.clone()),
576            Node::StringLiteral(s) | Node::RawStringLiteral(s) => Ok(s.clone()),
577            _ => Err(ConstEvalError::disallowed(
578                entry.key.span,
579                "dict keys in a const dict literal must be identifiers or string literals",
580            )),
581        }
582    }
583
584    fn apply_binary(
585        &self,
586        op: &str,
587        lhs: ConstValue,
588        rhs: ConstValue,
589        span: Span,
590    ) -> Result<ConstValue, ConstEvalError> {
591        use ConstValue::*;
592
593        // Special cases first.
594        if op == "&&" || op == "||" {
595            let (Bool(l), Bool(r)) = (&lhs, &rhs) else {
596                return Err(ConstEvalError::runtime(
597                    span,
598                    format!("`{op}` requires bool operands"),
599                ));
600            };
601            return Ok(Bool(if op == "&&" { *l && *r } else { *l || *r }));
602        }
603        if op == "??" {
604            return Ok(match lhs {
605                Nil => rhs,
606                other => other,
607            });
608        }
609        if op == "==" {
610            return Ok(Bool(lhs == rhs));
611        }
612        if op == "!=" {
613            return Ok(Bool(lhs != rhs));
614        }
615
616        // String concat via `+`.
617        if op == "+" {
618            if let (String(a), String(b)) = (&lhs, &rhs) {
619                return Ok(String(format!("{a}{b}")));
620            }
621        }
622
623        // Numeric arithmetic. Promote to float when either side is float.
624        let (lhs_num, rhs_num) = match (&lhs, &rhs) {
625            (Int(_) | Float(_), Int(_) | Float(_)) => (lhs.clone(), rhs.clone()),
626            _ => {
627                return Err(ConstEvalError::runtime(
628                    span,
629                    format!(
630                        "`{op}` requires numeric operands, got {} and {}",
631                        value_kind(&lhs),
632                        value_kind(&rhs)
633                    ),
634                ))
635            }
636        };
637
638        // Comparisons.
639        if matches!(op, "<" | ">" | "<=" | ">=") {
640            let (l, r) = (as_float(&lhs_num), as_float(&rhs_num));
641            let out = match op {
642                "<" => l < r,
643                ">" => l > r,
644                "<=" => l <= r,
645                ">=" => l >= r,
646                _ => unreachable!(),
647            };
648            return Ok(Bool(out));
649        }
650
651        // Arithmetic.
652        if let (Int(a), Int(b)) = (&lhs_num, &rhs_num) {
653            let result = match op {
654                "+" => a.checked_add(*b),
655                "-" => a.checked_sub(*b),
656                "*" => a.checked_mul(*b),
657                "/" => {
658                    if *b == 0 {
659                        return Err(ConstEvalError::runtime(span, "division by zero"));
660                    }
661                    a.checked_div(*b)
662                }
663                "%" => {
664                    if *b == 0 {
665                        return Err(ConstEvalError::runtime(span, "modulo by zero"));
666                    }
667                    a.checked_rem(*b)
668                }
669                "**" => {
670                    if *b < 0 || *b > u32::MAX as i64 {
671                        return Err(ConstEvalError::runtime(
672                            span,
673                            "exponent must be a non-negative i64 within u32 range",
674                        ));
675                    }
676                    a.checked_pow(*b as u32)
677                }
678                _ => unreachable!(),
679            };
680            return result
681                .map(Int)
682                .ok_or_else(|| ConstEvalError::runtime(span, "integer overflow"));
683        }
684
685        let (l, r) = (as_float(&lhs_num), as_float(&rhs_num));
686        let value = match op {
687            "+" => l + r,
688            "-" => l - r,
689            "*" => l * r,
690            "/" => {
691                if r == 0.0 {
692                    return Err(ConstEvalError::runtime(span, "division by zero"));
693                }
694                l / r
695            }
696            "%" => {
697                if r == 0.0 {
698                    return Err(ConstEvalError::runtime(span, "modulo by zero"));
699                }
700                l % r
701            }
702            "**" => l.powf(r),
703            _ => unreachable!(),
704        };
705        Ok(Float(value))
706    }
707
708    fn apply_builtin(
709        &self,
710        name: &str,
711        args: Vec<ConstValue>,
712        span: Span,
713    ) -> Result<ConstValue, ConstEvalError> {
714        match name {
715            "len" => match args.as_slice() {
716                [ConstValue::String(s)] => Ok(ConstValue::Int(s.chars().count() as i64)),
717                [ConstValue::List(items)] => Ok(ConstValue::Int(items.len() as i64)),
718                [ConstValue::Dict(entries)] => Ok(ConstValue::Int(entries.len() as i64)),
719                _ => Err(ConstEvalError::runtime(
720                    span,
721                    "len() expects a single string / list / dict argument",
722                )),
723            },
724            "format" => format_call(span, args),
725            "concat" => {
726                let mut out = String::new();
727                for arg in &args {
728                    match arg {
729                        ConstValue::String(s) => out.push_str(s),
730                        _ => {
731                            return Err(ConstEvalError::runtime(
732                                span,
733                                "concat() expects string arguments",
734                            ))
735                        }
736                    }
737                }
738                Ok(ConstValue::String(out))
739            }
740            "join" => match args.as_slice() {
741                [ConstValue::List(items), ConstValue::String(sep)] => {
742                    let mut parts = Vec::with_capacity(items.len());
743                    for item in items {
744                        match item {
745                            ConstValue::String(s) => parts.push(s.clone()),
746                            other => parts.push(other.display()),
747                        }
748                    }
749                    Ok(ConstValue::String(parts.join(sep)))
750                }
751                _ => Err(ConstEvalError::runtime(
752                    span,
753                    "join() expects (list, string)",
754                )),
755            },
756            "min" | "max" => apply_min_max(name, &args, span),
757            "abs" => match args.as_slice() {
758                [ConstValue::Int(n)] => {
759                    Ok(ConstValue::Int(n.checked_abs().ok_or_else(|| {
760                        ConstEvalError::runtime(span, "integer overflow in abs()")
761                    })?))
762                }
763                [ConstValue::Float(f)] => Ok(ConstValue::Float(f.abs())),
764                _ => Err(ConstEvalError::runtime(
765                    span,
766                    "abs() expects a single numeric argument",
767                )),
768            },
769            "floor" => unary_float(span, &args, |f| f.floor()),
770            "ceil" => unary_float(span, &args, |f| f.ceil()),
771            "round" => match args.as_slice() {
772                // 2-arg form mirrors the runtime builtin: round to `digits`
773                // decimal places (half away from zero); negative digits round
774                // to power-of-ten buckets; ints stay ints when they fit.
775                [ConstValue::Float(f), ConstValue::Int(digits)] => {
776                    Ok(ConstValue::Float(round_float_to_digits(*f, *digits)))
777                }
778                [ConstValue::Int(n), ConstValue::Int(digits)] => {
779                    Ok(round_int_to_digits(*n, *digits))
780                }
781                _ => unary_float(span, &args, |f| f.round()),
782            },
783            "lowercase" => match args.as_slice() {
784                [ConstValue::String(s)] => Ok(ConstValue::String(s.to_lowercase())),
785                _ => Err(ConstEvalError::runtime(
786                    span,
787                    "lowercase() expects a string",
788                )),
789            },
790            "uppercase" => match args.as_slice() {
791                [ConstValue::String(s)] => Ok(ConstValue::String(s.to_uppercase())),
792                _ => Err(ConstEvalError::runtime(
793                    span,
794                    "uppercase() expects a string",
795                )),
796            },
797            "trim" => match args.as_slice() {
798                [ConstValue::String(s)] => Ok(ConstValue::String(s.trim().to_string())),
799                _ => Err(ConstEvalError::runtime(span, "trim() expects a string")),
800            },
801            // PURE_BUILTINS is the source of truth; if you reach this
802            // arm a name was added to the allowlist without an
803            // implementation here. Treat as a sandbox violation so the
804            // caller still gets an actionable diagnostic.
805            _ => Err(ConstEvalError::sandbox(
806                span,
807                format!("`{name}(...)` lacks a const-eval implementation"),
808            )),
809        }
810    }
811}
812
813/// Walk down a receiver chain (`Identifier` → `PropertyAccess` → … ) and
814/// return the leftmost identifier name. Used by the method-call sandbox
815/// probe so `harness.clock.now()` reports a precise sandbox diagnostic.
816fn leftmost_receiver_identifier(node: &SNode) -> Option<&str> {
817    let mut current = node;
818    loop {
819        match &current.node {
820            Node::Identifier(name) => return Some(name.as_str()),
821            Node::PropertyAccess { object, .. }
822            | Node::OptionalPropertyAccess { object, .. }
823            | Node::SubscriptAccess { object, .. }
824            | Node::OptionalSubscriptAccess { object, .. } => {
825                current = object;
826            }
827            _ => return None,
828        }
829    }
830}
831
832fn value_kind(v: &ConstValue) -> &'static str {
833    match v {
834        ConstValue::Int(_) => "int",
835        ConstValue::Float(_) => "float",
836        ConstValue::Bool(_) => "bool",
837        ConstValue::String(_) => "string",
838        ConstValue::List(_) => "list",
839        ConstValue::Dict(_) => "dict",
840        ConstValue::Nil => "nil",
841    }
842}
843
844fn as_float(v: &ConstValue) -> f64 {
845    match v {
846        ConstValue::Int(n) => *n as f64,
847        ConstValue::Float(f) => *f,
848        _ => 0.0,
849    }
850}
851
852fn format_call(span: Span, args: Vec<ConstValue>) -> Result<ConstValue, ConstEvalError> {
853    let mut iter = args.into_iter();
854    let template = match iter.next() {
855        Some(ConstValue::String(s)) => s,
856        Some(_) => {
857            return Err(ConstEvalError::runtime(
858                span,
859                "format() template must be a string literal",
860            ))
861        }
862        None => {
863            return Err(ConstEvalError::runtime(
864                span,
865                "format() requires at least a template argument",
866            ))
867        }
868    };
869    let rest: Vec<ConstValue> = iter.collect();
870
871    // Mirror runtime semantics: a single dict arg substitutes named
872    // `{key}` placeholders; otherwise positional `{}`.
873    if let [ConstValue::Dict(entries)] = rest.as_slice() {
874        let mut result = String::with_capacity(template.len());
875        let mut rest_str = template.as_str();
876        while let Some((head, after_open)) = rest_str.split_once('{') {
877            result.push_str(head);
878            if let Some((key, after_close)) = after_open.split_once('}') {
879                if let Some((_, val)) = entries.iter().find(|(k, _)| k == key) {
880                    result.push_str(&val.display());
881                } else {
882                    result.push('{');
883                    result.push_str(key);
884                    result.push('}');
885                }
886                rest_str = after_close;
887            } else {
888                result.push('{');
889                result.push_str(after_open);
890                rest_str = "";
891                break;
892            }
893        }
894        result.push_str(rest_str);
895        return Ok(ConstValue::String(result));
896    }
897
898    let mut result = String::with_capacity(template.len());
899    let mut rest_iter = rest.iter();
900    let mut tail = template.as_str();
901    while let Some((head, rest_of_template)) = tail.split_once("{}") {
902        result.push_str(head);
903        if let Some(arg) = rest_iter.next() {
904            result.push_str(&arg.display());
905        } else {
906            result.push_str("{}");
907        }
908        tail = rest_of_template;
909    }
910    result.push_str(tail);
911    Ok(ConstValue::String(result))
912}
913
914fn apply_min_max(
915    name: &str,
916    args: &[ConstValue],
917    span: Span,
918) -> Result<ConstValue, ConstEvalError> {
919    if args.is_empty() {
920        return Err(ConstEvalError::runtime(
921            span,
922            format!("{name}() requires at least one argument"),
923        ));
924    }
925    let mut all_int = true;
926    for arg in args {
927        match arg {
928            ConstValue::Int(_) => {}
929            ConstValue::Float(_) => all_int = false,
930            _ => {
931                return Err(ConstEvalError::runtime(
932                    span,
933                    format!("{name}() expects numeric arguments"),
934                ))
935            }
936        }
937    }
938    if all_int {
939        let nums: Vec<i64> = args
940            .iter()
941            .map(|v| match v {
942                ConstValue::Int(n) => *n,
943                _ => unreachable!(),
944            })
945            .collect();
946        let pick = if name == "min" {
947            nums.iter().copied().min().unwrap()
948        } else {
949            nums.iter().copied().max().unwrap()
950        };
951        Ok(ConstValue::Int(pick))
952    } else {
953        let nums: Vec<f64> = args.iter().map(as_float).collect();
954        let pick = if name == "min" {
955            nums.iter().copied().fold(f64::INFINITY, f64::min)
956        } else {
957            nums.iter().copied().fold(f64::NEG_INFINITY, f64::max)
958        };
959        Ok(ConstValue::Float(pick))
960    }
961}
962
963/// `round(x, digits)` for floats: half-away-from-zero at the requested
964/// decimal place. Mirrors `round_float_to_digits` in
965/// `crates/harn-vm/src/stdlib/math.rs` so const folding matches runtime.
966fn round_float_to_digits(x: f64, digits: i64) -> f64 {
967    if !x.is_finite() {
968        return x;
969    }
970    if digits == 0 {
971        return x.round();
972    }
973    if digits > 308 {
974        return x;
975    }
976    if digits < -308 {
977        return 0.0 * x.signum();
978    }
979    let factor = 10f64.powi(digits as i32);
980    let scaled = x * factor;
981    if !scaled.is_finite() {
982        return x;
983    }
984    scaled.round() / factor
985}
986
987/// `round(n, digits)` for ints. Mirrors `round_int_to_digits` in
988/// `crates/harn-vm/src/stdlib/math.rs`: identity for `digits >= 0`, negative
989/// digits round to the nearest power-of-ten bucket (halves away from zero),
990/// and an out-of-range result promotes to float.
991fn round_int_to_digits(n: i64, digits: i64) -> ConstValue {
992    if digits >= 0 || n == 0 {
993        return ConstValue::Int(n);
994    }
995    if digits <= -19 {
996        return ConstValue::Int(0);
997    }
998    let factor = 10i128.pow((-digits) as u32);
999    let n128 = n as i128;
1000    let rem = n128 % factor;
1001    let base = n128 - rem;
1002    let rounded = if rem.abs() * 2 >= factor {
1003        base + factor * n128.signum()
1004    } else {
1005        base
1006    };
1007    match i64::try_from(rounded) {
1008        Ok(v) => ConstValue::Int(v),
1009        Err(_) => ConstValue::Float(rounded as f64),
1010    }
1011}
1012
1013fn unary_float(
1014    span: Span,
1015    args: &[ConstValue],
1016    op: impl Fn(f64) -> f64,
1017) -> Result<ConstValue, ConstEvalError> {
1018    match args {
1019        [ConstValue::Int(n)] => Ok(ConstValue::Float(op(*n as f64))),
1020        [ConstValue::Float(f)] => Ok(ConstValue::Float(op(*f))),
1021        _ => Err(ConstEvalError::runtime(
1022            span,
1023            "expected a single numeric argument",
1024        )),
1025    }
1026}
1027
1028#[cfg(test)]
1029mod tests {
1030    use super::*;
1031    use crate::parse_source;
1032
1033    fn fold(source: &str) -> Result<ConstValue, ConstEvalError> {
1034        // Wrap as a const binding so the parser produces our node, then
1035        // extract the right-hand side and run const_eval on it with a
1036        // fresh environment seeded from any earlier const decls.
1037        let program = parse_source(source).expect("parse");
1038        let mut env = ConstEnv::new();
1039        let mut last = None;
1040        for snode in &program {
1041            if let Node::ConstBinding {
1042                pattern: crate::ast::BindingPattern::Identifier(name),
1043                value,
1044                ..
1045            } = &snode.node
1046            {
1047                let folded = const_eval(value, &env)?;
1048                env.insert(name.clone(), folded.clone());
1049                last = Some(folded);
1050            }
1051        }
1052        Ok(last.expect("no const binding in source"))
1053    }
1054
1055    #[test]
1056    fn arithmetic_literals_fold() {
1057        assert_eq!(fold("const X = 1 + 2").unwrap(), ConstValue::Int(3));
1058        assert_eq!(fold("const Y = 5 * (3 + 2)").unwrap(), ConstValue::Int(25));
1059        assert_eq!(fold("const Z = 2 ** 10").unwrap(), ConstValue::Int(1024));
1060    }
1061
1062    #[test]
1063    fn string_concat_folds() {
1064        assert_eq!(
1065            fold(r#"const S = "foo" + "-" + "bar""#).unwrap(),
1066            ConstValue::String("foo-bar".to_string())
1067        );
1068    }
1069
1070    #[test]
1071    fn earlier_const_visible_to_later() {
1072        let src = "const A = 10\nconst B = A * 2";
1073        assert_eq!(fold(src).unwrap(), ConstValue::Int(20));
1074    }
1075
1076    #[test]
1077    fn len_of_literal_list() {
1078        assert_eq!(
1079            fold("const N = len([1, 2, 3, 4])").unwrap(),
1080            ConstValue::Int(4)
1081        );
1082    }
1083
1084    #[test]
1085    fn format_positional_placeholders() {
1086        let src = r#"const G = format("{}-{}", "hello", 42)"#;
1087        assert_eq!(
1088            fold(src).unwrap(),
1089            ConstValue::String("hello-42".to_string())
1090        );
1091    }
1092
1093    #[test]
1094    fn host_property_access_is_sandboxed() {
1095        let err = fold("const Z = harness.clock.now()").unwrap_err();
1096        assert!(matches!(
1097            err.kind,
1098            ConstEvalErrorKind::SandboxViolation | ConstEvalErrorKind::Disallowed
1099        ));
1100    }
1101
1102    #[test]
1103    fn division_by_zero_is_runtime_error() {
1104        let err = fold("const Z = 1 / 0").unwrap_err();
1105        assert!(matches!(err.kind, ConstEvalErrorKind::RuntimeError));
1106    }
1107
1108    #[test]
1109    fn unknown_identifier_is_runtime_error() {
1110        let err = fold("const Z = NOPE + 1").unwrap_err();
1111        assert!(matches!(err.kind, ConstEvalErrorKind::RuntimeError));
1112    }
1113
1114    #[test]
1115    fn spawn_is_sandbox_violation() {
1116        let err = fold("const Z = spawn { 1 }").unwrap_err();
1117        assert!(matches!(err.kind, ConstEvalErrorKind::SandboxViolation));
1118    }
1119
1120    #[test]
1121    fn user_function_call_is_sandboxed() {
1122        let err = fold("const Z = some_user_fn()").unwrap_err();
1123        assert!(matches!(err.kind, ConstEvalErrorKind::SandboxViolation));
1124    }
1125
1126    #[test]
1127    fn ternary_picks_branch() {
1128        assert_eq!(fold("const T = true ? 1 : 2").unwrap(), ConstValue::Int(1));
1129        assert_eq!(fold("const T = false ? 1 : 2").unwrap(), ConstValue::Int(2));
1130    }
1131
1132    #[test]
1133    fn list_subscript_folds() {
1134        assert_eq!(
1135            fold("const N = [10, 20, 30][1]").unwrap(),
1136            ConstValue::Int(20)
1137        );
1138    }
1139
1140    #[test]
1141    fn list_subscript_out_of_bounds_is_runtime_error() {
1142        let err = fold("const N = [1, 2][9]").unwrap_err();
1143        assert!(matches!(err.kind, ConstEvalErrorKind::RuntimeError));
1144    }
1145
1146    #[test]
1147    fn recursion_depth_is_bounded() {
1148        // Drive the depth guard directly without building a deep AST —
1149        // a deep `Box<SNode>` chain would stack-overflow Rust's default
1150        // recursive `Drop` long before the const-evaluator's own guard
1151        // tripped, which masked the unit being tested. The same guard
1152        // fires from production code paths regardless of how the depth
1153        // is reached.
1154        let env = ConstEnv::new();
1155        let mut ctx = EvalCtx {
1156            env: &env,
1157            steps: 0,
1158            depth: MAX_DEPTH,
1159        };
1160        let err = ctx.enter(Span::dummy()).unwrap_err();
1161        assert!(matches!(err.kind, ConstEvalErrorKind::RecursionLimit));
1162        // Guard cleanup: enter() must not have left the depth counter
1163        // bumped past the cap on the error path (otherwise repeated
1164        // tripping would saturate the counter and starve future calls).
1165        assert_eq!(ctx.depth, MAX_DEPTH);
1166    }
1167
1168    #[test]
1169    fn step_budget_is_bounded() {
1170        // The step counter is checked on every `eval_node` call, not
1171        // amortized — flip the counter near the cap and confirm the
1172        // very next reduction trips. Driving the counter directly keeps
1173        // the test fast and avoids constructing an AST large enough to
1174        // approach the 100k-step budget.
1175        let env = ConstEnv::new();
1176        let mut ctx = EvalCtx {
1177            env: &env,
1178            steps: MAX_STEPS,
1179            depth: 0,
1180        };
1181        let err = ctx.step(Span::dummy()).unwrap_err();
1182        assert!(matches!(err.kind, ConstEvalErrorKind::StepLimit));
1183    }
1184
1185    #[test]
1186    fn step_counter_is_not_amortized() {
1187        // A defensive end-to-end check: cap steps to a small fixed
1188        // budget via a hand-built ConstBinding-shaped expression and
1189        // observe that the very next call fails. The intent is to
1190        // prevent an accidental refactor that batches the cap check
1191        // (e.g. only every N steps).
1192        let env = ConstEnv::new();
1193        // Five back-to-back literal lookups consume exactly five steps.
1194        // Pre-loading the counter to MAX_STEPS - 4 then folding a
1195        // 5-step expression must trip exactly once.
1196        let mut ctx = EvalCtx {
1197            env: &env,
1198            steps: MAX_STEPS - 4,
1199            depth: 0,
1200        };
1201        let span = Span::dummy();
1202        for _ in 0..4 {
1203            ctx.step(span).expect("inside budget");
1204        }
1205        let err = ctx.step(span).unwrap_err();
1206        assert!(matches!(err.kind, ConstEvalErrorKind::StepLimit));
1207    }
1208}