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