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" => match args.as_slice() {
774                // 2-arg form mirrors the runtime builtin: round to `digits`
775                // decimal places (half away from zero); negative digits round
776                // to power-of-ten buckets; ints stay ints when they fit.
777                [ConstValue::Float(f), ConstValue::Int(digits)] => {
778                    Ok(ConstValue::Float(round_float_to_digits(*f, *digits)))
779                }
780                [ConstValue::Int(n), ConstValue::Int(digits)] => {
781                    Ok(round_int_to_digits(*n, *digits))
782                }
783                _ => unary_float(span, &args, |f| f.round()),
784            },
785            "lowercase" => match args.as_slice() {
786                [ConstValue::String(s)] => Ok(ConstValue::String(s.to_lowercase())),
787                _ => Err(ConstEvalError::runtime(
788                    span,
789                    "lowercase() expects a string",
790                )),
791            },
792            "uppercase" => match args.as_slice() {
793                [ConstValue::String(s)] => Ok(ConstValue::String(s.to_uppercase())),
794                _ => Err(ConstEvalError::runtime(
795                    span,
796                    "uppercase() expects a string",
797                )),
798            },
799            "trim" => match args.as_slice() {
800                [ConstValue::String(s)] => Ok(ConstValue::String(s.trim().to_string())),
801                _ => Err(ConstEvalError::runtime(span, "trim() expects a string")),
802            },
803            // PURE_BUILTINS is the source of truth; if you reach this
804            // arm a name was added to the allowlist without an
805            // implementation here. Treat as a sandbox violation so the
806            // caller still gets an actionable diagnostic.
807            _ => Err(ConstEvalError::sandbox(
808                span,
809                format!("`{name}(...)` lacks a const-eval implementation"),
810            )),
811        }
812    }
813}
814
815/// Walk down a receiver chain (`Identifier` → `PropertyAccess` → … ) and
816/// return the leftmost identifier name. Used by the method-call sandbox
817/// probe so `harness.clock.now()` reports a precise sandbox diagnostic.
818fn leftmost_receiver_identifier(node: &SNode) -> Option<&str> {
819    let mut current = node;
820    loop {
821        match &current.node {
822            Node::Identifier(name) => return Some(name.as_str()),
823            Node::PropertyAccess { object, .. }
824            | Node::OptionalPropertyAccess { object, .. }
825            | Node::SubscriptAccess { object, .. }
826            | Node::OptionalSubscriptAccess { object, .. } => {
827                current = object;
828            }
829            _ => return None,
830        }
831    }
832}
833
834fn value_kind(v: &ConstValue) -> &'static str {
835    match v {
836        ConstValue::Int(_) => "int",
837        ConstValue::Float(_) => "float",
838        ConstValue::Bool(_) => "bool",
839        ConstValue::String(_) => "string",
840        ConstValue::List(_) => "list",
841        ConstValue::Dict(_) => "dict",
842        ConstValue::Nil => "nil",
843    }
844}
845
846fn as_float(v: &ConstValue) -> f64 {
847    match v {
848        ConstValue::Int(n) => *n as f64,
849        ConstValue::Float(f) => *f,
850        _ => 0.0,
851    }
852}
853
854fn format_call(span: Span, args: Vec<ConstValue>) -> Result<ConstValue, ConstEvalError> {
855    let mut iter = args.into_iter();
856    let template = match iter.next() {
857        Some(ConstValue::String(s)) => s,
858        Some(_) => {
859            return Err(ConstEvalError::runtime(
860                span,
861                "format() template must be a string literal",
862            ))
863        }
864        None => {
865            return Err(ConstEvalError::runtime(
866                span,
867                "format() requires at least a template argument",
868            ))
869        }
870    };
871    let rest: Vec<ConstValue> = iter.collect();
872
873    // Mirror runtime semantics: a single dict arg substitutes named
874    // `{key}` placeholders; otherwise positional `{}`.
875    if let [ConstValue::Dict(entries)] = rest.as_slice() {
876        let mut result = String::with_capacity(template.len());
877        let mut rest_str = template.as_str();
878        while let Some(open) = rest_str.find('{') {
879            result.push_str(&rest_str[..open]);
880            if let Some(close) = rest_str[open..].find('}') {
881                let key = &rest_str[open + 1..open + close];
882                if let Some((_, val)) = entries.iter().find(|(k, _)| k == key) {
883                    result.push_str(&val.display());
884                } else {
885                    result.push_str(&rest_str[open..open + close + 1]);
886                }
887                rest_str = &rest_str[open + close + 1..];
888            } else {
889                result.push_str(&rest_str[open..]);
890                rest_str = "";
891                break;
892            }
893        }
894        result.push_str(rest_str);
895        return Ok(ConstValue::String(result));
896    }
897
898    let mut result = String::with_capacity(template.len());
899    let mut rest_iter = rest.iter();
900    let mut tail = template.as_str();
901    while let Some(pos) = tail.find("{}") {
902        result.push_str(&tail[..pos]);
903        if let Some(arg) = rest_iter.next() {
904            result.push_str(&arg.display());
905        } else {
906            result.push_str("{}");
907        }
908        tail = &tail[pos + 2..];
909    }
910    result.push_str(tail);
911    Ok(ConstValue::String(result))
912}
913
914fn apply_min_max(
915    name: &str,
916    args: &[ConstValue],
917    span: Span,
918) -> Result<ConstValue, ConstEvalError> {
919    if args.is_empty() {
920        return Err(ConstEvalError::runtime(
921            span,
922            format!("{name}() requires at least one argument"),
923        ));
924    }
925    let mut all_int = true;
926    for arg in args {
927        match arg {
928            ConstValue::Int(_) => {}
929            ConstValue::Float(_) => all_int = false,
930            _ => {
931                return Err(ConstEvalError::runtime(
932                    span,
933                    format!("{name}() expects numeric arguments"),
934                ))
935            }
936        }
937    }
938    if all_int {
939        let nums: Vec<i64> = args
940            .iter()
941            .map(|v| match v {
942                ConstValue::Int(n) => *n,
943                _ => unreachable!(),
944            })
945            .collect();
946        let pick = if name == "min" {
947            nums.iter().copied().min().unwrap()
948        } else {
949            nums.iter().copied().max().unwrap()
950        };
951        Ok(ConstValue::Int(pick))
952    } else {
953        let nums: Vec<f64> = args.iter().map(as_float).collect();
954        let pick = if name == "min" {
955            nums.iter().copied().fold(f64::INFINITY, f64::min)
956        } else {
957            nums.iter().copied().fold(f64::NEG_INFINITY, f64::max)
958        };
959        Ok(ConstValue::Float(pick))
960    }
961}
962
963/// `round(x, digits)` for floats: half-away-from-zero at the requested
964/// decimal place. Mirrors `round_float_to_digits` in
965/// `crates/harn-vm/src/stdlib/math.rs` so const folding matches runtime.
966fn round_float_to_digits(x: f64, digits: i64) -> f64 {
967    if !x.is_finite() {
968        return x;
969    }
970    if digits == 0 {
971        return x.round();
972    }
973    if digits > 308 {
974        return x;
975    }
976    if digits < -308 {
977        return 0.0 * x.signum();
978    }
979    let factor = 10f64.powi(digits as i32);
980    let scaled = x * factor;
981    if !scaled.is_finite() {
982        return x;
983    }
984    scaled.round() / factor
985}
986
987/// `round(n, digits)` for ints. Mirrors `round_int_to_digits` in
988/// `crates/harn-vm/src/stdlib/math.rs`: identity for `digits >= 0`, negative
989/// digits round to the nearest power-of-ten bucket (halves away from zero),
990/// and an out-of-range result promotes to float.
991fn round_int_to_digits(n: i64, digits: i64) -> ConstValue {
992    if digits >= 0 || n == 0 {
993        return ConstValue::Int(n);
994    }
995    if digits <= -19 {
996        return ConstValue::Int(0);
997    }
998    let factor = 10i128.pow((-digits) as u32);
999    let n128 = n as i128;
1000    let rem = n128 % factor;
1001    let base = n128 - rem;
1002    let rounded = if rem.abs() * 2 >= factor {
1003        base + factor * n128.signum()
1004    } else {
1005        base
1006    };
1007    match i64::try_from(rounded) {
1008        Ok(v) => ConstValue::Int(v),
1009        Err(_) => ConstValue::Float(rounded as f64),
1010    }
1011}
1012
1013fn unary_float(
1014    span: Span,
1015    args: &[ConstValue],
1016    op: impl Fn(f64) -> f64,
1017) -> Result<ConstValue, ConstEvalError> {
1018    match args {
1019        [ConstValue::Int(n)] => Ok(ConstValue::Float(op(*n as f64))),
1020        [ConstValue::Float(f)] => Ok(ConstValue::Float(op(*f))),
1021        _ => Err(ConstEvalError::runtime(
1022            span,
1023            "expected a single numeric argument",
1024        )),
1025    }
1026}
1027
1028#[cfg(test)]
1029mod tests {
1030    use super::*;
1031    use crate::parse_source;
1032
1033    fn fold(source: &str) -> Result<ConstValue, ConstEvalError> {
1034        // Wrap as a const binding so the parser produces our node, then
1035        // extract the right-hand side and run const_eval on it with a
1036        // fresh environment seeded from any earlier const decls.
1037        let program = parse_source(source).expect("parse");
1038        let mut env = ConstEnv::new();
1039        let mut last = None;
1040        for snode in &program {
1041            if let Node::ConstBinding { name, value, .. } = &snode.node {
1042                let folded = const_eval(value, &env)?;
1043                env.insert(name.clone(), folded.clone());
1044                last = Some(folded);
1045            }
1046        }
1047        Ok(last.expect("no const binding in source"))
1048    }
1049
1050    #[test]
1051    fn arithmetic_literals_fold() {
1052        assert_eq!(fold("const X = 1 + 2").unwrap(), ConstValue::Int(3));
1053        assert_eq!(fold("const Y = 5 * (3 + 2)").unwrap(), ConstValue::Int(25));
1054        assert_eq!(fold("const Z = 2 ** 10").unwrap(), ConstValue::Int(1024));
1055    }
1056
1057    #[test]
1058    fn string_concat_folds() {
1059        assert_eq!(
1060            fold(r#"const S = "foo" + "-" + "bar""#).unwrap(),
1061            ConstValue::String("foo-bar".to_string())
1062        );
1063    }
1064
1065    #[test]
1066    fn earlier_const_visible_to_later() {
1067        let src = "const A = 10\nconst B = A * 2";
1068        assert_eq!(fold(src).unwrap(), ConstValue::Int(20));
1069    }
1070
1071    #[test]
1072    fn len_of_literal_list() {
1073        assert_eq!(
1074            fold("const N = len([1, 2, 3, 4])").unwrap(),
1075            ConstValue::Int(4)
1076        );
1077    }
1078
1079    #[test]
1080    fn format_positional_placeholders() {
1081        let src = r#"const G = format("{}-{}", "hello", 42)"#;
1082        assert_eq!(
1083            fold(src).unwrap(),
1084            ConstValue::String("hello-42".to_string())
1085        );
1086    }
1087
1088    #[test]
1089    fn host_property_access_is_sandboxed() {
1090        let err = fold("const Z = harness.clock.now()").unwrap_err();
1091        assert!(matches!(
1092            err.kind,
1093            ConstEvalErrorKind::SandboxViolation | ConstEvalErrorKind::Disallowed
1094        ));
1095    }
1096
1097    #[test]
1098    fn division_by_zero_is_runtime_error() {
1099        let err = fold("const Z = 1 / 0").unwrap_err();
1100        assert!(matches!(err.kind, ConstEvalErrorKind::RuntimeError));
1101    }
1102
1103    #[test]
1104    fn unknown_identifier_is_runtime_error() {
1105        let err = fold("const Z = NOPE + 1").unwrap_err();
1106        assert!(matches!(err.kind, ConstEvalErrorKind::RuntimeError));
1107    }
1108
1109    #[test]
1110    fn spawn_is_sandbox_violation() {
1111        let err = fold("const Z = spawn { 1 }").unwrap_err();
1112        assert!(matches!(err.kind, ConstEvalErrorKind::SandboxViolation));
1113    }
1114
1115    #[test]
1116    fn user_function_call_is_sandboxed() {
1117        let err = fold("const Z = some_user_fn()").unwrap_err();
1118        assert!(matches!(err.kind, ConstEvalErrorKind::SandboxViolation));
1119    }
1120
1121    #[test]
1122    fn ternary_picks_branch() {
1123        assert_eq!(fold("const T = true ? 1 : 2").unwrap(), ConstValue::Int(1));
1124        assert_eq!(fold("const T = false ? 1 : 2").unwrap(), ConstValue::Int(2));
1125    }
1126
1127    #[test]
1128    fn list_subscript_folds() {
1129        assert_eq!(
1130            fold("const N = [10, 20, 30][1]").unwrap(),
1131            ConstValue::Int(20)
1132        );
1133    }
1134
1135    #[test]
1136    fn list_subscript_out_of_bounds_is_runtime_error() {
1137        let err = fold("const N = [1, 2][9]").unwrap_err();
1138        assert!(matches!(err.kind, ConstEvalErrorKind::RuntimeError));
1139    }
1140
1141    #[test]
1142    fn recursion_depth_is_bounded() {
1143        // Drive the depth guard directly without building a deep AST —
1144        // a deep `Box<SNode>` chain would stack-overflow Rust's default
1145        // recursive `Drop` long before the const-evaluator's own guard
1146        // tripped, which masked the unit being tested. The same guard
1147        // fires from production code paths regardless of how the depth
1148        // is reached.
1149        let env = ConstEnv::new();
1150        let mut ctx = EvalCtx {
1151            env: &env,
1152            steps: 0,
1153            depth: MAX_DEPTH,
1154        };
1155        let err = ctx.enter(Span::dummy()).unwrap_err();
1156        assert!(matches!(err.kind, ConstEvalErrorKind::RecursionLimit));
1157        // Guard cleanup: enter() must not have left the depth counter
1158        // bumped past the cap on the error path (otherwise repeated
1159        // tripping would saturate the counter and starve future calls).
1160        assert_eq!(ctx.depth, MAX_DEPTH);
1161    }
1162
1163    #[test]
1164    fn step_budget_is_bounded() {
1165        // The step counter is checked on every `eval_node` call, not
1166        // amortized — flip the counter near the cap and confirm the
1167        // very next reduction trips. Driving the counter directly keeps
1168        // the test fast and avoids constructing an AST large enough to
1169        // approach the 100k-step budget.
1170        let env = ConstEnv::new();
1171        let mut ctx = EvalCtx {
1172            env: &env,
1173            steps: MAX_STEPS,
1174            depth: 0,
1175        };
1176        let err = ctx.step(Span::dummy()).unwrap_err();
1177        assert!(matches!(err.kind, ConstEvalErrorKind::StepLimit));
1178    }
1179
1180    #[test]
1181    fn step_counter_is_not_amortized() {
1182        // A defensive end-to-end check: cap steps to a small fixed
1183        // budget via a hand-built ConstBinding-shaped expression and
1184        // observe that the very next call fails. The intent is to
1185        // prevent an accidental refactor that batches the cap check
1186        // (e.g. only every N steps).
1187        let env = ConstEnv::new();
1188        // Five back-to-back literal lookups consume exactly five steps.
1189        // Pre-loading the counter to MAX_STEPS - 4 then folding a
1190        // 5-step expression must trip exactly once.
1191        let mut ctx = EvalCtx {
1192            env: &env,
1193            steps: MAX_STEPS - 4,
1194            depth: 0,
1195        };
1196        let span = Span::dummy();
1197        for _ in 0..4 {
1198            ctx.step(span).expect("inside budget");
1199        }
1200        let err = ctx.step(span).unwrap_err();
1201        assert!(matches!(err.kind, ConstEvalErrorKind::StepLimit));
1202    }
1203
1204    #[test]
1205    fn evaluator_version_is_exposed() {
1206        // Cache key consumers depend on this being a public stable int.
1207        // The exact value participates in the documented cache key shape
1208        // so just reading it suffices — a renamed constant would fail
1209        // to compile.
1210        let _ = EVAL_VERSION;
1211    }
1212}