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
54pub const EVAL_VERSION: u32 = 1;
57
58#[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 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#[derive(Debug, Clone, PartialEq, Eq)]
117pub enum ConstEvalErrorKind {
118 Disallowed,
120 StepLimit,
122 RecursionLimit,
124 SandboxViolation,
127 RuntimeError,
130}
131
132#[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
184const 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
204const 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
224const PURE_BINARY_OPS: &[&str] = &[
228 "+", "-", "*", "/", "%", "**", "==", "!=", "<", ">", "<=", ">=", "&&", "||", "??",
229];
230
231pub type ConstEnv = HashMap<String, ConstValue>;
235
236pub 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 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 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 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 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 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 _ => 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 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 if op == "+" {
621 if let (String(a), String(b)) = (&lhs, &rhs) {
622 return Ok(String(format!("{a}{b}")));
623 }
624 }
625
626 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 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 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 [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 _ => Err(ConstEvalError::sandbox(
809 span,
810 format!("`{name}(...)` lacks a const-eval implementation"),
811 )),
812 }
813 }
814}
815
816fn leftmost_receiver_identifier(node: &SNode) -> Option<&str> {
820 let mut current = node;
821 loop {
822 match ¤t.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 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
964fn 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
988fn 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 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 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 assert_eq!(ctx.depth, MAX_DEPTH);
1167 }
1168
1169 #[test]
1170 fn step_budget_is_bounded() {
1171 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 let env = ConstEnv::new();
1194 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 let _ = EVAL_VERSION;
1217 }
1218}