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:
@ -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>),
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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]
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user