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