use petgraph::algo::{is_cyclic_directed, toposort};
use std::collections::HashMap;
use std::time::Instant;
use thiserror::Error;
use crate::diag::Span;
use crate::diag::{Diagnostic, Severity};
use crate::ir::*;
use crate::parser::{validate_patch_path, PathSeg};
use crate::types::Unit;
#[derive(Debug, Error)]
pub enum EvalError {
#[error("cycle detected in dependency graph")]
CycleDetected,
#[error("missing root")]
MissingRoot,
#[error("node {0:?} not found")]
MissingNode(NodeId),
#[error("type error: {message}")]
TypeError {
message: String,
span: Option<Span>,
file: Option<String>,
},
#[error("patch error: {message}")]
PatchError {
message: String,
span: Option<Span>,
file: Option<String>,
},
#[error("secret used in unsafe context")]
SecretLeak { file: Option<String> },
#[error("validation failed")]
ValidationFailed { diagnostics: Vec<Diagnostic> },
#[error("evaluation budget exceeded: {kind} (limit {limit})")]
BudgetExceeded {
kind: BudgetKind,
limit: u64,
span: Option<Span>,
file: Option<String>,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BudgetKind {
Nodes,
WallClock,
Depth,
ArrayLength,
Keys,
ValueSize,
}
impl std::fmt::Display for BudgetKind {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
BudgetKind::Nodes => write!(f, "node budget"),
BudgetKind::WallClock => write!(f, "time budget"),
BudgetKind::Depth => write!(f, "depth budget"),
BudgetKind::ArrayLength => write!(f, "array budget"),
BudgetKind::Keys => write!(f, "key budget"),
BudgetKind::ValueSize => write!(f, "value size budget"),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Budget {
pub max_nodes: u64,
pub max_depth: u64,
pub max_array_len: u64,
pub max_keys: u64,
pub max_wall_ms: u64,
pub max_value_size: u64,
pub max_total_value_size: u64,
}
impl Default for Budget {
fn default() -> Self {
Self {
max_nodes: 100_000,
max_depth: 256,
max_array_len: 100_000,
max_keys: 100_000,
max_wall_ms: 100,
max_value_size: 10_000_000,
max_total_value_size: 100_000_000,
}
}
}
impl Budget {
pub fn wall_deadline(&self) -> Option<Instant> {
if self.max_wall_ms == 0 {
None
} else {
Instant::now().checked_add(std::time::Duration::from_millis(self.max_wall_ms))
}
}
}
impl EvalError {
pub fn code(&self) -> &'static str {
match self {
EvalError::CycleDetected => "S001",
EvalError::MissingRoot => "S002",
EvalError::MissingNode(_) => "S003",
EvalError::TypeError { .. } => "T001",
EvalError::PatchError { .. } => "E019",
EvalError::SecretLeak { .. } => "S004",
EvalError::BudgetExceeded { kind, .. } => match kind {
BudgetKind::Nodes => "X001",
BudgetKind::WallClock => "X002",
BudgetKind::Depth => "X003",
BudgetKind::ArrayLength => "X004",
BudgetKind::Keys => "X005",
BudgetKind::ValueSize => "X006",
},
EvalError::ValidationFailed { diagnostics } => {
match diagnostics.first().map(|d| d.code.as_str()) {
Some("T003") => "T003",
Some("T004") => "T004",
_ => "T002",
}
}
}
}
pub fn span(&self) -> Option<&Span> {
match self {
EvalError::TypeError { span, .. } | EvalError::PatchError { span, .. } => span.as_ref(),
EvalError::BudgetExceeded { span, .. } => span.as_ref(),
EvalError::ValidationFailed { diagnostics } => {
diagnostics.first().and_then(|d| d.span.as_ref())
}
_ => None,
}
}
pub fn file(&self) -> Option<&str> {
match self {
EvalError::TypeError { file, .. } | EvalError::PatchError { file, .. } => {
file.as_deref()
}
EvalError::SecretLeak { file } => file.as_deref(),
EvalError::BudgetExceeded { file, .. } => file.as_deref(),
EvalError::ValidationFailed { diagnostics } => {
diagnostics.first().and_then(|d| d.file.as_deref())
}
_ => None,
}
}
fn type_error(message: impl Into<String>, module: &Module, node: NodeId) -> Self {
EvalError::TypeError {
message: message.into(),
span: module.node_spans.get(&node).cloned(),
file: module.node_files.get(&node).cloned(),
}
}
pub(crate) fn budget_exceeded(
kind: BudgetKind,
limit: u64,
module: &Module,
node: Option<NodeId>,
) -> Self {
EvalError::BudgetExceeded {
kind,
limit,
span: node.and_then(|n| module.node_spans.get(&n).cloned()),
file: node.and_then(|n| module.node_files.get(&n).cloned()),
}
}
fn patch_error(message: impl Into<String>, module: &Module, node: NodeId) -> Self {
EvalError::PatchError {
message: message.into(),
span: module.node_spans.get(&node).cloned(),
file: module.node_files.get(&node).cloned(),
}
}
fn secret_leak(module: &Module, node: NodeId) -> Self {
EvalError::SecretLeak {
file: module.node_files.get(&node).cloned(),
}
}
}
pub struct Engine {
pub resilient: bool,
pub profile_override: Option<String>,
pub budget: Budget,
pub env: std::collections::HashMap<String, String>,
}
impl Default for Engine {
fn default() -> Self {
Self::new()
}
}
fn check_result_depth(result: &Value, max_depth: u64) -> Result<(), EvalError> {
let mut stack: Vec<(&Value, u64)> = vec![(result, 0)];
while let Some((value, depth)) = stack.pop() {
if depth > max_depth {
return Err(EvalError::BudgetExceeded {
kind: BudgetKind::Depth,
limit: max_depth,
span: None,
file: None,
});
}
match value {
Value::Table(map) => {
for v in map.values() {
stack.push((v, depth + 1));
}
}
Value::Array(elements) => {
for v in elements {
stack.push((v, depth + 1));
}
}
_ => {}
}
}
Ok(())
}
pub(crate) struct Accountant {
spent: u64,
limit: u64,
}
impl Accountant {
pub(crate) fn charge(
&mut self,
module: &Module,
node: Option<NodeId>,
amount: u64,
) -> Result<(), EvalError> {
self.spent = self.spent.saturating_add(amount);
if self.limit != 0 && self.spent >= self.limit.saturating_add(1) {
return Err(EvalError::budget_exceeded(
BudgetKind::ValueSize,
self.limit,
module,
node,
));
}
Ok(())
}
}
pub(crate) struct BudgetCx<'a> {
pub deadline: Option<Instant>,
pub wall_limit: u64,
pub account: &'a mut Accountant,
}
pub(crate) fn measure_value(value: &Value) -> u64 {
let mut total = 0u64;
let mut stack = vec![value];
while let Some(v) = stack.pop() {
total = total.saturating_add(1);
match v {
Value::String(s) | Value::Secret(s) => {
total = total.saturating_add(s.len() as u64);
}
Value::Array(items) => stack.extend(items.iter()),
Value::Table(map) => stack.extend(map.values()),
_ => {}
}
}
total
}
fn check_static_budgets(module: &Module, budget: &Budget) -> Result<(), EvalError> {
let over = |limit: u64, used: u64| limit != 0 && used > limit;
if over(budget.max_nodes, module.arena.len() as u64) {
return Err(EvalError::BudgetExceeded {
kind: BudgetKind::Nodes,
limit: budget.max_nodes,
span: None,
file: None,
});
}
if budget.max_array_len != 0 || budget.max_keys != 0 {
let mut keys = 0u64;
for (i, node) in module.arena.iter().enumerate() {
let id = NodeId(i as u32);
match node {
Node::Table { fields } => {
keys += fields.len() as u64;
if over(budget.max_keys, keys) {
return Err(EvalError::BudgetExceeded {
kind: BudgetKind::Keys,
limit: budget.max_keys,
span: None,
file: None,
});
}
}
Node::Array { elements } if over(budget.max_array_len, elements.len() as u64) => {
return Err(EvalError::budget_exceeded(
BudgetKind::ArrayLength,
budget.max_array_len,
module,
Some(id),
));
}
_ => {}
}
}
}
Ok(())
}
fn check_dp_bounds(
module: &Module,
order: &[petgraph::graph::NodeIndex],
budget: &Budget,
) -> Result<(u64, Vec<u64>), EvalError> {
if budget.max_value_size == 0 && budget.max_depth == 0 && budget.max_total_value_size == 0 {
return Ok((0, vec![1u64; module.arena.len()]));
}
let size_cap = if budget.max_value_size == 0 {
u64::MAX
} else {
budget.max_value_size.saturating_add(1)
};
let depth_cap = if budget.max_depth == 0 {
u64::MAX
} else {
budget.max_depth.saturating_add(1)
};
let mut size = vec![1u64; module.arena.len()];
let mut depth = vec![0u64; module.arena.len()];
let sat_size = |a: u64, b: u64| a.saturating_add(b).min(size_cap);
let mut total = 0u64;
for node_idx in order {
let id = module.graph[*node_idx];
let i = id.0 as usize;
if i >= size.len() {
continue;
}
let mut child_size = 0u64;
let mut child_depth = 0u64;
match module.get(id) {
Some(Node::Table { fields }) => {
for child in fields.values() {
let c = child.0 as usize;
if c < size.len() {
child_size = sat_size(child_size, size[c]);
child_depth = child_depth.max(depth[c]);
}
}
size[i] = sat_size(1, child_size);
depth[i] = child_depth.saturating_add(1).min(depth_cap);
}
Some(Node::Array { elements }) => {
for child in elements {
let c = child.0 as usize;
if c < size.len() {
child_size = sat_size(child_size, size[c]);
child_depth = child_depth.max(depth[c]);
}
}
size[i] = sat_size(1, child_size);
depth[i] = child_depth.saturating_add(1).min(depth_cap);
}
Some(Node::Ref(target)) => {
let c = target.0 as usize;
if c < size.len() {
size[i] = size[c];
depth[i] = depth[c];
}
}
Some(Node::Literal(Value::String(s))) => {
size[i] = sat_size(1, s.len() as u64);
}
Some(Node::Interpolate { parts }) => {
for part in parts {
match part {
InterpPart::Expr(e) => {
let c = e.0 as usize;
if c < size.len() {
child_size = sat_size(child_size, size[c]);
child_depth = child_depth.max(depth[c]);
}
}
InterpPart::Literal(s) => {
child_size = sat_size(child_size, s.len() as u64);
}
}
}
size[i] = sat_size(1, child_size);
}
Some(Node::Binary { lhs, rhs, .. }) => {
for child in [*lhs, *rhs] {
let c = child.0 as usize;
if c < size.len() {
child_size = sat_size(child_size, size[c]);
}
}
size[i] = sat_size(1, child_size);
}
Some(Node::Unary { expr, .. }) => {
let c = expr.0 as usize;
if c < size.len() {
size[i] = sat_size(1, size[c]);
}
}
Some(Node::Semantic { inner, .. }) => {
let c = inner.0 as usize;
if c < size.len() {
size[i] = size[c];
depth[i] = depth[c];
}
}
_ => {}
}
if budget.max_value_size != 0 && size[i] >= size_cap {
return Err(EvalError::budget_exceeded(
BudgetKind::ValueSize,
budget.max_value_size,
module,
Some(id),
));
}
if budget.max_depth != 0 && depth[i] >= depth_cap {
return Err(EvalError::budget_exceeded(
BudgetKind::Depth,
budget.max_depth,
module,
Some(id),
));
}
total = total.saturating_add(size[i]);
if budget.max_total_value_size != 0
&& total >= budget.max_total_value_size.saturating_add(1)
{
return Err(EvalError::budget_exceeded(
BudgetKind::ValueSize,
budget.max_total_value_size,
module,
Some(id),
));
}
}
Ok((total, size))
}
pub struct IncrementalMemo {
map: HashMap<u64, (Value, Vec<Diagnostic>)>,
config: Option<(bool, Option<String>, Budget)>,
salt: u64,
pub last_eval_count: u64,
pub last_memo_hits: u64,
}
impl std::fmt::Debug for IncrementalMemo {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("IncrementalMemo")
.field("entries", &self.map.len())
.field("last_eval_count", &self.last_eval_count)
.field("last_memo_hits", &self.last_memo_hits)
.finish()
}
}
impl Default for IncrementalMemo {
fn default() -> Self {
Self {
map: HashMap::new(),
config: None,
salt: memo_salt(),
last_eval_count: 0,
last_memo_hits: 0,
}
}
}
fn memo_salt() -> u64 {
let mut buf = [0u8; 8];
if getrandom::getrandom(&mut buf).is_ok() {
u64::from_le_bytes(buf)
} else {
0x9E37_79B9_7F4A_7C15
}
}
struct FpHasher(u64);
impl FpHasher {
fn new(salt: u64) -> Self {
Self(14_695_981_039_346_656_037 ^ salt)
}
}
impl std::hash::Hasher for FpHasher {
fn write(&mut self, bytes: &[u8]) {
for &byte in bytes {
self.0 ^= u64::from(byte);
self.0 = self.0.wrapping_mul(1_099_511_628_211);
}
}
fn finish(&self) -> u64 {
self.0
}
}
fn fp_value(hasher: &mut FpHasher, value: &Value) {
use std::hash::Hash;
match value {
Value::Null => 0u8.hash(hasher),
Value::Bool(flag) => {
1u8.hash(hasher);
flag.hash(hasher);
}
Value::Int(num) => {
2u8.hash(hasher);
num.hash(hasher);
}
Value::Float(num) => {
3u8.hash(hasher);
num.to_bits().hash(hasher);
}
Value::String(text) => {
4u8.hash(hasher);
text.hash(hasher);
}
Value::DurationMs(millis) => {
5u8.hash(hasher);
millis.hash(hasher);
}
Value::Quantity(amount, unit) => {
6u8.hash(hasher);
amount.to_bits().hash(hasher);
unit.hash(hasher);
}
Value::Array(items) => {
7u8.hash(hasher);
items.len().hash(hasher);
for item in items {
fp_value(hasher, item);
}
}
Value::Table(fields) => {
8u8.hash(hasher);
fields.len().hash(hasher);
for (key, item) in fields {
key.hash(hasher);
fp_value(hasher, item);
}
}
Value::Secret(text) => {
9u8.hash(hasher);
text.hash(hasher);
}
}
}
fn fingerprint_one(
engine: &Engine,
module: &Module,
id: NodeId,
cache: &[Option<u64>],
salt: u64,
) -> Option<u64> {
use std::hash::{Hash, Hasher};
let child = |dep: &NodeId| cache.get(dep.0 as usize).copied().flatten();
let mut hasher = FpHasher::new(salt);
match module.get(id)? {
Node::Literal(v) => {
0u8.hash(&mut hasher);
fp_value(&mut hasher, v);
}
Node::Env { name, default } => {
1u8.hash(&mut hasher);
name.hash(&mut hasher);
engine.env.get(name).hash(&mut hasher);
match default {
Some(d) => child(d)?.hash(&mut hasher),
None => 0u8.hash(&mut hasher),
}
}
Node::Secret { locator } => {
2u8.hash(&mut hasher);
child(locator)?.hash(&mut hasher);
}
Node::Ref(target) => {
3u8.hash(&mut hasher);
child(target)?.hash(&mut hasher);
}
Node::Ident(name) => {
4u8.hash(&mut hasher);
name.hash(&mut hasher);
}
Node::Interpolate { parts } => {
5u8.hash(&mut hasher);
parts.len().hash(&mut hasher);
for part in parts {
match part {
crate::ir::InterpPart::Literal(s) => {
0u8.hash(&mut hasher);
s.hash(&mut hasher);
}
crate::ir::InterpPart::Expr(e) => {
1u8.hash(&mut hasher);
child(e)?.hash(&mut hasher);
}
}
}
}
Node::Binary { op, lhs, rhs } => {
6u8.hash(&mut hasher);
op.hash(&mut hasher);
child(lhs)?.hash(&mut hasher);
child(rhs)?.hash(&mut hasher);
}
Node::Unary { op, expr } => {
7u8.hash(&mut hasher);
op.hash(&mut hasher);
child(expr)?.hash(&mut hasher);
}
Node::Array { elements } => {
8u8.hash(&mut hasher);
elements.len().hash(&mut hasher);
for element in elements {
child(element)?.hash(&mut hasher);
}
}
Node::Table { fields } => {
9u8.hash(&mut hasher);
fields.len().hash(&mut hasher);
for (key, child_id) in fields {
key.hash(&mut hasher);
child(child_id)?.hash(&mut hasher);
}
}
Node::Quantity { value, unit } => {
10u8.hash(&mut hasher);
unit.hash(&mut hasher);
child(value)?.hash(&mut hasher);
}
Node::Semantic { tag, inner } => {
11u8.hash(&mut hasher);
tag.as_str().hash(&mut hasher);
child(inner)?.hash(&mut hasher);
}
_ => return None,
}
Some(hasher.finish())
}
impl Engine {
pub fn new() -> Self {
Self {
resilient: true,
profile_override: None,
budget: Budget::default(),
env: std::env::vars_os()
.filter_map(|(key, value)| {
Some((key.into_string().ok()?, value.into_string().ok()?))
})
.collect(),
}
}
pub fn with_env(mut self, env: std::collections::HashMap<String, String>) -> Self {
self.env = env;
self
}
pub fn evaluate(&self, module: &Module) -> Result<(Value, Vec<Diagnostic>), EvalError> {
self.evaluate_inner(module, None)
}
pub fn evaluate_incremental(
&self,
module: &Module,
memo: &mut IncrementalMemo,
) -> Result<(Value, Vec<Diagnostic>), EvalError> {
let token = (self.resilient, self.profile_override.clone(), self.budget);
if memo.config.as_ref() != Some(&token) {
memo.map.clear();
memo.config = Some(token);
}
memo.last_eval_count = 0;
memo.last_memo_hits = 0;
let outcome = self.evaluate_inner(module, Some(memo));
let cap = module.arena.len().saturating_mul(4).max(1024);
if memo.map.len() > cap {
memo.map.clear();
}
outcome
}
fn evaluate_inner(
&self,
module: &Module,
mut memo: Option<&mut IncrementalMemo>,
) -> Result<(Value, Vec<Diagnostic>), EvalError> {
let deadline = self.budget.wall_deadline();
check_static_budgets(module, &self.budget)?;
if is_cyclic_directed(&module.graph) {
return Err(EvalError::CycleDetected);
}
let order = toposort(&module.graph, None).map_err(|_| EvalError::CycleDetected)?;
let (dp_total, dp_bounds) = check_dp_bounds(module, &order, &self.budget)?;
let mut account = Accountant {
spent: dp_total,
limit: self.budget.max_total_value_size,
};
let mut actuals: HashMap<NodeId, u64> = HashMap::new();
let mut values: HashMap<NodeId, Value> = HashMap::new();
let mut diagnostics = module.diagnostics.clone();
diagnostics.extend(module.healing.iter().cloned());
let mut visited = 0u64;
let mut fp_cache: Vec<Option<u64>> = if memo.is_some() {
vec![None; module.arena.len()]
} else {
Vec::new()
};
for node_idx in order {
visited += 1;
if visited % 1024 == 0 {
if let Some(end) = deadline {
if Instant::now() >= end {
let node_id = module.graph[node_idx];
return Err(EvalError::budget_exceeded(
BudgetKind::WallClock,
self.budget.max_wall_ms,
module,
Some(node_id),
));
}
}
}
let node_id = module.graph[node_idx];
let node = module.get(node_id).ok_or(EvalError::MissingNode(node_id))?;
let mut memo_fp = None;
if let Some(memo) = memo.as_mut() {
let salt = memo.salt;
if let Some(fp) = fingerprint_one(self, module, node_id, &fp_cache, salt) {
if let Ok(index) = usize::try_from(node_id.0) {
if let Some(slot) = fp_cache.get_mut(index) {
*slot = Some(fp);
}
}
memo_fp = Some(fp);
if let Some((value, diags)) = memo.map.get(&fp) {
values.insert(node_id, value.clone());
let span = module.node_spans.get(&node_id).cloned();
let file = module.node_files.get(&node_id).cloned();
diagnostics.extend(diags.iter().map(|d| {
let mut fresh = d.clone();
fresh.span = span.clone();
fresh.file = file.clone();
fresh
}));
memo.last_memo_hits += 1;
continue;
}
}
}
let diag_mark = diagnostics.len();
let value = match node {
Node::Literal(v) => v.clone(),
Node::Env { name, default } => match self.env.get(name) {
Some(s) => {
actuals.insert(node_id, 1u64.saturating_add(s.len() as u64));
account.charge(module, Some(node_id), s.len() as u64)?;
Value::String(s.clone())
}
None => {
if let Some(def) = default {
values.get(def).cloned().unwrap_or(Value::Null)
} else {
Value::Null
}
}
},
Node::Secret { locator } => {
let loc = values.get(locator).cloned().unwrap_or(Value::Null);
let secret = match loc {
Value::String(s) => Value::Secret(s),
_ => Value::Secret(String::new()),
};
if let Value::Secret(s) = &secret {
let actual = 1u64.saturating_add(s.len() as u64);
actuals.insert(node_id, actual);
account.charge(module, Some(node_id), s.len() as u64)?;
}
secret
}
Node::Ref(id) => {
let v = values.get(id).cloned().unwrap_or(Value::Null);
if let Some(actual) = actuals.get(id) {
let bound = dp_bounds.get(id.0 as usize).copied().unwrap_or(1);
account.charge(module, Some(node_id), actual.saturating_sub(bound))?;
}
v
}
Node::Semantic { inner, .. } => values.get(inner).cloned().unwrap_or(Value::Null),
Node::SchemaField { .. } | Node::SchemaTable { .. } | Node::SchemaArray { .. } => {
Value::Null
}
Node::Ident(name) => {
diagnostics.push(Diagnostic {
severity: Severity::Warning,
code: "W001".into(),
message: format!("unresolved identifier '{}'", name),
path: Some(name.clone()),
span: module.node_spans.get(&node_id).cloned(),
repair: None,
confidence: 1.0,
file: module.node_files.get(&node_id).cloned(),
});
Value::Null
}
Node::Interpolate { parts } => {
let mut out = String::new();
for part in parts {
match part {
InterpPart::Literal(s) => out.push_str(s),
InterpPart::Expr(id) => {
if let Some(v) = values.get(id) {
match v {
Value::String(s) => out.push_str(s),
Value::Int(i) => out.push_str(&i.to_string()),
Value::Float(f) => out.push_str(&f.to_string()),
Value::Bool(b) => out.push_str(&b.to_string()),
Value::DurationMs(ms) => out.push_str(&format!("{}ms", ms)),
Value::Quantity(n, unit) => {
out.push_str(&format!("{}{}", n, unit.suffix()))
}
Value::Secret(_) => {
return Err(EvalError::secret_leak(module, node_id));
}
Value::Null => out.push_str("null"),
Value::Array(_) | Value::Table(_) => {
return Err(EvalError::type_error(
format!(
"cannot interpolate a {} into a string",
match v {
Value::Array(_) => "array",
_ => "table",
}
),
module,
node_id,
));
}
}
} else {
out.push_str("null");
}
}
}
}
Value::String(out)
}
Node::Binary { op, lhs, rhs } => {
let l = values.get(lhs).cloned().unwrap_or(Value::Null);
let r = values.get(rhs).cloned().unwrap_or(Value::Null);
eval_binary(*op, &l, &r)
.map_err(|m| EvalError::type_error(m, module, node_id))?
}
Node::Unary { op, expr } => {
let v = values.get(expr).cloned().unwrap_or(Value::Null);
eval_unary(*op, &v).map_err(|m| EvalError::type_error(m, module, node_id))?
}
Node::Array { elements } => {
let els: Vec<Value> = elements
.iter()
.map(|e| values.get(e).cloned().unwrap_or(Value::Null))
.collect();
Value::Array(els)
}
Node::Table { fields } => {
let mut map = indexmap::IndexMap::new();
for (k, id) in fields {
map.insert(k.clone(), values.get(id).cloned().unwrap_or(Value::Null));
}
Value::Table(map)
}
Node::Quantity { value, unit } => {
let v = values.get(value).cloned().unwrap_or(Value::Null);
normalize_quantity(&v, unit)
.map_err(|m| EvalError::type_error(m, module, node_id))?
}
_ => Value::Null,
};
if let Some(memo) = memo.as_mut() {
memo.last_eval_count += 1;
if let Some(fp) = memo_fp {
let fresh = diagnostics[diag_mark..].to_vec();
memo.map.insert(fp, (value.clone(), fresh));
}
}
values.insert(node_id, value);
}
let root = module.root.ok_or(EvalError::MissingRoot)?;
let result = values.get(&root).cloned().unwrap_or(Value::Null);
let result = self.apply_profile(module, &values, result, &mut diagnostics, &mut account)?;
let mut result = result;
self.apply_patches(module, &values, &mut result, &mut diagnostics, &mut account)?;
if !module.patches.is_empty() && self.budget.max_depth != 0 {
check_result_depth(&result, self.budget.max_depth)?;
}
self.validate_schema(
module,
&values,
&mut result,
&mut diagnostics,
BudgetCx {
deadline,
wall_limit: self.budget.max_wall_ms,
account: &mut account,
},
)?;
self.validate_annotations(
module,
&values,
&result,
&mut diagnostics,
deadline,
self.budget.max_wall_ms,
)?;
if let Some(end) = deadline {
if Instant::now() >= end {
return Err(EvalError::budget_exceeded(
BudgetKind::WallClock,
self.budget.max_wall_ms,
module,
None,
));
}
}
Ok((result, diagnostics))
}
fn check_immutable(
module: &Module,
patch_id: NodeId,
targets: &[&str],
) -> Result<(), EvalError> {
for target in targets {
if let Some(immutable) = Self::immutable_overlap(module, target) {
return Err(EvalError::patch_error(
format!("patch target `{target}` overlaps ai_immutable `{immutable}`"),
module,
patch_id,
));
}
}
Ok(())
}
fn immutable_overlap(module: &Module, target: &str) -> Option<String> {
let target_segs = validate_patch_path(target).ok()?;
let mut paths: Vec<&String> = module
.annotations
.values()
.flatten()
.filter(|a| matches!(&a.kind, crate::ir::AnnotationKind::AiImmutable(true)))
.map(|a| &a.path)
.collect();
paths.sort();
for apath in paths {
let Ok(asegs) = validate_patch_path(apath) else {
continue;
};
let shared = target_segs.len().min(asegs.len());
if shared > 0 && target_segs[..shared] == asegs[..shared] {
return Some(apath.clone());
}
}
None
}
fn apply_patches(
&self,
module: &Module,
values: &HashMap<NodeId, Value>,
result: &mut Value,
diagnostics: &mut Vec<Diagnostic>,
account: &mut Accountant,
) -> Result<(), EvalError> {
use crate::ir::PatchOp;
for &patch_id in &module.patches {
let (op, span, file) = match module.get(patch_id) {
Some(Node::PatchDirective { op }) => (
op.clone(),
module.node_spans.get(&patch_id).cloned(),
module.node_files.get(&patch_id).cloned(),
),
_ => continue,
};
let mut warn = |code: &str, message: String| {
diagnostics.push(Diagnostic {
severity: Severity::Warning,
code: code.into(),
message,
path: None,
span: span.clone(),
repair: None,
confidence: 1.0,
file: file.clone(),
});
};
match op {
PatchOp::Set { path, value } => {
Self::check_immutable(module, patch_id, &[&path])?;
let segs = validate_patch_path(&path).map_err(|why| {
EvalError::patch_error(
format!("invalid patch path `{path}`: {why}"),
module,
patch_id,
)
})?;
let v = values.get(&value).cloned().unwrap_or(Value::Null);
account.charge(
module,
Some(patch_id),
measure_value(&v).saturating_add(segs.len() as u64),
)?;
let snapshot = result.clone();
if !patch_set(result, &segs, v) {
*result = snapshot;
warn(
"W004",
format!("patch target `{path}` is blocked or out of bounds"),
);
}
}
PatchOp::Remove { path } => {
Self::check_immutable(module, patch_id, &[&path])?;
let segs = validate_patch_path(&path).map_err(|why| {
EvalError::patch_error(
format!("invalid patch path `{path}`: {why}"),
module,
patch_id,
)
})?;
if !patch_remove(result, &segs) {
warn("W004", format!("remove target `{path}` does not exist"));
}
}
PatchOp::Rename { from, to } => {
Self::check_immutable(module, patch_id, &[&from, &to])?;
let segs = validate_patch_path(&from).map_err(|why| {
EvalError::patch_error(
format!("invalid patch path `{from}`: {why}"),
module,
patch_id,
)
})?;
match patch_rename(result, &segs, &to) {
RenameOutcome::Ok => {}
RenameOutcome::Missing => {
warn("W004", format!("rename source `{from}` does not exist"));
}
RenameOutcome::Exists => {
return Err(EvalError::patch_error(
format!("rename target `{to}` already exists"),
module,
patch_id,
));
}
}
}
}
}
Ok(())
}
fn apply_profile(
&self,
module: &Module,
values: &HashMap<NodeId, Value>,
result: Value,
diagnostics: &mut Vec<Diagnostic>,
account: &mut Accountant,
) -> Result<Value, EvalError> {
if module.profiles.is_empty() {
let selected: Option<String> = match &self.profile_override {
Some(name) => Some(name.clone()),
None => match &result {
Value::Table(map) => match map.get("active_profile") {
Some(Value::String(s)) => Some(s.clone()),
Some(_) => {
diagnostics.push(Diagnostic {
severity: Severity::Warning,
code: "W002".into(),
message: "active_profile must be a string".into(),
path: Some("active_profile".into()),
span: if self.profile_override.is_some() {
None
} else {
active_key_span(module, &result)
},
repair: None,
confidence: 1.0,
file: if self.profile_override.is_some() {
None
} else {
active_key_file(module, &result)
},
});
return Ok(result);
}
None => None,
},
_ => None,
},
};
if let Some(name) = selected {
diagnostics.push(Diagnostic {
severity: Severity::Warning,
code: "W002".into(),
message: format!("unknown profile '{name}'"),
path: Some("active_profile".into()),
span: if self.profile_override.is_some() {
None
} else {
active_key_span(module, &result)
},
repair: None,
confidence: 1.0,
file: if self.profile_override.is_some() {
None
} else {
active_key_file(module, &result)
},
});
}
return Ok(result);
}
let base_table = match &result {
Value::Table(map) => map.clone(),
_ => return Ok(result),
};
let selected: Option<String> = match &self.profile_override {
Some(name) => Some(name.clone()),
None => match base_table.get("active_profile") {
None => return Ok(result),
Some(Value::String(s)) => Some(s.clone()),
Some(other) => {
diagnostics.push(Diagnostic {
severity: Severity::Warning,
code: "W002".into(),
message: format!(
"active_profile must be a string, got {}",
value_kind_name(other)
),
path: Some("active_profile".into()),
span: if self.profile_override.is_some() {
None
} else {
active_key_span(module, &result)
},
repair: None,
confidence: 1.0,
file: if self.profile_override.is_some() {
None
} else {
active_key_file(module, &result)
},
});
return Ok(result);
}
},
};
let name = match selected {
Some(n) => n,
None => return Ok(result),
};
let Some(&profile_id) = module.profiles.get(&name) else {
diagnostics.push(Diagnostic {
severity: Severity::Warning,
code: "W002".into(),
message: format!("unknown profile '{name}'"),
path: Some("active_profile".into()),
span: if self.profile_override.is_some() {
None
} else {
active_key_span(module, &result)
},
repair: None,
confidence: 1.0,
file: if self.profile_override.is_some() {
None
} else {
active_key_file(module, &result)
},
});
return Ok(result);
};
let profile_value = values.get(&profile_id).cloned().unwrap_or(Value::Null);
account.charge(module, Some(profile_id), measure_value(&profile_value))?;
let mut merged = base_table;
merge_profile_value(
&mut merged,
profile_value,
&name,
String::new(),
module.node_spans.get(&profile_id).cloned(),
module.node_files.get(&profile_id).cloned(),
diagnostics,
);
Ok(Value::Table(merged))
}
}
fn active_key_span(module: &Module, result: &Value) -> Option<crate::diag::Span> {
let Value::Table(_) = result else {
return None;
};
let root = module.root?;
let Node::Table { fields } = module.get(root)? else {
return None;
};
let id = fields.get("active_profile")?;
module.node_spans.get(id).cloned()
}
fn active_key_file(module: &Module, result: &Value) -> Option<String> {
let Value::Table(_) = result else {
return None;
};
let root = module.root?;
let Node::Table { fields } = module.get(root)? else {
return None;
};
let id = fields.get("active_profile")?;
module.node_files.get(id).cloned()
}
pub(crate) fn value_kind_name(v: &Value) -> String {
match v {
Value::Null => "null".into(),
Value::Bool(_) => "boolean".into(),
Value::Int(_) | Value::Float(_) => "number".into(),
Value::String(_) => "string".into(),
Value::DurationMs(_) => "duration".into(),
Value::Quantity(_, unit) => format!("quantity:{}", unit.suffix()),
Value::Array(_) => "array".into(),
Value::Table(_) => "table".into(),
Value::Secret(_) => "secret".into(),
}
}
fn merge_profile_value(
base: &mut indexmap::IndexMap<String, Value>,
overlay: Value,
profile: &str,
path: String,
span: Option<crate::diag::Span>,
file: Option<String>,
diagnostics: &mut Vec<Diagnostic>,
) {
let Value::Table(fields) = overlay else {
return;
};
for (k, v) in fields {
let child = if path.is_empty() {
k.clone()
} else {
format!("{path}.{k}")
};
match base.get(&k) {
None => {
base.insert(k, v);
}
Some(existing) => match (existing, &v) {
(Value::Table(_), Value::Table(_)) => {
if let Some(Value::Table(base_sub)) = base.get_mut(&k) {
merge_profile_value(
base_sub,
v,
profile,
child,
span.clone(),
file.clone(),
diagnostics,
);
}
}
_ => {
if value_kind_name(existing) != value_kind_name(&v) {
diagnostics.push(Diagnostic {
severity: Severity::Warning,
code: "W003".into(),
message: format!(
"profile '{profile}' overrides '{child}' with a different type"
),
path: Some(child),
span: span.clone(),
repair: None,
confidence: 1.0,
file: file.clone(),
});
}
base.insert(k, v);
}
},
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum RenameOutcome {
Ok,
Missing,
Exists,
}
fn patch_parent<'a>(root: &'a mut Value, segs: &[PathSeg], create: bool) -> Option<&'a mut Value> {
let mut cur = root;
let last = segs.len().checked_sub(1)?;
let mut i = 0;
while i < last {
match &segs[i] {
PathSeg::Key(k) => {
let need_array = matches!(segs.get(i + 1), Some(PathSeg::Index(_)));
cur = match cur {
Value::Table(map) => {
if !create && !map.contains_key(k) {
return None;
}
map.entry(k.clone()).or_insert_with(|| {
if need_array {
Value::Array(Vec::new())
} else {
Value::Table(indexmap::IndexMap::new())
}
})
}
_ => return None,
};
}
PathSeg::Index(idx) => {
cur = match cur {
Value::Array(items) => items.get_mut(*idx)?,
_ => return None,
};
}
}
i += 1;
}
Some(cur)
}
fn patch_set(root: &mut Value, segs: &[PathSeg], value: Value) -> bool {
let Some(last) = segs.last() else {
return false;
};
let Some(parent) = patch_parent(root, segs, true) else {
return false;
};
match (parent, last) {
(Value::Table(map), PathSeg::Key(k)) => {
map.insert(k.clone(), value);
true
}
(Value::Array(items), PathSeg::Index(i)) => {
if let Some(slot) = items.get_mut(*i) {
*slot = value;
true
} else {
false
}
}
_ => false,
}
}
fn patch_remove(root: &mut Value, segs: &[PathSeg]) -> bool {
let Some(last) = segs.last() else {
return false;
};
let Some(parent) = patch_parent(root, segs, false) else {
return false;
};
match (parent, last) {
(Value::Table(map), PathSeg::Key(k)) => map.shift_remove(k).is_some(),
(Value::Array(items), PathSeg::Index(i)) if *i < items.len() => {
items.remove(*i);
true
}
_ => false,
}
}
fn patch_rename(root: &mut Value, segs: &[PathSeg], new_name: &str) -> RenameOutcome {
let Some(last) = segs.last() else {
return RenameOutcome::Missing;
};
if !matches!(last, PathSeg::Key(_)) {
return RenameOutcome::Missing;
}
let Some(parent) = patch_parent(root, segs, false) else {
return RenameOutcome::Missing;
};
let Value::Table(map) = parent else {
return RenameOutcome::Missing;
};
let PathSeg::Key(old) = last else {
return RenameOutcome::Missing;
};
if !map.contains_key(old) {
return RenameOutcome::Missing;
}
if map.contains_key(new_name) {
return RenameOutcome::Exists;
}
match map.shift_remove(old) {
Some(v) => {
map.insert(new_name.to_string(), v);
RenameOutcome::Ok
}
None => RenameOutcome::Missing,
}
}
fn normalize_quantity(v: &Value, unit: &Unit) -> Result<Value, String> {
let num = match v {
Value::Int(n) => *n as f64,
Value::Float(f) => *f,
_ => {
return Err(format!("quantity requires numeric value, got {v:?}"));
}
};
if let Some(ms) = unit.normalize_time_to_ms(num) {
return Ok(Value::DurationMs(ms));
}
if let Some(bytes) = unit.normalize_to_bytes(num) {
return Ok(Value::Int(i64::try_from(bytes).unwrap_or(i64::MAX)));
}
Ok(Value::Quantity(num, unit.clone()))
}
pub(crate) fn eval_binary(op: BinOp, lhs: &Value, rhs: &Value) -> Result<Value, String> {
use BinOp::*;
match (op, lhs, rhs) {
(Add, Value::Int(a), Value::Int(b)) => Ok(Value::Int(a.saturating_add(*b))),
(Sub, Value::Int(a), Value::Int(b)) => Ok(Value::Int(a.saturating_sub(*b))),
(Mul, Value::Int(a), Value::Int(b)) => Ok(Value::Int(a.saturating_mul(*b))),
(Div, Value::Int(a), Value::Int(b)) if *b != 0 => {
Ok(Value::Int(a.checked_div(*b).unwrap_or(i64::MAX)))
}
(Add, Value::Float(a), Value::Float(b)) => Ok(Value::Float(a + b)),
(Sub, Value::Float(a), Value::Float(b)) => Ok(Value::Float(a - b)),
(Mul, Value::Float(a), Value::Float(b)) => Ok(Value::Float(a * b)),
(Div, Value::Float(a), Value::Float(b)) if *b != 0.0 => Ok(Value::Float(a / b)),
(Add, Value::Int(a), Value::Float(b)) => Ok(Value::Float(*a as f64 + b)),
(Add, Value::Float(a), Value::Int(b)) => Ok(Value::Float(a + *b as f64)),
(Mul, Value::Int(a), Value::Float(b)) => Ok(Value::Float(*a as f64 * b)),
(Mul, Value::Float(a), Value::Int(b)) => Ok(Value::Float(a * *b as f64)),
(Eq, a, b) => Ok(Value::Bool(value_eq(a, b))),
(Ne, a, b) => Ok(Value::Bool(!value_eq(a, b))),
(Lt, Value::Int(a), Value::Int(b)) => Ok(Value::Bool(a < b)),
(Le, Value::Int(a), Value::Int(b)) => Ok(Value::Bool(a <= b)),
(Gt, Value::Int(a), Value::Int(b)) => Ok(Value::Bool(a > b)),
(Ge, Value::Int(a), Value::Int(b)) => Ok(Value::Bool(a >= b)),
(Add, Value::DurationMs(a), Value::DurationMs(b)) => {
Ok(Value::DurationMs(a.saturating_add(*b)))
}
(Sub, Value::DurationMs(a), Value::DurationMs(b)) => {
Ok(Value::DurationMs(a.saturating_sub(*b)))
}
(Mul, Value::DurationMs(a), Value::Int(b)) => {
Ok(Value::DurationMs(scale_dur(*a, *b as f64)))
}
(Mul, Value::Int(a), Value::DurationMs(b)) => {
Ok(Value::DurationMs(scale_dur(*b, *a as f64)))
}
(Mul, Value::DurationMs(a), Value::Float(b)) => Ok(Value::DurationMs(scale_dur(*a, *b))),
(Mul, Value::Float(a), Value::DurationMs(b)) => Ok(Value::DurationMs(scale_dur(*b, *a))),
(Div, Value::DurationMs(a), Value::Int(b)) if *b != 0 => {
Ok(Value::DurationMs(scale_dur(*a, 1.0 / *b as f64)))
}
(Div, Value::DurationMs(a), Value::Float(b)) if *b != 0.0 => {
Ok(Value::DurationMs(scale_dur(*a, 1.0 / *b)))
}
(Div, Value::DurationMs(a), Value::DurationMs(b)) if *b != 0 => {
Ok(Value::Int((*a / *b) as i64))
}
(Lt, Value::DurationMs(a), Value::DurationMs(b)) => Ok(Value::Bool(a < b)),
(Le, Value::DurationMs(a), Value::DurationMs(b)) => Ok(Value::Bool(a <= b)),
(Gt, Value::DurationMs(a), Value::DurationMs(b)) => Ok(Value::Bool(a > b)),
(Ge, Value::DurationMs(a), Value::DurationMs(b)) => Ok(Value::Bool(a >= b)),
(And, Value::Bool(a), Value::Bool(b)) => Ok(Value::Bool(*a && *b)),
(Or, Value::Bool(a), Value::Bool(b)) => Ok(Value::Bool(*a || *b)),
_ => Err(format!(
"unsupported binary op {op:?} on {lhs:?} and {rhs:?}"
)),
}
}
fn scale_dur(ms: u64, factor: f64) -> u64 {
(ms as f64 * factor).round().max(0.0) as u64
}
pub(crate) fn eval_unary(op: UnOp, v: &Value) -> Result<Value, String> {
match (op, v) {
(UnOp::Neg, Value::Int(n)) => Ok(Value::Int(n.saturating_neg())),
(UnOp::Neg, Value::Float(f)) => Ok(Value::Float(-f)),
(UnOp::Not, Value::Bool(b)) => Ok(Value::Bool(!b)),
_ => Err(format!("unsupported unary op {op:?} on {v:?}")),
}
}
fn value_eq(a: &Value, b: &Value) -> bool {
match (a, b) {
(Value::Null, Value::Null) => true,
(Value::Bool(x), Value::Bool(y)) => x == y,
(Value::Int(x), Value::Int(y)) => x == y,
(Value::Float(x), Value::Float(y)) => (x - y).abs() < f64::EPSILON,
(Value::String(x), Value::String(y)) => x == y,
(Value::DurationMs(x), Value::DurationMs(y)) => x == y,
(Value::Quantity(x, ux), Value::Quantity(y, uy)) => x == y && ux == uy,
_ => false,
}
}