refactor: replace BinOp string with typed enum in Expr AST

Previously Expr::BinOp(String, ...) accepted any string as an operator.
Invalid operators (e.g. "diagonal") would compile fine and silently
return CellValue::Empty at eval time.

Now BinOp is an enum with variants Add/Sub/Mul/Div/Pow/Eq/Ne/Lt/Gt/Le/Ge.
The parser produces enum variants directly; the evaluator pattern-matches
exhaustively with no fallback branch. An invalid operator is now a
compile error at the call site, and the compiler ensures every variant
is handled in both eval_expr and eval_bool.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Ed L
2026-03-24 00:20:08 -07:00
parent 5434a60cc4
commit 599f1adcbd
4 changed files with 60 additions and 34 deletions

View File

@ -9,6 +9,25 @@ pub enum AggFunc {
Count, Count,
} }
/// Arithmetic and comparison operators used in binary expressions.
/// Having an enum (rather than a raw String) means the parser must
/// produce a valid operator; invalid operators are caught at parse
/// time rather than silently returning Empty at eval time.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum BinOp {
Add,
Sub,
Mul,
Div,
Pow,
Eq,
Ne,
Lt,
Gt,
Le,
Ge,
}
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Filter { pub struct Filter {
pub category: String, pub category: String,
@ -19,7 +38,7 @@ pub struct Filter {
pub enum Expr { pub enum Expr {
Number(f64), Number(f64),
Ref(String), Ref(String),
BinOp(String, Box<Expr>, Box<Expr>), BinOp(BinOp, Box<Expr>, Box<Expr>),
UnaryMinus(Box<Expr>), UnaryMinus(Box<Expr>),
Agg(AggFunc, Box<Expr>, Option<Filter>), Agg(AggFunc, Box<Expr>, Option<Filter>),
If(Box<Expr>, Box<Expr>, Box<Expr>), If(Box<Expr>, Box<Expr>, Box<Expr>),

View File

@ -1,5 +1,5 @@
pub mod parser; pub mod parser;
pub mod ast; pub mod ast;
pub use ast::{AggFunc, Expr, Formula}; pub use ast::{AggFunc, BinOp, Expr, Formula};
pub use parser::parse_formula; pub use parser::parse_formula;

View File

@ -1,6 +1,6 @@
use anyhow::{anyhow, Result}; use anyhow::{anyhow, Result};
use super::ast::{AggFunc, Expr, Filter, Formula}; use super::ast::{AggFunc, BinOp, Expr, Filter, Formula};
/// Parse a formula string like "Profit = Revenue - Cost" /// Parse a formula string like "Profit = Revenue - Cost"
/// or "Tax = Revenue * 0.08 WHERE Region = \"East\"" /// or "Tax = Revenue * 0.08 WHERE Region = \"East\""
@ -161,13 +161,13 @@ fn parse_add_sub(tokens: &[Token], pos: &mut usize) -> Result<Expr> {
let mut left = parse_mul_div(tokens, pos)?; let mut left = parse_mul_div(tokens, pos)?;
while *pos < tokens.len() { while *pos < tokens.len() {
let op = match &tokens[*pos] { let op = match &tokens[*pos] {
Token::Plus => "+", Token::Plus => BinOp::Add,
Token::Minus => "-", Token::Minus => BinOp::Sub,
_ => break, _ => break,
}; };
*pos += 1; *pos += 1;
let right = parse_mul_div(tokens, pos)?; let right = parse_mul_div(tokens, pos)?;
left = Expr::BinOp(op.to_string(), Box::new(left), Box::new(right)); left = Expr::BinOp(op, Box::new(left), Box::new(right));
} }
Ok(left) Ok(left)
} }
@ -176,13 +176,13 @@ fn parse_mul_div(tokens: &[Token], pos: &mut usize) -> Result<Expr> {
let mut left = parse_pow(tokens, pos)?; let mut left = parse_pow(tokens, pos)?;
while *pos < tokens.len() { while *pos < tokens.len() {
let op = match &tokens[*pos] { let op = match &tokens[*pos] {
Token::Star => "*", Token::Star => BinOp::Mul,
Token::Slash => "/", Token::Slash => BinOp::Div,
_ => break, _ => break,
}; };
*pos += 1; *pos += 1;
let right = parse_pow(tokens, pos)?; let right = parse_pow(tokens, pos)?;
left = Expr::BinOp(op.to_string(), Box::new(left), Box::new(right)); left = Expr::BinOp(op, Box::new(left), Box::new(right));
} }
Ok(left) Ok(left)
} }
@ -192,7 +192,7 @@ fn parse_pow(tokens: &[Token], pos: &mut usize) -> Result<Expr> {
if *pos < tokens.len() && tokens[*pos] == Token::Caret { if *pos < tokens.len() && tokens[*pos] == Token::Caret {
*pos += 1; *pos += 1;
let exp = parse_unary(tokens, pos)?; let exp = parse_unary(tokens, pos)?;
return Ok(Expr::BinOp("^".to_string(), Box::new(base), Box::new(exp))); return Ok(Expr::BinOp(BinOp::Pow, Box::new(base), Box::new(exp)));
} }
Ok(base) Ok(base)
} }
@ -298,30 +298,30 @@ fn parse_comparison(tokens: &[Token], pos: &mut usize) -> Result<Expr> {
let left = parse_add_sub(tokens, pos)?; let left = parse_add_sub(tokens, pos)?;
if *pos >= tokens.len() { return Ok(left); } if *pos >= tokens.len() { return Ok(left); }
let op = match &tokens[*pos] { let op = match &tokens[*pos] {
Token::Eq => "=", Token::Eq => BinOp::Eq,
Token::Ne => "!=", Token::Ne => BinOp::Ne,
Token::Lt => "<", Token::Lt => BinOp::Lt,
Token::Gt => ">", Token::Gt => BinOp::Gt,
Token::Le => "<=", Token::Le => BinOp::Le,
Token::Ge => ">=", Token::Ge => BinOp::Ge,
_ => return Ok(left), _ => return Ok(left),
}; };
*pos += 1; *pos += 1;
let right = parse_add_sub(tokens, pos)?; let right = parse_add_sub(tokens, pos)?;
Ok(Expr::BinOp(op.to_string(), Box::new(left), Box::new(right))) Ok(Expr::BinOp(op, Box::new(left), Box::new(right)))
} }
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::parse_formula; use super::parse_formula;
use crate::formula::{Expr, AggFunc}; use crate::formula::{AggFunc, BinOp, Expr};
#[test] #[test]
fn parse_simple_subtraction() { fn parse_simple_subtraction() {
let f = parse_formula("Profit = Revenue - Cost", "Measure").unwrap(); let f = parse_formula("Profit = Revenue - Cost", "Measure").unwrap();
assert_eq!(f.target, "Profit"); assert_eq!(f.target, "Profit");
assert_eq!(f.target_category, "Measure"); assert_eq!(f.target_category, "Measure");
assert!(matches!(f.expr, Expr::BinOp(ref op, _, _) if op == "-")); assert!(matches!(f.expr, Expr::BinOp(BinOp::Sub, _, _)));
} }
#[test] #[test]

View File

@ -193,15 +193,19 @@ impl Model {
model.evaluate(&new_key).as_f64() model.evaluate(&new_key).as_f64()
} }
Expr::BinOp(op, l, r) => { Expr::BinOp(op, l, r) => {
use crate::formula::BinOp;
let lv = eval_expr(l, context, model, target_category)?; let lv = eval_expr(l, context, model, target_category)?;
let rv = eval_expr(r, context, model, target_category)?; let rv = eval_expr(r, context, model, target_category)?;
Some(match op.as_str() { Some(match op {
"+" => lv + rv, BinOp::Add => lv + rv,
"-" => lv - rv, BinOp::Sub => lv - rv,
"*" => lv * rv, BinOp::Mul => lv * rv,
"/" => { if rv == 0.0 { return None; } lv / rv } BinOp::Div => { if rv == 0.0 { return None; } lv / rv }
"^" => lv.powf(rv), BinOp::Pow => lv.powf(rv),
_ => return None, // Comparison operators are handled by eval_bool; reaching
// here means a comparison was used where a number is expected.
BinOp::Eq | BinOp::Ne | BinOp::Lt |
BinOp::Gt | BinOp::Le | BinOp::Ge => return None,
}) })
} }
Expr::UnaryMinus(e) => Some(-eval_expr(e, context, model, target_category)?), Expr::UnaryMinus(e) => Some(-eval_expr(e, context, model, target_category)?),
@ -247,18 +251,21 @@ impl Model {
model: &Model, model: &Model,
target_category: &str, target_category: &str,
) -> Option<bool> { ) -> Option<bool> {
use crate::formula::BinOp;
match expr { match expr {
Expr::BinOp(op, l, r) => { Expr::BinOp(op, l, r) => {
let lv = eval_expr(l, context, model, target_category)?; let lv = eval_expr(l, context, model, target_category)?;
let rv = eval_expr(r, context, model, target_category)?; let rv = eval_expr(r, context, model, target_category)?;
Some(match op.as_str() { Some(match op {
"=" | "==" => (lv - rv).abs() < 1e-10, BinOp::Eq => (lv - rv).abs() < 1e-10,
"!=" => (lv - rv).abs() >= 1e-10, BinOp::Ne => (lv - rv).abs() >= 1e-10,
"<" => lv < rv, BinOp::Lt => lv < rv,
">" => lv > rv, BinOp::Gt => lv > rv,
"<=" => lv <= rv, BinOp::Le => lv <= rv,
">=" => lv >= rv, BinOp::Ge => lv >= rv,
_ => return None, // Arithmetic operators are not comparisons
BinOp::Add | BinOp::Sub | BinOp::Mul |
BinOp::Div | BinOp::Pow => return None,
}) })
} }
_ => None, _ => None,