use std::collections::HashMap;
use serde::Serialize;
use crate::dag::{toposort, Dag};
use crate::excel_error::ExcelError;
use crate::finding::{LintFinding, Severity};
use crate::formula::{BinOp, Expr, UnOp};
use crate::resolve;
use super::eval_bridge::{eval_leaf, from_json, percent, powf, to_json, CellEnv};
use super::eval_value::EvalValue;
use super::semantics;
use super::value::CellValue;
use super::{Cell, CellExpr};
#[derive(Debug, Clone, Default, Serialize, schemars::JsonSchema)]
pub struct EvalTrace {
pub cell: String,
pub formula: Option<String>,
pub resolved_refs: Vec<(String, CellValue)>,
pub dispatched_fn: Option<String>,
pub operand_values: Vec<CellValue>,
pub coercions: Vec<String>,
pub short_circuited: Option<ExcelError>,
}
impl EvalTrace {
fn new(cell: &str) -> Self {
EvalTrace {
cell: cell.to_string(),
..Default::default()
}
}
}
#[derive(Debug, Clone, Default, Serialize, schemars::JsonSchema)]
pub struct RunResult {
pub computed: HashMap<String, CellValue>,
pub traces: HashMap<String, EvalTrace>,
}
fn owning_sheet(key: &str) -> &str {
key.split_once('!').map_or("", |(s, _)| s)
}
pub fn run(
ir: &HashMap<String, Cell>,
dag: &Dag,
seed: &CellEnv,
) -> Result<RunResult, Box<LintFinding>> {
let order = toposort(dag).map_err(|residual| {
let (sheet, cell) = residual
.first()
.and_then(|k| k.split_once('!'))
.map(|(s, a)| (s.to_string(), Some(a.to_string())))
.unwrap_or_default();
Box::new(LintFinding::new(
Severity::Error,
"dag/cycle",
sheet,
cell,
format!("dependency cycle through cells: {}", residual.join(" → ")),
"break the cycle by removing one of the circular references",
))
})?;
let mut env = seed.clone();
let mut errs: HashMap<String, ExcelError> = HashMap::new();
let mut computed: HashMap<String, CellValue> = HashMap::new();
let mut traces: HashMap<String, EvalTrace> = HashMap::new();
for key in order {
match ir.get(&key) {
Some(Cell {
expr: CellExpr::Literal(v),
..
}) => {
if env.get(&key).is_none() {
env = env.seed_cell(&key, v);
}
if let CellValue::Error(err) = v {
errs.insert(key.clone(), *err);
}
computed.insert(key.clone(), v.clone());
},
Some(Cell {
expr: CellExpr::Formula(e),
..
}) => {
let mut trace = EvalTrace::new(&key);
let current_sheet = owning_sheet(&key);
let result = eval_expr(e, &env, &errs, current_sheet, &mut trace);
if let CellValue::Error(err) = &result {
errs.insert(key.clone(), *err);
trace.short_circuited.get_or_insert(*err);
}
if let Some(j) = to_json(&result) {
env = env.with_value(&key, j);
}
computed.insert(key.clone(), result);
traces.insert(key.clone(), trace);
},
None => {},
}
}
Ok(RunResult { computed, traces })
}
fn eval_expr(
e: &Expr,
env: &CellEnv,
errs: &HashMap<String, ExcelError>,
current_sheet: &str,
trace: &mut EvalTrace,
) -> CellValue {
trace.formula.get_or_insert_with(|| format!("{e:?}"));
let mut ctx = Ctx {
env,
errs,
current_sheet,
trace,
};
match e {
Expr::Call { name, args } => {
eval_call(name, args, ctx.env, ctx.errs, ctx.current_sheet, ctx.trace)
},
Expr::BinaryOp { left, op, right } => eval_binary_op(e, left, *op, right, &mut ctx),
Expr::UnaryOp { op, operand } => eval_unary_op(e, *op, operand, &mut ctx),
other => {
record_refs(other, ctx.env, ctx.current_sheet, ctx.trace);
eval_leaf(other, ctx.env, ctx.errs)
},
}
}
fn eval_call(
name: &str,
args: &[Expr],
env: &CellEnv,
errs: &HashMap<String, ExcelError>,
current_sheet: &str,
trace: &mut EvalTrace,
) -> CellValue {
let vals: Vec<EvalValue> = args
.iter()
.map(|a| materialize_arg(a, env, errs, current_sheet, trace))
.collect();
trace.dispatched_fn = Some(name.to_string());
for v in &vals {
record_operand_values(v, trace);
}
semantics::apply(name, &vals)
}
fn record_operand_values(v: &EvalValue, trace: &mut EvalTrace) {
match v {
EvalValue::Scalar(cv) => trace.operand_values.push(cv.clone()),
EvalValue::Range(rows) => {
for row in rows {
for cv in row {
trace.operand_values.push(cv.clone());
}
}
},
}
}
struct Ctx<'a> {
env: &'a CellEnv,
errs: &'a HashMap<String, ExcelError>,
current_sheet: &'a str,
trace: &'a mut EvalTrace,
}
impl Ctx<'_> {
fn eval(&mut self, e: &Expr) -> CellValue {
eval_expr(e, self.env, self.errs, self.current_sheet, self.trace)
}
}
fn eval_binary_op(e: &Expr, left: &Expr, op: BinOp, right: &Expr, ctx: &mut Ctx) -> CellValue {
if matches!(op, BinOp::Pow) {
let lv = ctx.eval(left);
let rv = ctx.eval(right);
return match (semantics::to_number(&lv), semantics::to_number(&rv)) {
(Ok(b), Ok(x)) => finite_or_num(powf(b, x)),
(Err(e), _) | (_, Err(e)) => CellValue::Error(e),
};
}
if is_leaf_lowerable(left) && is_leaf_lowerable(right) {
record_refs(e, ctx.env, ctx.current_sheet, ctx.trace);
return eval_leaf(e, ctx.env, ctx.errs);
}
let l = ctx.eval(left);
let r = ctx.eval(right);
let lowered = Expr::BinaryOp {
left: Box::new(scalar_to_leaf(&l)),
op,
right: Box::new(scalar_to_leaf(&r)),
};
eval_leaf(&lowered, ctx.env, ctx.errs)
}
fn eval_unary_op(e: &Expr, op: UnOp, operand: &Expr, ctx: &mut Ctx) -> CellValue {
if matches!(op, UnOp::Percent) {
let v = ctx.eval(operand);
return match semantics::to_number(&v) {
Ok(x) => finite_or_num(percent(x)),
Err(e) => CellValue::Error(e),
};
}
if is_leaf_lowerable(operand) {
record_refs(e, ctx.env, ctx.current_sheet, ctx.trace);
return eval_leaf(e, ctx.env, ctx.errs);
}
let v = ctx.eval(operand);
let lowered = Expr::UnaryOp {
op,
operand: Box::new(scalar_to_leaf(&v)),
};
eval_leaf(&lowered, ctx.env, ctx.errs)
}
fn finite_or_num(n: f64) -> CellValue {
if n.is_nan() {
CellValue::Error(ExcelError::DivZero)
} else if !n.is_finite() {
CellValue::Error(ExcelError::Num)
} else {
CellValue::Number(n)
}
}
fn is_leaf_lowerable(e: &Expr) -> bool {
match e {
Expr::Ref(_) | Expr::Number(_) | Expr::Str(_) | Expr::Bool(_) | Expr::ErrorLit(_) => true,
Expr::Range(_) | Expr::Name(_) | Expr::Call { .. } => false,
Expr::BinaryOp { left, op, right } => {
!matches!(op, BinOp::Pow) && is_leaf_lowerable(left) && is_leaf_lowerable(right)
},
Expr::UnaryOp { op, operand } => !matches!(op, UnOp::Percent) && is_leaf_lowerable(operand),
}
}
fn scalar_to_leaf(cv: &CellValue) -> Expr {
match cv {
CellValue::Number(n) => Expr::Number(*n),
CellValue::Text(s) => Expr::Str(s.clone()),
CellValue::Bool(b) => Expr::Bool(*b),
CellValue::Empty => Expr::Number(0.0), CellValue::Error(e) => Expr::ErrorLit(*e),
}
}
fn materialize_arg(
a: &Expr,
env: &CellEnv,
errs: &HashMap<String, ExcelError>,
current_sheet: &str,
trace: &mut EvalTrace,
) -> EvalValue {
match a {
Expr::Range(range) => match resolve::expand_range(range, current_sheet) {
Ok((keys, shape)) => build_range(&keys, shape, env, errs, trace),
Err(_) => {
trace.short_circuited.get_or_insert(ExcelError::Ref);
EvalValue::Scalar(CellValue::Error(ExcelError::Ref))
},
},
Expr::Name(_) => EvalValue::Scalar(CellValue::Error(ExcelError::Name)),
scalar => EvalValue::Scalar(eval_expr(scalar, env, errs, current_sheet, trace)),
}
}
fn build_range(
keys: &[String],
shape: resolve::RangeShape,
env: &CellEnv,
errs: &HashMap<String, ExcelError>,
trace: &mut EvalTrace,
) -> EvalValue {
let rows = shape.rows as usize;
let cols = shape.cols as usize;
let mut out: Vec<Vec<CellValue>> = Vec::with_capacity(rows);
for r in 0..rows {
let mut row_cells: Vec<CellValue> = Vec::with_capacity(cols);
for c in 0..cols {
let key = &keys[c * rows + r];
let cv = match env_lookup(env, key) {
Some(cv) => cv,
None => match errs.get(key) {
Some(e) => CellValue::Error(*e),
None => {
trace.short_circuited.get_or_insert(ExcelError::Ref);
CellValue::Error(ExcelError::Ref)
},
},
};
trace.resolved_refs.push((key.clone(), cv.clone()));
row_cells.push(cv);
}
out.push(row_cells);
}
EvalValue::Range(out)
}
fn collect_ref_keys(e: &Expr, current_sheet: &str, out: &mut Vec<String>) {
match e {
Expr::Ref(addr) => out.push(addr.clone()),
Expr::Range(range) => {
if let Ok((keys, _shape)) = resolve::expand_range(range, current_sheet) {
out.extend(keys);
}
},
Expr::BinaryOp { left, right, .. } => {
collect_ref_keys(left, current_sheet, out);
collect_ref_keys(right, current_sheet, out);
},
Expr::UnaryOp { operand, .. } => collect_ref_keys(operand, current_sheet, out),
Expr::Call { args, .. } => {
for a in args {
collect_ref_keys(a, current_sheet, out);
}
},
Expr::Number(_) | Expr::Str(_) | Expr::Bool(_) | Expr::Name(_) | Expr::ErrorLit(_) => {},
}
}
fn record_refs(e: &Expr, env: &CellEnv, current_sheet: &str, trace: &mut EvalTrace) {
let mut keys = Vec::new();
collect_ref_keys(e, current_sheet, &mut keys);
for key in keys {
let cv = env_lookup(env, &key).unwrap_or(CellValue::Empty);
trace.resolved_refs.push((key, cv));
}
}
pub fn build_dag(ir: &HashMap<String, Cell>) -> Dag {
let mut dag = Dag::new();
for (key, cell) in ir {
dag.add_node(key);
if let CellExpr::Formula(e) = &cell.expr {
let current_sheet = owning_sheet(key);
let mut deps = Vec::new();
collect_ref_keys(e, current_sheet, &mut deps);
for dep in deps {
dag.add_edge(key, &dep);
}
}
}
dag
}
fn env_lookup(env: &CellEnv, key: &str) -> Option<CellValue> {
env.get(key).map(from_json)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::formula::BinOp;
use crate::range_ref::RangeRef;
fn lit(key: &str, n: f64) -> (String, Cell) {
(
key.to_string(),
Cell {
key: key.to_string(),
expr: CellExpr::Literal(CellValue::Number(n)),
},
)
}
fn formula(key: &str, e: Expr) -> (String, Cell) {
(
key.to_string(),
Cell {
key: key.to_string(),
expr: CellExpr::Formula(e),
},
)
}
fn dag_of(edges: &[(&str, &[&str])]) -> Dag {
let mut dag = Dag::new();
for (cell, deps) in edges {
dag.add_node(cell);
for d in *deps {
dag.add_edge(cell, d);
}
}
dag
}
fn range(sheet: &str, start: &str, end: &str) -> Expr {
Expr::Range(RangeRef {
sheet: sheet.to_string(),
start: start.to_string(),
end: end.to_string(),
})
}
fn call(name: &str, args: Vec<Expr>) -> Expr {
Expr::Call {
name: name.to_string(),
args,
}
}
#[test]
fn literal_is_seeded_and_readable_downstream() {
let ir: HashMap<String, Cell> = [
lit("S!A1", 3.0),
formula("S!B1", Expr::Ref("S!A1".to_string())),
]
.into_iter()
.collect();
let dag = dag_of(&[("S!A1", &[]), ("S!B1", &["S!A1"])]);
let out = run(&ir, &dag, &CellEnv::new()).expect("no cycle");
assert_eq!(out.computed.get("S!B1"), Some(&CellValue::Number(3.0)));
}
#[test]
fn leaf_arithmetic_computes_via_pure_rust() {
let ir: HashMap<String, Cell> = [
lit("S!A1", 10.0),
lit("S!A2", 5.0),
formula(
"S!C1",
Expr::BinaryOp {
left: Box::new(Expr::Ref("S!A1".to_string())),
op: BinOp::Add,
right: Box::new(Expr::Ref("S!A2".to_string())),
},
),
]
.into_iter()
.collect();
let dag = dag_of(&[("S!A1", &[]), ("S!A2", &[]), ("S!C1", &["S!A1", "S!A2"])]);
let out = run(&ir, &dag, &CellEnv::new()).expect("no cycle");
assert_eq!(out.computed.get("S!C1"), Some(&CellValue::Number(15.0)));
}
#[test]
fn cycle_is_one_located_finding_not_a_panic() {
let ir: HashMap<String, Cell> = [
formula("S!A1", Expr::Ref("S!B1".to_string())),
formula("S!B1", Expr::Ref("S!A1".to_string())),
]
.into_iter()
.collect();
let mut dag = Dag::new();
dag.add_edge("S!A1", "S!B1");
dag.add_edge("S!B1", "S!A1");
let err = run(&ir, &dag, &CellEnv::new()).expect_err("a cycle must be Err");
assert_eq!(err.rule, "dag/cycle");
assert_eq!(err.severity, Severity::Error);
}
#[test]
fn eval_trace_records_resolved_refs() {
let ir: HashMap<String, Cell> = [
lit("S!A1", 10.0),
lit("S!A2", 5.0),
formula(
"S!C1",
Expr::BinaryOp {
left: Box::new(Expr::Ref("S!A1".to_string())),
op: BinOp::Add,
right: Box::new(Expr::Ref("S!A2".to_string())),
},
),
]
.into_iter()
.collect();
let dag = dag_of(&[("S!A1", &[]), ("S!A2", &[]), ("S!C1", &["S!A1", "S!A2"])]);
let out = run(&ir, &dag, &CellEnv::new()).expect("no cycle");
let trace = out.traces.get("S!C1").expect("a trace for C1");
assert_eq!(
trace.resolved_refs,
vec![
("S!A1".to_string(), CellValue::Number(10.0)),
("S!A2".to_string(), CellValue::Number(5.0)),
]
);
}
#[test]
fn error_cell_short_circuits_downstream() {
let ir: HashMap<String, Cell> = [
(
"S!A1".to_string(),
Cell {
key: "S!A1".to_string(),
expr: CellExpr::Literal(CellValue::Error(ExcelError::Ref)),
},
),
formula(
"S!B1",
Expr::BinaryOp {
left: Box::new(Expr::Ref("S!A1".to_string())),
op: BinOp::Add,
right: Box::new(Expr::Number(1.0)),
},
),
]
.into_iter()
.collect();
let dag = dag_of(&[("S!A1", &[]), ("S!B1", &["S!A1"])]);
let out = run(&ir, &dag, &CellEnv::new()).expect("no cycle");
assert_eq!(
out.computed.get("S!B1"),
Some(&CellValue::Error(ExcelError::Ref))
);
}
#[test]
fn sum_range_1d_column_major() {
let ir: HashMap<String, Cell> = [
lit("S!B2", 10.0),
lit("S!B3", 20.0),
lit("S!B4", 30.0),
formula("S!C1", call("SUM", vec![range("S", "B2", "B4")])),
]
.into_iter()
.collect();
let dag = dag_of(&[
("S!B2", &[]),
("S!B3", &[]),
("S!B4", &[]),
("S!C1", &["S!B2", "S!B3", "S!B4"]),
]);
let out = run(&ir, &dag, &CellEnv::new()).expect("no cycle");
assert_eq!(out.computed.get("S!C1"), Some(&CellValue::Number(60.0)));
}
#[test]
fn sum_over_range_member_with_computed_error_propagates_that_error() {
let ir: HashMap<String, Cell> = [
lit("S!B2", 10.0),
formula("S!B3", Expr::ErrorLit(ExcelError::DivZero)),
lit("S!B4", 30.0),
formula("S!C1", call("SUM", vec![range("S", "B2", "B4")])),
]
.into_iter()
.collect();
let dag = dag_of(&[
("S!B2", &[]),
("S!B3", &[]),
("S!B4", &[]),
("S!C1", &["S!B2", "S!B3", "S!B4"]),
]);
let out = run(&ir, &dag, &CellEnv::new()).expect("no cycle");
assert_eq!(
out.computed.get("S!C1"),
Some(&CellValue::Error(ExcelError::DivZero)),
"the member's actual error propagates, not #REF!"
);
let trace = out.traces.get("S!C1").expect("a trace for C1");
assert!(
trace
.resolved_refs
.iter()
.any(|(k, v)| k == "S!B3" && *v == CellValue::Error(ExcelError::DivZero)),
"resolved_refs records S!B3 as #DIV/0!, got {:?}",
trace.resolved_refs
);
}
#[test]
fn nested_call_in_binary_op() {
let ir: HashMap<String, Cell> = [
lit("S!A1", 1.0),
lit("S!A2", 2.0),
lit("S!A3", 3.0),
lit("S!B1", 4.567),
formula(
"S!C1",
Expr::BinaryOp {
left: Box::new(call("SUM", vec![range("S", "A1", "A3")])),
op: BinOp::Add,
right: Box::new(call(
"ROUND",
vec![Expr::Ref("S!B1".to_string()), Expr::Number(2.0)],
)),
},
),
]
.into_iter()
.collect();
let dag = dag_of(&[
("S!A1", &[]),
("S!A2", &[]),
("S!A3", &[]),
("S!B1", &[]),
("S!C1", &["S!A1", "S!A2", "S!A3", "S!B1"]),
]);
let out = run(&ir, &dag, &CellEnv::new()).expect("no cycle");
match out.computed.get("S!C1") {
Some(CellValue::Number(n)) => assert!((n - 10.57).abs() < 1e-9, "got {n}"),
other => panic!("expected Number(10.57), got {other:?}"),
}
}
#[test]
fn pow_and_percent_are_not_in_the_evaluator() {
let ir: HashMap<String, Cell> = [
formula(
"S!C1",
Expr::BinaryOp {
left: Box::new(Expr::Number(2.0)),
op: BinOp::Pow,
right: Box::new(Expr::Number(3.0)),
},
),
formula(
"S!D1",
Expr::UnaryOp {
op: UnOp::Percent,
operand: Box::new(Expr::Number(50.0)),
},
),
]
.into_iter()
.collect();
let dag = dag_of(&[("S!C1", &[]), ("S!D1", &[])]);
let out = run(&ir, &dag, &CellEnv::new()).expect("no cycle");
assert_eq!(out.computed.get("S!C1"), Some(&CellValue::Number(8.0)));
assert_eq!(out.computed.get("S!D1"), Some(&CellValue::Number(0.5)));
}
#[test]
fn build_dag_includes_range_member_edges() {
let ir: HashMap<String, Cell> = [
lit("S!B2", 10.0),
lit("S!B3", 20.0),
lit("S!B4", 30.0),
formula("S!C1", call("SUM", vec![range("S", "B2", "B4")])),
]
.into_iter()
.collect();
let dag = build_dag(&ir);
let mut deps = dag.dependencies_of("S!C1").to_vec();
deps.sort();
assert_eq!(
deps,
vec!["S!B2".to_string(), "S!B3".to_string(), "S!B4".to_string()],
"every range member is a DAG edge (not dropped)"
);
let out = run(&ir, &dag, &CellEnv::new()).expect("no cycle");
assert_eq!(out.computed.get("S!C1"), Some(&CellValue::Number(60.0)));
}
#[test]
fn unqualified_range_defaults_to_the_owning_cells_sheet() {
let ir: HashMap<String, Cell> = [
lit("S!B2", 10.0),
lit("S!B3", 20.0),
lit("S!B4", 30.0),
formula("S!C1", call("SUM", vec![range("", "B2", "B4")])),
]
.into_iter()
.collect();
let dag = build_dag(&ir);
let mut deps = dag.dependencies_of("S!C1").to_vec();
deps.sort();
assert_eq!(
deps,
vec!["S!B2".to_string(), "S!B3".to_string(), "S!B4".to_string()],
"unqualified range edges are sheet-qualified, not phantom \"!B2\" nodes"
);
let out = run(&ir, &dag, &CellEnv::new()).expect("no cycle");
assert_eq!(out.computed.get("S!C1"), Some(&CellValue::Number(60.0)));
}
#[test]
fn build_dag_ref_edges_drive_a_correct_run() {
let ir: HashMap<String, Cell> = [
lit("S!A1", 3.0),
formula(
"S!B1",
Expr::BinaryOp {
left: Box::new(Expr::Ref("S!A1".to_string())),
op: BinOp::Add,
right: Box::new(Expr::Number(1.0)),
},
),
]
.into_iter()
.collect();
let dag = build_dag(&ir);
assert_eq!(dag.dependencies_of("S!B1"), &["S!A1".to_string()]);
let out = run(&ir, &dag, &CellEnv::new()).expect("no cycle");
assert_eq!(out.computed.get("S!B1"), Some(&CellValue::Number(4.0)));
}
#[test]
fn coil_band_ceiling_reconciles_700() {
let ir: HashMap<String, Cell> = [
formula(
"5_Quantities!C8",
call(
"CEILING",
vec![
Expr::BinaryOp {
left: Box::new(Expr::Ref("5_Quantities!C6".to_string())),
op: BinOp::Mul,
right: Box::new(Expr::Ref("2_Constants!C17".to_string())),
},
Expr::Ref("2_Constants!C18".to_string()),
],
),
),
lit("2_Constants!C17", 1.05),
lit("2_Constants!C18", 50.0),
]
.into_iter()
.collect();
let mut dag = Dag::new();
dag.add_node("5_Quantities!C6");
dag.add_node("2_Constants!C17");
dag.add_node("2_Constants!C18");
dag.add_edge("5_Quantities!C8", "5_Quantities!C6");
dag.add_edge("5_Quantities!C8", "2_Constants!C17");
dag.add_edge("5_Quantities!C8", "2_Constants!C18");
let seed = CellEnv::new().seed_cell("5_Quantities!C6", &CellValue::Number(666.0));
let out = run(&ir, &dag, &seed).expect("no cycle");
assert_eq!(
out.computed.get("5_Quantities!C8"),
Some(&CellValue::Number(700.0))
);
}
}