1use std::collections::HashMap;
41
42use harn_lexer::{Span, StringSegment};
43
44use crate::ast::{DictEntry, Node, SNode};
45
46pub const MAX_STEPS: u32 = 100_000;
49
50pub const MAX_DEPTH: u32 = 256;
53
54#[derive(Debug, Clone, PartialEq)]
63pub enum ConstValue {
64 Int(i64),
65 Float(f64),
66 Bool(bool),
67 String(String),
68 List(Vec<ConstValue>),
69 Dict(Vec<(String, ConstValue)>),
70 Nil,
71}
72
73impl ConstValue {
74 pub fn display(&self) -> String {
78 match self {
79 ConstValue::Int(n) => n.to_string(),
80 ConstValue::Float(f) => format_float(*f),
81 ConstValue::Bool(b) => b.to_string(),
82 ConstValue::String(s) => s.clone(),
83 ConstValue::Nil => "nil".to_string(),
84 ConstValue::List(items) => {
85 let parts: Vec<String> = items.iter().map(|v| v.display()).collect();
86 format!("[{}]", parts.join(", "))
87 }
88 ConstValue::Dict(entries) => {
89 let parts: Vec<String> = entries
90 .iter()
91 .map(|(k, v)| format!("{k}: {}", v.display()))
92 .collect();
93 format!("{{{}}}", parts.join(", "))
94 }
95 }
96 }
97}
98
99fn format_float(f: f64) -> String {
100 if f.fract() == 0.0 && f.is_finite() {
101 format!("{f:.1}")
102 } else {
103 format!("{f}")
104 }
105}
106
107#[derive(Debug, Clone, PartialEq, Eq)]
115pub enum ConstEvalErrorKind {
116 Disallowed,
118 StepLimit,
120 RecursionLimit,
122 SandboxViolation,
125 RuntimeError,
128}
129
130#[derive(Debug, Clone)]
134pub struct ConstEvalError {
135 pub kind: ConstEvalErrorKind,
136 pub span: Span,
137 pub detail: String,
138}
139
140impl ConstEvalError {
141 fn disallowed(span: Span, detail: impl Into<String>) -> Self {
142 Self {
143 kind: ConstEvalErrorKind::Disallowed,
144 span,
145 detail: detail.into(),
146 }
147 }
148
149 fn sandbox(span: Span, detail: impl Into<String>) -> Self {
150 Self {
151 kind: ConstEvalErrorKind::SandboxViolation,
152 span,
153 detail: detail.into(),
154 }
155 }
156
157 fn runtime(span: Span, detail: impl Into<String>) -> Self {
158 Self {
159 kind: ConstEvalErrorKind::RuntimeError,
160 span,
161 detail: detail.into(),
162 }
163 }
164
165 fn step_limit(span: Span) -> Self {
166 Self {
167 kind: ConstEvalErrorKind::StepLimit,
168 span,
169 detail: format!("const-eval exceeded the {MAX_STEPS}-step budget"),
170 }
171 }
172
173 fn recursion_limit(span: Span) -> Self {
174 Self {
175 kind: ConstEvalErrorKind::RecursionLimit,
176 span,
177 detail: format!("const-eval exceeded the {MAX_DEPTH}-deep recursion budget"),
178 }
179 }
180}
181
182const SANDBOXED_OBJECT_ROOTS: &[&str] = &[
188 "harness",
189 "host",
190 "transcript",
191 "registry",
192 "process",
193 "fs",
194 "net",
195 "env",
196 "stdio",
197 "log",
198 "agent",
199 "session",
200];
201
202const PURE_BUILTINS: &[&str] = &[
207 "len",
208 "format",
209 "min",
210 "max",
211 "abs",
212 "floor",
213 "ceil",
214 "round",
215 "lowercase",
216 "uppercase",
217 "trim",
218 "concat",
219 "join",
220];
221
222const PURE_BINARY_OPS: &[&str] = &[
226 "+", "-", "*", "/", "%", "**", "==", "!=", "<", ">", "<=", ">=", "&&", "||", "??",
227];
228
229pub type ConstEnv = HashMap<String, ConstValue>;
233
234pub fn const_eval(node: &SNode, env: &ConstEnv) -> Result<ConstValue, ConstEvalError> {
238 const_eval_with_resolver(node, &|name| env.get(name).cloned())
239}
240
241pub(crate) fn const_eval_with_resolver(
243 node: &SNode,
244 resolve: &dyn Fn(&str) -> Option<ConstValue>,
245) -> Result<ConstValue, ConstEvalError> {
246 let mut ctx = EvalCtx {
247 resolve,
248 steps: 0,
249 depth: 0,
250 };
251 ctx.eval_node(node)
252}
253
254struct EvalCtx<'a> {
255 resolve: &'a dyn Fn(&str) -> Option<ConstValue>,
256 steps: u32,
257 depth: u32,
258}
259
260impl<'a> EvalCtx<'a> {
261 fn step(&mut self, span: Span) -> Result<(), ConstEvalError> {
262 self.steps = self.steps.saturating_add(1);
263 if self.steps > MAX_STEPS {
264 return Err(ConstEvalError::step_limit(span));
265 }
266 Ok(())
267 }
268
269 fn enter(&mut self, span: Span) -> Result<(), ConstEvalError> {
270 self.depth = self.depth.saturating_add(1);
271 if self.depth > MAX_DEPTH {
272 self.depth -= 1;
273 return Err(ConstEvalError::recursion_limit(span));
274 }
275 Ok(())
276 }
277
278 fn leave(&mut self) {
279 self.depth = self.depth.saturating_sub(1);
280 }
281
282 fn eval_node(&mut self, node: &SNode) -> Result<ConstValue, ConstEvalError> {
283 self.step(node.span)?;
284 self.enter(node.span)?;
285 let result = self.eval_node_inner(node);
286 self.leave();
287 result
288 }
289
290 fn eval_node_inner(&mut self, node: &SNode) -> Result<ConstValue, ConstEvalError> {
291 let ctx = self;
292 match &node.node {
293 Node::IntLiteral(n) => Ok(ConstValue::Int(*n)),
294 Node::FloatLiteral(f) => Ok(ConstValue::Float(*f)),
295 Node::BoolLiteral(b) => Ok(ConstValue::Bool(*b)),
296 Node::StringLiteral(s) | Node::RawStringLiteral(s) => Ok(ConstValue::String(s.clone())),
297 Node::NilLiteral => Ok(ConstValue::Nil),
298
299 Node::Identifier(name) => (ctx.resolve)(name).ok_or_else(|| {
300 ConstEvalError::runtime(
301 node.span,
302 format!("`{name}` is not a const-known identifier"),
303 )
304 }),
305
306 Node::ListLiteral(items) => {
307 let mut out = Vec::with_capacity(items.len());
308 for item in items {
309 if matches!(&item.node, Node::Spread(_)) {
310 return Err(ConstEvalError::disallowed(
311 item.span,
312 "spread in a const list literal is not supported",
313 ));
314 }
315 out.push(ctx.eval_node(item)?);
316 }
317 Ok(ConstValue::List(out))
318 }
319
320 Node::DictLiteral(entries) => {
321 let mut out: Vec<(String, ConstValue)> = Vec::with_capacity(entries.len());
322 for entry in entries {
323 let key = ctx.dict_key_name(entry)?;
324 let value = ctx.eval_node(&entry.value)?;
325 out.push((key, value));
326 }
327 Ok(ConstValue::Dict(out))
328 }
329
330 Node::InterpolatedString(segments) => {
331 let mut buf = String::new();
332 for seg in segments {
333 match seg {
334 StringSegment::Literal(lit) => buf.push_str(lit),
335 StringSegment::Expression(src, _, _) => {
336 return Err(ConstEvalError::disallowed(
346 node.span,
347 format!("interpolated expression `${{{src}}}` is not supported in a const initializer; use `format(...)` or string concatenation"),
348 ));
349 }
350 }
351 }
352 Ok(ConstValue::String(buf))
353 }
354
355 Node::UnaryOp { op, operand } => {
356 let value = ctx.eval_node(operand)?;
357 match (op.as_str(), &value) {
358 ("-", ConstValue::Int(n)) => {
359 Ok(ConstValue::Int(n.checked_neg().ok_or_else(|| {
360 ConstEvalError::runtime(node.span, "integer overflow in unary minus")
361 })?))
362 }
363 ("-", ConstValue::Float(f)) => Ok(ConstValue::Float(-f)),
364 ("!", ConstValue::Bool(b)) => Ok(ConstValue::Bool(!b)),
365 _ => Err(ConstEvalError::runtime(
366 node.span,
367 format!("unary `{op}` is not defined for the operand"),
368 )),
369 }
370 }
371
372 Node::BinaryOp { op, left, right } => {
373 if !PURE_BINARY_OPS.contains(&op.as_str()) {
374 return Err(ConstEvalError::disallowed(
375 node.span,
376 format!("binary operator `{op}` is not const-evaluable"),
377 ));
378 }
379 let lhs = ctx.eval_node(left)?;
380 let rhs = ctx.eval_node(right)?;
381 ctx.apply_binary(op, lhs, rhs, node.span)
382 }
383
384 Node::Ternary {
385 condition,
386 true_expr,
387 false_expr,
388 } => {
389 let cond = ctx.eval_node(condition)?;
390 let pick = match cond {
391 ConstValue::Bool(b) => b,
392 _ => {
393 return Err(ConstEvalError::runtime(
394 condition.span,
395 "ternary condition must fold to a bool",
396 ))
397 }
398 };
399 if pick {
400 ctx.eval_node(true_expr)
401 } else {
402 ctx.eval_node(false_expr)
403 }
404 }
405
406 Node::IfElse {
407 condition,
408 then_body,
409 else_body,
410 ..
411 } => {
412 let cond = ctx.eval_node(condition)?;
413 let pick = match cond {
414 ConstValue::Bool(b) => b,
415 _ => {
416 return Err(ConstEvalError::runtime(
417 condition.span,
418 "if-expression condition must fold to a bool",
419 ))
420 }
421 };
422 let branch =
423 if pick {
424 then_body.as_slice()
425 } else {
426 match else_body {
427 Some(body) => body.as_slice(),
428 None => return Err(ConstEvalError::disallowed(
429 node.span,
430 "if-expression without an else branch cannot be const-evaluated",
431 )),
432 }
433 };
434 let Some(last) = branch.last() else {
435 return Err(ConstEvalError::disallowed(
436 node.span,
437 "if-expression branch must produce a value",
438 ));
439 };
440 if let Some(first_pre) = branch[..branch.len().saturating_sub(1)].first() {
441 return Err(ConstEvalError::disallowed(
446 first_pre.span,
447 "multi-statement if-branch is not const-evaluable",
448 ));
449 }
450 ctx.eval_node(last)
451 }
452
453 Node::FunctionCall { name, args, .. } => {
454 if !PURE_BUILTINS.contains(&name.as_str()) {
455 return Err(ConstEvalError::sandbox(
456 node.span,
457 format!(
458 "`{name}(...)` is not on the const-eval allowlist (only pure stdlib builtins may be called from a const initializer)"
459 ),
460 ));
461 }
462 let mut folded = Vec::with_capacity(args.len());
463 for arg in args {
464 folded.push(ctx.eval_node(arg)?);
465 }
466 ctx.apply_builtin(name, folded, node.span)
467 }
468
469 Node::PropertyAccess { object, .. } | Node::OptionalPropertyAccess { object, .. } => {
475 if let Node::Identifier(root) = &object.node {
476 if SANDBOXED_OBJECT_ROOTS.contains(&root.as_str()) {
477 return Err(ConstEvalError::sandbox(
478 node.span,
479 format!(
480 "`{root}.*` is a sandboxed capability surface; const-eval refuses fs/net/env/process/host access"
481 ),
482 ));
483 }
484 }
485 Err(ConstEvalError::disallowed(
486 node.span,
487 "property access is not const-evaluable",
488 ))
489 }
490 Node::MethodCall { object, .. } | Node::OptionalMethodCall { object, .. } => {
491 if let Some(root) = leftmost_receiver_identifier(object) {
500 if SANDBOXED_OBJECT_ROOTS.contains(&root) {
501 return Err(ConstEvalError::sandbox(
502 node.span,
503 format!(
504 "`{root}.*(...)` is a sandboxed capability surface; const-eval refuses fs/net/env/process/host access"
505 ),
506 ));
507 }
508 }
509 Err(ConstEvalError::disallowed(
510 node.span,
511 "method call is not const-evaluable",
512 ))
513 }
514 Node::SubscriptAccess { object, index } => {
515 let recv = ctx.eval_node(object)?;
516 let idx = ctx.eval_node(index)?;
517 match (recv, idx) {
518 (ConstValue::List(items), ConstValue::Int(i)) => {
519 items.get(i as usize).cloned().ok_or_else(|| {
520 ConstEvalError::runtime(node.span, format!("index {i} out of bounds"))
521 })
522 }
523 (ConstValue::Dict(entries), ConstValue::String(k)) => entries
524 .into_iter()
525 .find(|(name, _)| *name == k)
526 .map(|(_, v)| v)
527 .ok_or_else(|| {
528 ConstEvalError::runtime(node.span, format!("unknown key `{k}`"))
529 }),
530 _ => Err(ConstEvalError::runtime(
531 node.span,
532 "subscript receiver and index types are incompatible",
533 )),
534 }
535 }
536 Node::Block(_) => Err(ConstEvalError::disallowed(
537 node.span,
538 "block expression is not const-evaluable",
539 )),
540 Node::Closure { .. } => Err(ConstEvalError::disallowed(
541 node.span,
542 "closure is not const-evaluable",
543 )),
544
545 Node::SpawnExpr { .. }
547 | Node::SelectExpr { .. }
548 | Node::Parallel { .. }
549 | Node::MutexBlock { .. }
550 | Node::DeferStmt { .. }
551 | Node::YieldExpr { .. }
552 | Node::EmitExpr { .. }
553 | Node::TryCatch { .. }
554 | Node::TryExpr { .. }
555 | Node::TryOperator { .. }
556 | Node::TryStar { .. }
557 | Node::DeadlineBlock { .. }
558 | Node::CostRoute { .. }
559 | Node::WhileLoop { .. }
560 | Node::ForIn { .. }
561 | Node::Retry { .. }
562 | Node::GuardStmt { .. }
563 | Node::RequireStmt { .. }
564 | Node::Assignment { .. }
565 | Node::ThrowStmt { .. }
566 | Node::ReturnStmt { .. }
567 | Node::BreakStmt
568 | Node::ContinueStmt => Err(ConstEvalError::sandbox(
569 node.span,
570 "runtime construct is not permitted in a const initializer",
571 )),
572
573 _ => Err(ConstEvalError::disallowed(
575 node.span,
576 "expression shape is not on the const-eval allowlist",
577 )),
578 }
579 }
580
581 fn dict_key_name(&self, entry: &DictEntry) -> Result<String, ConstEvalError> {
582 match &entry.key.node {
583 Node::Identifier(name) => Ok(name.clone()),
584 Node::StringLiteral(s) | Node::RawStringLiteral(s) => Ok(s.clone()),
585 _ => Err(ConstEvalError::disallowed(
586 entry.key.span,
587 "dict keys in a const dict literal must be identifiers or string literals",
588 )),
589 }
590 }
591
592 fn apply_binary(
593 &self,
594 op: &str,
595 lhs: ConstValue,
596 rhs: ConstValue,
597 span: Span,
598 ) -> Result<ConstValue, ConstEvalError> {
599 use ConstValue::*;
600
601 if op == "&&" || op == "||" {
603 let (Bool(l), Bool(r)) = (&lhs, &rhs) else {
604 return Err(ConstEvalError::runtime(
605 span,
606 format!("`{op}` requires bool operands"),
607 ));
608 };
609 return Ok(Bool(if op == "&&" { *l && *r } else { *l || *r }));
610 }
611 if op == "??" {
612 return Ok(match lhs {
613 Nil => rhs,
614 other => other,
615 });
616 }
617 if op == "==" {
618 return Ok(Bool(lhs == rhs));
619 }
620 if op == "!=" {
621 return Ok(Bool(lhs != rhs));
622 }
623
624 if op == "+" {
626 if let (String(a), String(b)) = (&lhs, &rhs) {
627 return Ok(String(format!("{a}{b}")));
628 }
629 }
630
631 let (lhs_num, rhs_num) = match (&lhs, &rhs) {
633 (Int(_) | Float(_), Int(_) | Float(_)) => (lhs.clone(), rhs.clone()),
634 _ => {
635 return Err(ConstEvalError::runtime(
636 span,
637 format!(
638 "`{op}` requires numeric operands, got {} and {}",
639 value_kind(&lhs),
640 value_kind(&rhs)
641 ),
642 ))
643 }
644 };
645
646 if matches!(op, "<" | ">" | "<=" | ">=") {
648 let (l, r) = (as_float(&lhs_num), as_float(&rhs_num));
649 let out = match op {
650 "<" => l < r,
651 ">" => l > r,
652 "<=" => l <= r,
653 ">=" => l >= r,
654 _ => unreachable!(),
655 };
656 return Ok(Bool(out));
657 }
658
659 if let (Int(a), Int(b)) = (&lhs_num, &rhs_num) {
661 let result = match op {
662 "+" => a.checked_add(*b),
663 "-" => a.checked_sub(*b),
664 "*" => a.checked_mul(*b),
665 "/" => {
666 if *b == 0 {
667 return Err(ConstEvalError::runtime(span, "division by zero"));
668 }
669 a.checked_div(*b)
670 }
671 "%" => {
672 if *b == 0 {
673 return Err(ConstEvalError::runtime(span, "modulo by zero"));
674 }
675 a.checked_rem(*b)
676 }
677 "**" => {
678 if *b < 0 || *b > u32::MAX as i64 {
679 return Err(ConstEvalError::runtime(
680 span,
681 "exponent must be a non-negative i64 within u32 range",
682 ));
683 }
684 a.checked_pow(*b as u32)
685 }
686 _ => unreachable!(),
687 };
688 return result
689 .map(Int)
690 .ok_or_else(|| ConstEvalError::runtime(span, "integer overflow"));
691 }
692
693 let (l, r) = (as_float(&lhs_num), as_float(&rhs_num));
694 let value = match op {
695 "+" => l + r,
696 "-" => l - r,
697 "*" => l * r,
698 "/" => {
699 if r == 0.0 {
700 return Err(ConstEvalError::runtime(span, "division by zero"));
701 }
702 l / r
703 }
704 "%" => {
705 if r == 0.0 {
706 return Err(ConstEvalError::runtime(span, "modulo by zero"));
707 }
708 l % r
709 }
710 "**" => l.powf(r),
711 _ => unreachable!(),
712 };
713 Ok(Float(value))
714 }
715
716 fn apply_builtin(
717 &self,
718 name: &str,
719 args: Vec<ConstValue>,
720 span: Span,
721 ) -> Result<ConstValue, ConstEvalError> {
722 match name {
723 "len" => match args.as_slice() {
724 [ConstValue::String(s)] => Ok(ConstValue::Int(s.chars().count() as i64)),
725 [ConstValue::List(items)] => Ok(ConstValue::Int(items.len() as i64)),
726 [ConstValue::Dict(entries)] => Ok(ConstValue::Int(entries.len() as i64)),
727 _ => Err(ConstEvalError::runtime(
728 span,
729 "len() expects a single string / list / dict argument",
730 )),
731 },
732 "format" => format_call(span, args),
733 "concat" => {
734 let mut out = String::new();
735 for arg in &args {
736 match arg {
737 ConstValue::String(s) => out.push_str(s),
738 _ => {
739 return Err(ConstEvalError::runtime(
740 span,
741 "concat() expects string arguments",
742 ))
743 }
744 }
745 }
746 Ok(ConstValue::String(out))
747 }
748 "join" => match args.as_slice() {
749 [ConstValue::List(items), ConstValue::String(sep)] => {
750 let mut parts = Vec::with_capacity(items.len());
751 for item in items {
752 match item {
753 ConstValue::String(s) => parts.push(s.clone()),
754 other => parts.push(other.display()),
755 }
756 }
757 Ok(ConstValue::String(parts.join(sep)))
758 }
759 _ => Err(ConstEvalError::runtime(
760 span,
761 "join() expects (list, string)",
762 )),
763 },
764 "min" | "max" => apply_min_max(name, &args, span),
765 "abs" => match args.as_slice() {
766 [ConstValue::Int(n)] => {
767 Ok(ConstValue::Int(n.checked_abs().ok_or_else(|| {
768 ConstEvalError::runtime(span, "integer overflow in abs()")
769 })?))
770 }
771 [ConstValue::Float(f)] => Ok(ConstValue::Float(f.abs())),
772 _ => Err(ConstEvalError::runtime(
773 span,
774 "abs() expects a single numeric argument",
775 )),
776 },
777 "floor" => unary_float(span, &args, |f| f.floor()),
778 "ceil" => unary_float(span, &args, |f| f.ceil()),
779 "round" => match args.as_slice() {
780 [ConstValue::Float(f), ConstValue::Int(digits)] => {
784 Ok(ConstValue::Float(round_float_to_digits(*f, *digits)))
785 }
786 [ConstValue::Int(n), ConstValue::Int(digits)] => {
787 Ok(round_int_to_digits(*n, *digits))
788 }
789 _ => unary_float(span, &args, |f| f.round()),
790 },
791 "lowercase" => match args.as_slice() {
792 [ConstValue::String(s)] => Ok(ConstValue::String(s.to_lowercase())),
793 _ => Err(ConstEvalError::runtime(
794 span,
795 "lowercase() expects a string",
796 )),
797 },
798 "uppercase" => match args.as_slice() {
799 [ConstValue::String(s)] => Ok(ConstValue::String(s.to_uppercase())),
800 _ => Err(ConstEvalError::runtime(
801 span,
802 "uppercase() expects a string",
803 )),
804 },
805 "trim" => match args.as_slice() {
806 [ConstValue::String(s)] => Ok(ConstValue::String(s.trim().to_string())),
807 _ => Err(ConstEvalError::runtime(span, "trim() expects a string")),
808 },
809 _ => Err(ConstEvalError::sandbox(
814 span,
815 format!("`{name}(...)` lacks a const-eval implementation"),
816 )),
817 }
818 }
819}
820
821fn leftmost_receiver_identifier(node: &SNode) -> Option<&str> {
825 let mut current = node;
826 loop {
827 match ¤t.node {
828 Node::Identifier(name) => return Some(name.as_str()),
829 Node::PropertyAccess { object, .. }
830 | Node::OptionalPropertyAccess { object, .. }
831 | Node::SubscriptAccess { object, .. }
832 | Node::OptionalSubscriptAccess { object, .. } => {
833 current = object;
834 }
835 _ => return None,
836 }
837 }
838}
839
840fn value_kind(v: &ConstValue) -> &'static str {
841 match v {
842 ConstValue::Int(_) => "int",
843 ConstValue::Float(_) => "float",
844 ConstValue::Bool(_) => "bool",
845 ConstValue::String(_) => "string",
846 ConstValue::List(_) => "list",
847 ConstValue::Dict(_) => "dict",
848 ConstValue::Nil => "nil",
849 }
850}
851
852fn as_float(v: &ConstValue) -> f64 {
853 match v {
854 ConstValue::Int(n) => *n as f64,
855 ConstValue::Float(f) => *f,
856 _ => 0.0,
857 }
858}
859
860fn format_call(span: Span, args: Vec<ConstValue>) -> Result<ConstValue, ConstEvalError> {
861 let mut iter = args.into_iter();
862 let template = match iter.next() {
863 Some(ConstValue::String(s)) => s,
864 Some(_) => {
865 return Err(ConstEvalError::runtime(
866 span,
867 "format() template must be a string literal",
868 ))
869 }
870 None => {
871 return Err(ConstEvalError::runtime(
872 span,
873 "format() requires at least a template argument",
874 ))
875 }
876 };
877 let rest: Vec<ConstValue> = iter.collect();
878
879 if let [ConstValue::Dict(entries)] = rest.as_slice() {
882 let mut result = String::with_capacity(template.len());
883 let mut rest_str = template.as_str();
884 while let Some((head, after_open)) = rest_str.split_once('{') {
885 result.push_str(head);
886 if let Some((key, after_close)) = after_open.split_once('}') {
887 if let Some((_, val)) = entries.iter().find(|(k, _)| k == key) {
888 result.push_str(&val.display());
889 } else {
890 result.push('{');
891 result.push_str(key);
892 result.push('}');
893 }
894 rest_str = after_close;
895 } else {
896 result.push('{');
897 result.push_str(after_open);
898 rest_str = "";
899 break;
900 }
901 }
902 result.push_str(rest_str);
903 return Ok(ConstValue::String(result));
904 }
905
906 let mut result = String::with_capacity(template.len());
907 let mut rest_iter = rest.iter();
908 let mut tail = template.as_str();
909 while let Some((head, rest_of_template)) = tail.split_once("{}") {
910 result.push_str(head);
911 if let Some(arg) = rest_iter.next() {
912 result.push_str(&arg.display());
913 } else {
914 result.push_str("{}");
915 }
916 tail = rest_of_template;
917 }
918 result.push_str(tail);
919 Ok(ConstValue::String(result))
920}
921
922fn apply_min_max(
923 name: &str,
924 args: &[ConstValue],
925 span: Span,
926) -> Result<ConstValue, ConstEvalError> {
927 if args.is_empty() {
928 return Err(ConstEvalError::runtime(
929 span,
930 format!("{name}() requires at least one argument"),
931 ));
932 }
933 let mut all_int = true;
934 for arg in args {
935 match arg {
936 ConstValue::Int(_) => {}
937 ConstValue::Float(_) => all_int = false,
938 _ => {
939 return Err(ConstEvalError::runtime(
940 span,
941 format!("{name}() expects numeric arguments"),
942 ))
943 }
944 }
945 }
946 if all_int {
947 let nums: Vec<i64> = args
948 .iter()
949 .map(|v| match v {
950 ConstValue::Int(n) => *n,
951 _ => unreachable!(),
952 })
953 .collect();
954 let pick = if name == "min" {
955 nums.iter().copied().min().unwrap()
956 } else {
957 nums.iter().copied().max().unwrap()
958 };
959 Ok(ConstValue::Int(pick))
960 } else {
961 let nums: Vec<f64> = args.iter().map(as_float).collect();
962 let pick = if name == "min" {
963 nums.iter().copied().fold(f64::INFINITY, f64::min)
964 } else {
965 nums.iter().copied().fold(f64::NEG_INFINITY, f64::max)
966 };
967 Ok(ConstValue::Float(pick))
968 }
969}
970
971fn round_float_to_digits(x: f64, digits: i64) -> f64 {
975 if !x.is_finite() {
976 return x;
977 }
978 if digits == 0 {
979 return x.round();
980 }
981 if digits > 308 {
982 return x;
983 }
984 if digits < -308 {
985 return 0.0 * x.signum();
986 }
987 let factor = 10f64.powi(digits as i32);
988 let scaled = x * factor;
989 if !scaled.is_finite() {
990 return x;
991 }
992 scaled.round() / factor
993}
994
995fn round_int_to_digits(n: i64, digits: i64) -> ConstValue {
1000 if digits >= 0 || n == 0 {
1001 return ConstValue::Int(n);
1002 }
1003 if digits <= -19 {
1004 return ConstValue::Int(0);
1005 }
1006 let factor = 10i128.pow((-digits) as u32);
1007 let n128 = n as i128;
1008 let rem = n128 % factor;
1009 let base = n128 - rem;
1010 let rounded = if rem.abs() * 2 >= factor {
1011 base + factor * n128.signum()
1012 } else {
1013 base
1014 };
1015 match i64::try_from(rounded) {
1016 Ok(v) => ConstValue::Int(v),
1017 Err(_) => ConstValue::Float(rounded as f64),
1018 }
1019}
1020
1021fn unary_float(
1022 span: Span,
1023 args: &[ConstValue],
1024 op: impl Fn(f64) -> f64,
1025) -> Result<ConstValue, ConstEvalError> {
1026 match args {
1027 [ConstValue::Int(n)] => Ok(ConstValue::Float(op(*n as f64))),
1028 [ConstValue::Float(f)] => Ok(ConstValue::Float(op(*f))),
1029 _ => Err(ConstEvalError::runtime(
1030 span,
1031 "expected a single numeric argument",
1032 )),
1033 }
1034}
1035
1036#[cfg(test)]
1037mod tests {
1038 use super::*;
1039 use crate::parse_source;
1040
1041 fn fold(source: &str) -> Result<ConstValue, ConstEvalError> {
1042 let program = parse_source(source).expect("parse");
1046 let mut env = ConstEnv::new();
1047 let mut last = None;
1048 for snode in &program {
1049 if let Node::ConstBinding {
1050 pattern: crate::ast::BindingPattern::Identifier(name),
1051 value,
1052 ..
1053 } = &snode.node
1054 {
1055 let folded = const_eval(value, &env)?;
1056 env.insert(name.clone(), folded.clone());
1057 last = Some(folded);
1058 }
1059 }
1060 Ok(last.expect("no const binding in source"))
1061 }
1062
1063 #[test]
1064 fn arithmetic_literals_fold() {
1065 assert_eq!(fold("const X = 1 + 2").unwrap(), ConstValue::Int(3));
1066 assert_eq!(fold("const Y = 5 * (3 + 2)").unwrap(), ConstValue::Int(25));
1067 assert_eq!(fold("const Z = 2 ** 10").unwrap(), ConstValue::Int(1024));
1068 }
1069
1070 #[test]
1071 fn string_concat_folds() {
1072 assert_eq!(
1073 fold(r#"const S = "foo" + "-" + "bar""#).unwrap(),
1074 ConstValue::String("foo-bar".to_string())
1075 );
1076 }
1077
1078 #[test]
1079 fn earlier_const_visible_to_later() {
1080 let src = "const A = 10\nconst B = A * 2";
1081 assert_eq!(fold(src).unwrap(), ConstValue::Int(20));
1082 }
1083
1084 #[test]
1085 fn len_of_literal_list() {
1086 assert_eq!(
1087 fold("const N = len([1, 2, 3, 4])").unwrap(),
1088 ConstValue::Int(4)
1089 );
1090 }
1091
1092 #[test]
1093 fn format_positional_placeholders() {
1094 let src = r#"const G = format("{}-{}", "hello", 42)"#;
1095 assert_eq!(
1096 fold(src).unwrap(),
1097 ConstValue::String("hello-42".to_string())
1098 );
1099 }
1100
1101 #[test]
1102 fn host_property_access_is_sandboxed() {
1103 let err = fold("const Z = harness.clock.now()").unwrap_err();
1104 assert!(matches!(
1105 err.kind,
1106 ConstEvalErrorKind::SandboxViolation | ConstEvalErrorKind::Disallowed
1107 ));
1108 }
1109
1110 #[test]
1111 fn division_by_zero_is_runtime_error() {
1112 let err = fold("const Z = 1 / 0").unwrap_err();
1113 assert!(matches!(err.kind, ConstEvalErrorKind::RuntimeError));
1114 }
1115
1116 #[test]
1117 fn unknown_identifier_is_runtime_error() {
1118 let err = fold("const Z = NOPE + 1").unwrap_err();
1119 assert!(matches!(err.kind, ConstEvalErrorKind::RuntimeError));
1120 }
1121
1122 #[test]
1123 fn spawn_is_sandbox_violation() {
1124 let err = fold("const Z = spawn { 1 }").unwrap_err();
1125 assert!(matches!(err.kind, ConstEvalErrorKind::SandboxViolation));
1126 }
1127
1128 #[test]
1129 fn user_function_call_is_sandboxed() {
1130 let err = fold("const Z = some_user_fn()").unwrap_err();
1131 assert!(matches!(err.kind, ConstEvalErrorKind::SandboxViolation));
1132 }
1133
1134 #[test]
1135 fn ternary_picks_branch() {
1136 assert_eq!(fold("const T = true ? 1 : 2").unwrap(), ConstValue::Int(1));
1137 assert_eq!(fold("const T = false ? 1 : 2").unwrap(), ConstValue::Int(2));
1138 }
1139
1140 #[test]
1141 fn list_subscript_folds() {
1142 assert_eq!(
1143 fold("const N = [10, 20, 30][1]").unwrap(),
1144 ConstValue::Int(20)
1145 );
1146 }
1147
1148 #[test]
1149 fn list_subscript_out_of_bounds_is_runtime_error() {
1150 let err = fold("const N = [1, 2][9]").unwrap_err();
1151 assert!(matches!(err.kind, ConstEvalErrorKind::RuntimeError));
1152 }
1153
1154 #[test]
1155 fn recursion_depth_is_bounded() {
1156 let env = ConstEnv::new();
1163 let mut ctx = EvalCtx {
1164 resolve: &|name| env.get(name).cloned(),
1165 steps: 0,
1166 depth: MAX_DEPTH,
1167 };
1168 let err = ctx.enter(Span::dummy()).unwrap_err();
1169 assert!(matches!(err.kind, ConstEvalErrorKind::RecursionLimit));
1170 assert_eq!(ctx.depth, MAX_DEPTH);
1174 }
1175
1176 #[test]
1177 fn step_budget_is_bounded() {
1178 let env = ConstEnv::new();
1184 let mut ctx = EvalCtx {
1185 resolve: &|name| env.get(name).cloned(),
1186 steps: MAX_STEPS,
1187 depth: 0,
1188 };
1189 let err = ctx.step(Span::dummy()).unwrap_err();
1190 assert!(matches!(err.kind, ConstEvalErrorKind::StepLimit));
1191 }
1192
1193 #[test]
1194 fn step_counter_is_not_amortized() {
1195 let env = ConstEnv::new();
1201 let mut ctx = EvalCtx {
1205 resolve: &|name| env.get(name).cloned(),
1206 steps: MAX_STEPS - 4,
1207 depth: 0,
1208 };
1209 let span = Span::dummy();
1210 for _ in 0..4 {
1211 ctx.step(span).expect("inside budget");
1212 }
1213 let err = ctx.step(span).unwrap_err();
1214 assert!(matches!(err.kind, ConstEvalErrorKind::StepLimit));
1215 }
1216}