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