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 let mut ctx = EvalCtx {
239 env,
240 steps: 0,
241 depth: 0,
242 };
243 ctx.eval_node(node)
244}
245
246struct EvalCtx<'a> {
247 env: &'a ConstEnv,
248 steps: u32,
249 depth: u32,
250}
251
252impl<'a> EvalCtx<'a> {
253 fn step(&mut self, span: Span) -> Result<(), ConstEvalError> {
254 self.steps = self.steps.saturating_add(1);
255 if self.steps > MAX_STEPS {
256 return Err(ConstEvalError::step_limit(span));
257 }
258 Ok(())
259 }
260
261 fn enter(&mut self, span: Span) -> Result<(), ConstEvalError> {
262 self.depth = self.depth.saturating_add(1);
263 if self.depth > MAX_DEPTH {
264 self.depth -= 1;
265 return Err(ConstEvalError::recursion_limit(span));
266 }
267 Ok(())
268 }
269
270 fn leave(&mut self) {
271 self.depth = self.depth.saturating_sub(1);
272 }
273
274 fn eval_node(&mut self, node: &SNode) -> Result<ConstValue, ConstEvalError> {
275 self.step(node.span)?;
276 self.enter(node.span)?;
277 let result = self.eval_node_inner(node);
278 self.leave();
279 result
280 }
281
282 fn eval_node_inner(&mut self, node: &SNode) -> Result<ConstValue, ConstEvalError> {
283 let ctx = self;
284 match &node.node {
285 Node::IntLiteral(n) => Ok(ConstValue::Int(*n)),
286 Node::FloatLiteral(f) => Ok(ConstValue::Float(*f)),
287 Node::BoolLiteral(b) => Ok(ConstValue::Bool(*b)),
288 Node::StringLiteral(s) | Node::RawStringLiteral(s) => Ok(ConstValue::String(s.clone())),
289 Node::NilLiteral => Ok(ConstValue::Nil),
290
291 Node::Identifier(name) => ctx.env.get(name).cloned().ok_or_else(|| {
292 ConstEvalError::runtime(
293 node.span,
294 format!("`{name}` is not a const-known identifier"),
295 )
296 }),
297
298 Node::ListLiteral(items) => {
299 let mut out = Vec::with_capacity(items.len());
300 for item in items {
301 if matches!(&item.node, Node::Spread(_)) {
302 return Err(ConstEvalError::disallowed(
303 item.span,
304 "spread in a const list literal is not supported",
305 ));
306 }
307 out.push(ctx.eval_node(item)?);
308 }
309 Ok(ConstValue::List(out))
310 }
311
312 Node::DictLiteral(entries) => {
313 let mut out: Vec<(String, ConstValue)> = Vec::with_capacity(entries.len());
314 for entry in entries {
315 let key = ctx.dict_key_name(entry)?;
316 let value = ctx.eval_node(&entry.value)?;
317 out.push((key, value));
318 }
319 Ok(ConstValue::Dict(out))
320 }
321
322 Node::InterpolatedString(segments) => {
323 let mut buf = String::new();
324 for seg in segments {
325 match seg {
326 StringSegment::Literal(lit) => buf.push_str(lit),
327 StringSegment::Expression(src, _, _) => {
328 return Err(ConstEvalError::disallowed(
338 node.span,
339 format!("interpolated expression `${{{src}}}` is not supported in a const initializer; use `format(...)` or string concatenation"),
340 ));
341 }
342 }
343 }
344 Ok(ConstValue::String(buf))
345 }
346
347 Node::UnaryOp { op, operand } => {
348 let value = ctx.eval_node(operand)?;
349 match (op.as_str(), &value) {
350 ("-", ConstValue::Int(n)) => {
351 Ok(ConstValue::Int(n.checked_neg().ok_or_else(|| {
352 ConstEvalError::runtime(node.span, "integer overflow in unary minus")
353 })?))
354 }
355 ("-", ConstValue::Float(f)) => Ok(ConstValue::Float(-f)),
356 ("!", ConstValue::Bool(b)) => Ok(ConstValue::Bool(!b)),
357 _ => Err(ConstEvalError::runtime(
358 node.span,
359 format!("unary `{op}` is not defined for the operand"),
360 )),
361 }
362 }
363
364 Node::BinaryOp { op, left, right } => {
365 if !PURE_BINARY_OPS.contains(&op.as_str()) {
366 return Err(ConstEvalError::disallowed(
367 node.span,
368 format!("binary operator `{op}` is not const-evaluable"),
369 ));
370 }
371 let lhs = ctx.eval_node(left)?;
372 let rhs = ctx.eval_node(right)?;
373 ctx.apply_binary(op, lhs, rhs, node.span)
374 }
375
376 Node::Ternary {
377 condition,
378 true_expr,
379 false_expr,
380 } => {
381 let cond = ctx.eval_node(condition)?;
382 let pick = match cond {
383 ConstValue::Bool(b) => b,
384 _ => {
385 return Err(ConstEvalError::runtime(
386 condition.span,
387 "ternary condition must fold to a bool",
388 ))
389 }
390 };
391 if pick {
392 ctx.eval_node(true_expr)
393 } else {
394 ctx.eval_node(false_expr)
395 }
396 }
397
398 Node::IfElse {
399 condition,
400 then_body,
401 else_body,
402 ..
403 } => {
404 let cond = ctx.eval_node(condition)?;
405 let pick = match cond {
406 ConstValue::Bool(b) => b,
407 _ => {
408 return Err(ConstEvalError::runtime(
409 condition.span,
410 "if-expression condition must fold to a bool",
411 ))
412 }
413 };
414 let branch =
415 if pick {
416 then_body.as_slice()
417 } else {
418 match else_body {
419 Some(body) => body.as_slice(),
420 None => return Err(ConstEvalError::disallowed(
421 node.span,
422 "if-expression without an else branch cannot be const-evaluated",
423 )),
424 }
425 };
426 let Some(last) = branch.last() else {
427 return Err(ConstEvalError::disallowed(
428 node.span,
429 "if-expression branch must produce a value",
430 ));
431 };
432 if let Some(first_pre) = branch[..branch.len().saturating_sub(1)].first() {
433 return Err(ConstEvalError::disallowed(
438 first_pre.span,
439 "multi-statement if-branch is not const-evaluable",
440 ));
441 }
442 ctx.eval_node(last)
443 }
444
445 Node::FunctionCall { name, args, .. } => {
446 if !PURE_BUILTINS.contains(&name.as_str()) {
447 return Err(ConstEvalError::sandbox(
448 node.span,
449 format!(
450 "`{name}(...)` is not on the const-eval allowlist (only pure stdlib builtins may be called from a const initializer)"
451 ),
452 ));
453 }
454 let mut folded = Vec::with_capacity(args.len());
455 for arg in args {
456 folded.push(ctx.eval_node(arg)?);
457 }
458 ctx.apply_builtin(name, folded, node.span)
459 }
460
461 Node::PropertyAccess { object, .. } | Node::OptionalPropertyAccess { object, .. } => {
467 if let Node::Identifier(root) = &object.node {
468 if SANDBOXED_OBJECT_ROOTS.contains(&root.as_str()) {
469 return Err(ConstEvalError::sandbox(
470 node.span,
471 format!(
472 "`{root}.*` is a sandboxed capability surface; const-eval refuses fs/net/env/process/host access"
473 ),
474 ));
475 }
476 }
477 Err(ConstEvalError::disallowed(
478 node.span,
479 "property access is not const-evaluable",
480 ))
481 }
482 Node::MethodCall { object, .. } | Node::OptionalMethodCall { object, .. } => {
483 if let Some(root) = leftmost_receiver_identifier(object) {
492 if SANDBOXED_OBJECT_ROOTS.contains(&root) {
493 return Err(ConstEvalError::sandbox(
494 node.span,
495 format!(
496 "`{root}.*(...)` is a sandboxed capability surface; const-eval refuses fs/net/env/process/host access"
497 ),
498 ));
499 }
500 }
501 Err(ConstEvalError::disallowed(
502 node.span,
503 "method call is not const-evaluable",
504 ))
505 }
506 Node::SubscriptAccess { object, index } => {
507 let recv = ctx.eval_node(object)?;
508 let idx = ctx.eval_node(index)?;
509 match (recv, idx) {
510 (ConstValue::List(items), ConstValue::Int(i)) => {
511 items.get(i as usize).cloned().ok_or_else(|| {
512 ConstEvalError::runtime(node.span, format!("index {i} out of bounds"))
513 })
514 }
515 (ConstValue::Dict(entries), ConstValue::String(k)) => entries
516 .into_iter()
517 .find(|(name, _)| *name == k)
518 .map(|(_, v)| v)
519 .ok_or_else(|| {
520 ConstEvalError::runtime(node.span, format!("unknown key `{k}`"))
521 }),
522 _ => Err(ConstEvalError::runtime(
523 node.span,
524 "subscript receiver and index types are incompatible",
525 )),
526 }
527 }
528 Node::Block(_) => Err(ConstEvalError::disallowed(
529 node.span,
530 "block expression is not const-evaluable",
531 )),
532 Node::Closure { .. } => Err(ConstEvalError::disallowed(
533 node.span,
534 "closure is not const-evaluable",
535 )),
536
537 Node::SpawnExpr { .. }
539 | Node::SelectExpr { .. }
540 | Node::Parallel { .. }
541 | Node::MutexBlock { .. }
542 | Node::DeferStmt { .. }
543 | Node::YieldExpr { .. }
544 | Node::EmitExpr { .. }
545 | Node::TryCatch { .. }
546 | Node::TryExpr { .. }
547 | Node::TryOperator { .. }
548 | Node::TryStar { .. }
549 | Node::DeadlineBlock { .. }
550 | Node::CostRoute { .. }
551 | Node::WhileLoop { .. }
552 | Node::ForIn { .. }
553 | Node::Retry { .. }
554 | Node::GuardStmt { .. }
555 | Node::RequireStmt { .. }
556 | Node::Assignment { .. }
557 | Node::ThrowStmt { .. }
558 | Node::ReturnStmt { .. }
559 | Node::BreakStmt
560 | Node::ContinueStmt => Err(ConstEvalError::sandbox(
561 node.span,
562 "runtime construct is not permitted in a const initializer",
563 )),
564
565 _ => Err(ConstEvalError::disallowed(
567 node.span,
568 "expression shape is not on the const-eval allowlist",
569 )),
570 }
571 }
572
573 fn dict_key_name(&self, entry: &DictEntry) -> Result<String, ConstEvalError> {
574 match &entry.key.node {
575 Node::Identifier(name) => Ok(name.clone()),
576 Node::StringLiteral(s) | Node::RawStringLiteral(s) => Ok(s.clone()),
577 _ => Err(ConstEvalError::disallowed(
578 entry.key.span,
579 "dict keys in a const dict literal must be identifiers or string literals",
580 )),
581 }
582 }
583
584 fn apply_binary(
585 &self,
586 op: &str,
587 lhs: ConstValue,
588 rhs: ConstValue,
589 span: Span,
590 ) -> Result<ConstValue, ConstEvalError> {
591 use ConstValue::*;
592
593 if op == "&&" || op == "||" {
595 let (Bool(l), Bool(r)) = (&lhs, &rhs) else {
596 return Err(ConstEvalError::runtime(
597 span,
598 format!("`{op}` requires bool operands"),
599 ));
600 };
601 return Ok(Bool(if op == "&&" { *l && *r } else { *l || *r }));
602 }
603 if op == "??" {
604 return Ok(match lhs {
605 Nil => rhs,
606 other => other,
607 });
608 }
609 if op == "==" {
610 return Ok(Bool(lhs == rhs));
611 }
612 if op == "!=" {
613 return Ok(Bool(lhs != rhs));
614 }
615
616 if op == "+" {
618 if let (String(a), String(b)) = (&lhs, &rhs) {
619 return Ok(String(format!("{a}{b}")));
620 }
621 }
622
623 let (lhs_num, rhs_num) = match (&lhs, &rhs) {
625 (Int(_) | Float(_), Int(_) | Float(_)) => (lhs.clone(), rhs.clone()),
626 _ => {
627 return Err(ConstEvalError::runtime(
628 span,
629 format!(
630 "`{op}` requires numeric operands, got {} and {}",
631 value_kind(&lhs),
632 value_kind(&rhs)
633 ),
634 ))
635 }
636 };
637
638 if matches!(op, "<" | ">" | "<=" | ">=") {
640 let (l, r) = (as_float(&lhs_num), as_float(&rhs_num));
641 let out = match op {
642 "<" => l < r,
643 ">" => l > r,
644 "<=" => l <= r,
645 ">=" => l >= r,
646 _ => unreachable!(),
647 };
648 return Ok(Bool(out));
649 }
650
651 if let (Int(a), Int(b)) = (&lhs_num, &rhs_num) {
653 let result = match op {
654 "+" => a.checked_add(*b),
655 "-" => a.checked_sub(*b),
656 "*" => a.checked_mul(*b),
657 "/" => {
658 if *b == 0 {
659 return Err(ConstEvalError::runtime(span, "division by zero"));
660 }
661 a.checked_div(*b)
662 }
663 "%" => {
664 if *b == 0 {
665 return Err(ConstEvalError::runtime(span, "modulo by zero"));
666 }
667 a.checked_rem(*b)
668 }
669 "**" => {
670 if *b < 0 || *b > u32::MAX as i64 {
671 return Err(ConstEvalError::runtime(
672 span,
673 "exponent must be a non-negative i64 within u32 range",
674 ));
675 }
676 a.checked_pow(*b as u32)
677 }
678 _ => unreachable!(),
679 };
680 return result
681 .map(Int)
682 .ok_or_else(|| ConstEvalError::runtime(span, "integer overflow"));
683 }
684
685 let (l, r) = (as_float(&lhs_num), as_float(&rhs_num));
686 let value = match op {
687 "+" => l + r,
688 "-" => l - r,
689 "*" => l * r,
690 "/" => {
691 if r == 0.0 {
692 return Err(ConstEvalError::runtime(span, "division by zero"));
693 }
694 l / r
695 }
696 "%" => {
697 if r == 0.0 {
698 return Err(ConstEvalError::runtime(span, "modulo by zero"));
699 }
700 l % r
701 }
702 "**" => l.powf(r),
703 _ => unreachable!(),
704 };
705 Ok(Float(value))
706 }
707
708 fn apply_builtin(
709 &self,
710 name: &str,
711 args: Vec<ConstValue>,
712 span: Span,
713 ) -> Result<ConstValue, ConstEvalError> {
714 match name {
715 "len" => match args.as_slice() {
716 [ConstValue::String(s)] => Ok(ConstValue::Int(s.chars().count() as i64)),
717 [ConstValue::List(items)] => Ok(ConstValue::Int(items.len() as i64)),
718 [ConstValue::Dict(entries)] => Ok(ConstValue::Int(entries.len() as i64)),
719 _ => Err(ConstEvalError::runtime(
720 span,
721 "len() expects a single string / list / dict argument",
722 )),
723 },
724 "format" => format_call(span, args),
725 "concat" => {
726 let mut out = String::new();
727 for arg in &args {
728 match arg {
729 ConstValue::String(s) => out.push_str(s),
730 _ => {
731 return Err(ConstEvalError::runtime(
732 span,
733 "concat() expects string arguments",
734 ))
735 }
736 }
737 }
738 Ok(ConstValue::String(out))
739 }
740 "join" => match args.as_slice() {
741 [ConstValue::List(items), ConstValue::String(sep)] => {
742 let mut parts = Vec::with_capacity(items.len());
743 for item in items {
744 match item {
745 ConstValue::String(s) => parts.push(s.clone()),
746 other => parts.push(other.display()),
747 }
748 }
749 Ok(ConstValue::String(parts.join(sep)))
750 }
751 _ => Err(ConstEvalError::runtime(
752 span,
753 "join() expects (list, string)",
754 )),
755 },
756 "min" | "max" => apply_min_max(name, &args, span),
757 "abs" => match args.as_slice() {
758 [ConstValue::Int(n)] => {
759 Ok(ConstValue::Int(n.checked_abs().ok_or_else(|| {
760 ConstEvalError::runtime(span, "integer overflow in abs()")
761 })?))
762 }
763 [ConstValue::Float(f)] => Ok(ConstValue::Float(f.abs())),
764 _ => Err(ConstEvalError::runtime(
765 span,
766 "abs() expects a single numeric argument",
767 )),
768 },
769 "floor" => unary_float(span, &args, |f| f.floor()),
770 "ceil" => unary_float(span, &args, |f| f.ceil()),
771 "round" => match args.as_slice() {
772 [ConstValue::Float(f), ConstValue::Int(digits)] => {
776 Ok(ConstValue::Float(round_float_to_digits(*f, *digits)))
777 }
778 [ConstValue::Int(n), ConstValue::Int(digits)] => {
779 Ok(round_int_to_digits(*n, *digits))
780 }
781 _ => unary_float(span, &args, |f| f.round()),
782 },
783 "lowercase" => match args.as_slice() {
784 [ConstValue::String(s)] => Ok(ConstValue::String(s.to_lowercase())),
785 _ => Err(ConstEvalError::runtime(
786 span,
787 "lowercase() expects a string",
788 )),
789 },
790 "uppercase" => match args.as_slice() {
791 [ConstValue::String(s)] => Ok(ConstValue::String(s.to_uppercase())),
792 _ => Err(ConstEvalError::runtime(
793 span,
794 "uppercase() expects a string",
795 )),
796 },
797 "trim" => match args.as_slice() {
798 [ConstValue::String(s)] => Ok(ConstValue::String(s.trim().to_string())),
799 _ => Err(ConstEvalError::runtime(span, "trim() expects a string")),
800 },
801 _ => Err(ConstEvalError::sandbox(
806 span,
807 format!("`{name}(...)` lacks a const-eval implementation"),
808 )),
809 }
810 }
811}
812
813fn leftmost_receiver_identifier(node: &SNode) -> Option<&str> {
817 let mut current = node;
818 loop {
819 match ¤t.node {
820 Node::Identifier(name) => return Some(name.as_str()),
821 Node::PropertyAccess { object, .. }
822 | Node::OptionalPropertyAccess { object, .. }
823 | Node::SubscriptAccess { object, .. }
824 | Node::OptionalSubscriptAccess { object, .. } => {
825 current = object;
826 }
827 _ => return None,
828 }
829 }
830}
831
832fn value_kind(v: &ConstValue) -> &'static str {
833 match v {
834 ConstValue::Int(_) => "int",
835 ConstValue::Float(_) => "float",
836 ConstValue::Bool(_) => "bool",
837 ConstValue::String(_) => "string",
838 ConstValue::List(_) => "list",
839 ConstValue::Dict(_) => "dict",
840 ConstValue::Nil => "nil",
841 }
842}
843
844fn as_float(v: &ConstValue) -> f64 {
845 match v {
846 ConstValue::Int(n) => *n as f64,
847 ConstValue::Float(f) => *f,
848 _ => 0.0,
849 }
850}
851
852fn format_call(span: Span, args: Vec<ConstValue>) -> Result<ConstValue, ConstEvalError> {
853 let mut iter = args.into_iter();
854 let template = match iter.next() {
855 Some(ConstValue::String(s)) => s,
856 Some(_) => {
857 return Err(ConstEvalError::runtime(
858 span,
859 "format() template must be a string literal",
860 ))
861 }
862 None => {
863 return Err(ConstEvalError::runtime(
864 span,
865 "format() requires at least a template argument",
866 ))
867 }
868 };
869 let rest: Vec<ConstValue> = iter.collect();
870
871 if let [ConstValue::Dict(entries)] = rest.as_slice() {
874 let mut result = String::with_capacity(template.len());
875 let mut rest_str = template.as_str();
876 while let Some((head, after_open)) = rest_str.split_once('{') {
877 result.push_str(head);
878 if let Some((key, after_close)) = after_open.split_once('}') {
879 if let Some((_, val)) = entries.iter().find(|(k, _)| k == key) {
880 result.push_str(&val.display());
881 } else {
882 result.push('{');
883 result.push_str(key);
884 result.push('}');
885 }
886 rest_str = after_close;
887 } else {
888 result.push('{');
889 result.push_str(after_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((head, rest_of_template)) = tail.split_once("{}") {
902 result.push_str(head);
903 if let Some(arg) = rest_iter.next() {
904 result.push_str(&arg.display());
905 } else {
906 result.push_str("{}");
907 }
908 tail = rest_of_template;
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
963fn 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
987fn 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 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 {
1042 pattern: crate::ast::BindingPattern::Identifier(name),
1043 value,
1044 ..
1045 } = &snode.node
1046 {
1047 let folded = const_eval(value, &env)?;
1048 env.insert(name.clone(), folded.clone());
1049 last = Some(folded);
1050 }
1051 }
1052 Ok(last.expect("no const binding in source"))
1053 }
1054
1055 #[test]
1056 fn arithmetic_literals_fold() {
1057 assert_eq!(fold("const X = 1 + 2").unwrap(), ConstValue::Int(3));
1058 assert_eq!(fold("const Y = 5 * (3 + 2)").unwrap(), ConstValue::Int(25));
1059 assert_eq!(fold("const Z = 2 ** 10").unwrap(), ConstValue::Int(1024));
1060 }
1061
1062 #[test]
1063 fn string_concat_folds() {
1064 assert_eq!(
1065 fold(r#"const S = "foo" + "-" + "bar""#).unwrap(),
1066 ConstValue::String("foo-bar".to_string())
1067 );
1068 }
1069
1070 #[test]
1071 fn earlier_const_visible_to_later() {
1072 let src = "const A = 10\nconst B = A * 2";
1073 assert_eq!(fold(src).unwrap(), ConstValue::Int(20));
1074 }
1075
1076 #[test]
1077 fn len_of_literal_list() {
1078 assert_eq!(
1079 fold("const N = len([1, 2, 3, 4])").unwrap(),
1080 ConstValue::Int(4)
1081 );
1082 }
1083
1084 #[test]
1085 fn format_positional_placeholders() {
1086 let src = r#"const G = format("{}-{}", "hello", 42)"#;
1087 assert_eq!(
1088 fold(src).unwrap(),
1089 ConstValue::String("hello-42".to_string())
1090 );
1091 }
1092
1093 #[test]
1094 fn host_property_access_is_sandboxed() {
1095 let err = fold("const Z = harness.clock.now()").unwrap_err();
1096 assert!(matches!(
1097 err.kind,
1098 ConstEvalErrorKind::SandboxViolation | ConstEvalErrorKind::Disallowed
1099 ));
1100 }
1101
1102 #[test]
1103 fn division_by_zero_is_runtime_error() {
1104 let err = fold("const Z = 1 / 0").unwrap_err();
1105 assert!(matches!(err.kind, ConstEvalErrorKind::RuntimeError));
1106 }
1107
1108 #[test]
1109 fn unknown_identifier_is_runtime_error() {
1110 let err = fold("const Z = NOPE + 1").unwrap_err();
1111 assert!(matches!(err.kind, ConstEvalErrorKind::RuntimeError));
1112 }
1113
1114 #[test]
1115 fn spawn_is_sandbox_violation() {
1116 let err = fold("const Z = spawn { 1 }").unwrap_err();
1117 assert!(matches!(err.kind, ConstEvalErrorKind::SandboxViolation));
1118 }
1119
1120 #[test]
1121 fn user_function_call_is_sandboxed() {
1122 let err = fold("const Z = some_user_fn()").unwrap_err();
1123 assert!(matches!(err.kind, ConstEvalErrorKind::SandboxViolation));
1124 }
1125
1126 #[test]
1127 fn ternary_picks_branch() {
1128 assert_eq!(fold("const T = true ? 1 : 2").unwrap(), ConstValue::Int(1));
1129 assert_eq!(fold("const T = false ? 1 : 2").unwrap(), ConstValue::Int(2));
1130 }
1131
1132 #[test]
1133 fn list_subscript_folds() {
1134 assert_eq!(
1135 fold("const N = [10, 20, 30][1]").unwrap(),
1136 ConstValue::Int(20)
1137 );
1138 }
1139
1140 #[test]
1141 fn list_subscript_out_of_bounds_is_runtime_error() {
1142 let err = fold("const N = [1, 2][9]").unwrap_err();
1143 assert!(matches!(err.kind, ConstEvalErrorKind::RuntimeError));
1144 }
1145
1146 #[test]
1147 fn recursion_depth_is_bounded() {
1148 let env = ConstEnv::new();
1155 let mut ctx = EvalCtx {
1156 env: &env,
1157 steps: 0,
1158 depth: MAX_DEPTH,
1159 };
1160 let err = ctx.enter(Span::dummy()).unwrap_err();
1161 assert!(matches!(err.kind, ConstEvalErrorKind::RecursionLimit));
1162 assert_eq!(ctx.depth, MAX_DEPTH);
1166 }
1167
1168 #[test]
1169 fn step_budget_is_bounded() {
1170 let env = ConstEnv::new();
1176 let mut ctx = EvalCtx {
1177 env: &env,
1178 steps: MAX_STEPS,
1179 depth: 0,
1180 };
1181 let err = ctx.step(Span::dummy()).unwrap_err();
1182 assert!(matches!(err.kind, ConstEvalErrorKind::StepLimit));
1183 }
1184
1185 #[test]
1186 fn step_counter_is_not_amortized() {
1187 let env = ConstEnv::new();
1193 let mut ctx = EvalCtx {
1197 env: &env,
1198 steps: MAX_STEPS - 4,
1199 depth: 0,
1200 };
1201 let span = Span::dummy();
1202 for _ in 0..4 {
1203 ctx.step(span).expect("inside budget");
1204 }
1205 let err = ctx.step(span).unwrap_err();
1206 assert!(matches!(err.kind, ConstEvalErrorKind::StepLimit));
1207 }
1208}