Files
improvise/crates/improvise-formula/src/parser.rs
T
Edward Langley b1715f49b4 improvise-i5z6: parse formula.pest once per test process
The two generator strategies re-parsed the grammar inside their
prop_map, once per generated case: the same defect improvise-8shu fixed
in improvise-io. A OnceLock takes the lib test binary from ~0.9s to
~0.06s on the same machine and load.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-23 18:23:45 -07:00

1395 lines
50 KiB
Rust

use anyhow::{Result, anyhow};
use pest::Parser as _;
use pest::iterators::Pair;
use pest_derive::Parser;
use super::ast::{AggFunc, BinOp, Expr, Filter, Formula};
#[derive(Parser)]
#[grammar = "formula.pest"]
struct FormulaParser;
/// Bug marker for `.expect()` on invariants the grammar guarantees: if one
/// fires, the grammar and the tree walker are out of sync.
const GRAMMAR_INVARIANT: &str = "grammar invariant violated: parser out of sync with formula.pest";
/// Parse a formula string like "Profit = Revenue - Cost"
/// or "Tax = Revenue * 0.08 WHERE Region = \"East\""
pub fn parse_formula(raw: &str, target_category: &str) -> Result<Formula> {
let input = raw.trim();
let formula_pair = FormulaParser::parse(Rule::formula, input)
.map_err(|e| anyhow!("{}", e))?
.next()
.expect(GRAMMAR_INVARIANT);
Ok(build_formula(formula_pair, input, target_category))
}
/// Parse a bare expression (no target, no top-level WHERE clause).
/// Fails if the input contains trailing tokens after a complete expression.
pub fn parse_expr(s: &str) -> Result<Expr> {
let input = s.trim();
let expr_eoi_pair = FormulaParser::parse(Rule::expr_eoi, input)
.map_err(|e| anyhow!("{}", e))?
.next()
.expect(GRAMMAR_INVARIANT);
Ok(build_expr(first_inner(expr_eoi_pair)))
}
// ---- tree walkers: infallible, because pest validated the pair first ----
// See `context/code/formula-and-io.md` for why every .expect is a bug marker.
fn build_formula(pair: Pair<Rule>, raw: &str, target_category: &str) -> Formula {
let mut target = None;
let mut expr = None;
let mut filter = None;
let mut format = None;
for inner in pair.into_inner() {
match inner.as_rule() {
// Unquoted exactly like `Expr::Ref`: the same spelling must name
// the same item, or the output lands in an unreferencable column.
Rule::target => target = Some(identifier_to_string(first_inner(inner))),
Rule::expr => expr = Some(build_expr(inner)),
Rule::where_clause => filter = Some(build_filter(inner)),
// The spec, not the clause: `FORMAT ` is syntax and not the picture.
Rule::format_clause => {
format = Some(first_inner(inner).as_str().to_string());
}
// Ignored as an ARM, so "ignored" is a decision and not a silence.
Rule::EOI => {}
Rule::WHITESPACE
| Rule::formula
| Rule::expr_eoi
| Rule::add_expr
| Rule::add_op
| Rule::mul_expr
| Rule::mul_op
| Rule::pow_expr
| Rule::pow_op
| Rule::unary
| Rule::unary_minus
| Rule::primary
| Rule::is_expr
| Rule::sp
| Rule::paren_expr
| Rule::agg_call
| Rule::agg_func
| Rule::inline_where
| Rule::if_expr
| Rule::comparison
| Rule::cmp_op
| Rule::ref_expr
| Rule::identifier
| Rule::pipe_quoted
| Rule::bare_ident
| Rule::filter_value
| Rule::format_spec
| Rule::string
| Rule::number => {
unreachable!("{GRAMMAR_INVARIANT}: formula part {:?}", inner.as_rule())
}
}
}
Formula::new(
raw,
target.expect(GRAMMAR_INVARIANT),
target_category,
expr.expect(GRAMMAR_INVARIANT),
filter,
)
.with_format(format)
}
fn build_expr(pair: Pair<Rule>) -> Expr {
// expr = { add_expr }
build_add_expr(first_inner(pair))
}
fn build_add_expr(pair: Pair<Rule>) -> Expr {
fold_left_binop(pair, build_mul_expr, |s| {
if s == "+" {
BinOp::Add
} else {
// The grammar restricts add_op to "+" | "-".
BinOp::Sub
}
})
}
fn build_mul_expr(pair: Pair<Rule>) -> Expr {
fold_left_binop(pair, build_pow_expr, |s| {
if s == "*" {
BinOp::Mul
} else {
// The grammar restricts mul_op to "*" | "/".
BinOp::Div
}
})
}
fn build_pow_expr(pair: Pair<Rule>) -> Expr {
// pow_expr = { unary ~ (pow_op ~ unary)? }
let mut pairs = pair.into_inner();
let base = build_unary(pairs.next().expect(GRAMMAR_INVARIANT));
match pairs.next() {
None => base,
Some(_pow_op) => {
let exp = build_unary(pairs.next().expect(GRAMMAR_INVARIANT));
Expr::BinOp(BinOp::Pow, Box::new(base), Box::new(exp))
}
}
}
fn build_unary(pair: Pair<Rule>) -> Expr {
// unary = { unary_minus | primary }
let inner = first_inner(pair);
match inner.as_rule() {
Rule::unary_minus => Expr::UnaryMinus(Box::new(build_primary(first_inner(inner)))),
Rule::primary => build_primary(inner),
Rule::EOI
| Rule::WHITESPACE
| Rule::formula
| Rule::target
| Rule::where_clause
| Rule::expr_eoi
| Rule::expr
| Rule::add_expr
| Rule::add_op
| Rule::mul_expr
| Rule::mul_op
| Rule::pow_expr
| Rule::pow_op
| Rule::unary
| Rule::is_expr
| Rule::sp
| Rule::paren_expr
| Rule::agg_call
| Rule::agg_func
| Rule::inline_where
| Rule::if_expr
| Rule::comparison
| Rule::cmp_op
| Rule::ref_expr
| Rule::identifier
| Rule::pipe_quoted
| Rule::bare_ident
| Rule::filter_value
| Rule::format_clause
| Rule::format_spec
| Rule::string
| Rule::number => {
unreachable!(
"{GRAMMAR_INVARIANT}: unary alternative {:?}",
inner.as_rule()
)
}
}
}
fn build_primary(pair: Pair<Rule>) -> Expr {
// primary = { number | agg_call | if_expr | paren_expr | is_expr | ref_expr }
let inner = first_inner(pair);
match inner.as_rule() {
Rule::number => Expr::Number(inner.as_str().parse().expect(GRAMMAR_INVARIANT)),
Rule::agg_call => build_agg_call(inner),
Rule::if_expr => build_if_expr(inner),
Rule::paren_expr => build_expr(first_inner(inner)),
// Same shape as a WHERE clause minus the keyword: identifier ~ filter_value.
Rule::is_expr => Expr::Is(build_filter(inner)),
Rule::ref_expr => Expr::Ref(identifier_to_string(first_inner(inner))),
// Spelled out, not `_`: a new `primary` alternative must fail the
// BUILD here. context/code/formula-and-io.md, "tree walkers".
Rule::EOI
| Rule::WHITESPACE
| Rule::formula
| Rule::target
| Rule::where_clause
| Rule::expr_eoi
| Rule::expr
| Rule::add_expr
| Rule::add_op
| Rule::mul_expr
| Rule::mul_op
| Rule::pow_expr
| Rule::pow_op
| Rule::unary
| Rule::unary_minus
| Rule::primary
| Rule::sp
| Rule::agg_func
| Rule::inline_where
| Rule::comparison
| Rule::cmp_op
| Rule::identifier
| Rule::pipe_quoted
| Rule::bare_ident
| Rule::filter_value
| Rule::format_clause
| Rule::format_spec
| Rule::string => {
unreachable!(
"{GRAMMAR_INVARIANT}: primary alternative {:?}",
inner.as_rule()
)
}
}
}
fn build_agg_call(pair: Pair<Rule>) -> Expr {
// agg_call = { agg_func ~ "(" ~ expr ~ inline_where? ~ ")" }
let mut pairs = pair.into_inner();
let func = parse_agg_func(pairs.next().expect(GRAMMAR_INVARIANT).as_str());
let inner_expr = build_expr(pairs.next().expect(GRAMMAR_INVARIANT));
// The only pair after expr (if any) is `inline_where`, so we can
// map it directly without checking the rule variant.
let filter = pairs.next().map(build_filter);
Expr::Agg(func, Box::new(inner_expr), filter)
}
fn parse_agg_func(s: &str) -> AggFunc {
// agg_func = { ^"SUM" | ^"AVG" | ^"MIN" | ^"MAX" | ^"COUNT" }
match s.to_ascii_uppercase().as_str() {
"SUM" => AggFunc::Sum,
"AVG" => AggFunc::Avg,
"MIN" => AggFunc::Min,
"MAX" => AggFunc::Max,
"COUNT" => AggFunc::Count,
// A new agg_func alternative must be spelled here (improvise-492x).
other => unreachable!("{GRAMMAR_INVARIANT}: agg_func {other}"),
}
}
fn build_if_expr(pair: Pair<Rule>) -> Expr {
// if_expr = { ^"IF" ~ "(" ~ comparison ~ "," ~ expr ~ "," ~ expr ~ ")" }
let mut pairs = pair.into_inner();
let cond = build_comparison(pairs.next().expect(GRAMMAR_INVARIANT));
let then_e = build_expr(pairs.next().expect(GRAMMAR_INVARIANT));
let else_e = build_expr(pairs.next().expect(GRAMMAR_INVARIANT));
Expr::If(Box::new(cond), Box::new(then_e), Box::new(else_e))
}
fn build_comparison(pair: Pair<Rule>) -> Expr {
// comparison = { expr ~ cmp_op ~ expr }
let mut pairs = pair.into_inner();
let lhs = build_expr(pairs.next().expect(GRAMMAR_INVARIANT));
let op = parse_cmp_op(pairs.next().expect(GRAMMAR_INVARIANT).as_str());
let rhs = build_expr(pairs.next().expect(GRAMMAR_INVARIANT));
Expr::BinOp(op, Box::new(lhs), Box::new(rhs))
}
fn parse_cmp_op(s: &str) -> BinOp {
// cmp_op = { "!=" | "<=" | ">=" | "<" | ">" | "=" }
match s {
"=" => BinOp::Eq,
"!=" => BinOp::Ne,
"<" => BinOp::Lt,
">" => BinOp::Gt,
"<=" => BinOp::Le,
">=" => BinOp::Ge,
// A new cmp_op alternative must be spelled here (improvise-492x).
other => unreachable!("{GRAMMAR_INVARIANT}: cmp_op {other}"),
}
}
fn build_filter(pair: Pair<Rule>) -> Filter {
// where_clause / inline_where both have shape:
// ^"WHERE" ~ identifier ~ "=" ~ filter_value
let mut pairs = pair.into_inner();
let category = identifier_to_string(pairs.next().expect(GRAMMAR_INVARIANT));
let item = filter_value_to_string(pairs.next().expect(GRAMMAR_INVARIANT));
Filter { category, item }
}
fn filter_value_to_string(pair: Pair<Rule>) -> String {
// filter_value = { string | pipe_quoted | bare_ident }
let inner = first_inner(pair);
let s = inner.as_str();
match inner.as_rule() {
Rule::string => strip_string_quotes(s),
Rule::pipe_quoted => unquote_pipe(s),
Rule::bare_ident => s.to_string(),
Rule::EOI
| Rule::WHITESPACE
| Rule::formula
| Rule::target
| Rule::where_clause
| Rule::expr_eoi
| Rule::expr
| Rule::add_expr
| Rule::add_op
| Rule::mul_expr
| Rule::mul_op
| Rule::pow_expr
| Rule::pow_op
| Rule::unary
| Rule::unary_minus
| Rule::primary
| Rule::is_expr
| Rule::sp
| Rule::paren_expr
| Rule::agg_call
| Rule::agg_func
| Rule::inline_where
| Rule::if_expr
| Rule::comparison
| Rule::cmp_op
| Rule::ref_expr
| Rule::identifier
| Rule::filter_value
| Rule::format_clause
| Rule::format_spec
| Rule::number => {
unreachable!(
"{GRAMMAR_INVARIANT}: filter_value alternative {:?}",
inner.as_rule()
)
}
}
}
/// An identifier pair (identifier, pipe_quoted or bare_ident) as its content:
/// pipes stripped and escapes applied, or returned verbatim.
fn identifier_to_string(pair: Pair<Rule>) -> String {
let s = pair.as_str();
if is_pipe_quoted(s) {
unquote_pipe(s)
} else {
s.to_string()
}
}
fn is_pipe_quoted(s: &str) -> bool {
s.len() >= 2 && s.starts_with('|') && s.ends_with('|')
}
fn strip_string_quotes(s: &str) -> String {
debug_assert!(s.len() >= 2 && s.starts_with('"') && s.ends_with('"'));
s[1..s.len() - 1].to_string()
}
/// Strip the pipes and apply `quote::pipe_quote`'s escape table — the one copy
/// of that rule, shared with `improvise-io`'s `.improv` walker (improvise-i2m5).
fn unquote_pipe(s: &str) -> String {
debug_assert!(is_pipe_quoted(s));
crate::quote::unescape_pipe_body(&s[1..s.len() - 1])
}
// ---- small helpers ------------------------------------------------------
fn first_inner(pair: Pair<'_, Rule>) -> Pair<'_, Rule> {
pair.into_inner().next().expect(GRAMMAR_INVARIANT)
}
/// Fold `rule = { child ~ (op ~ child)* }` into a left-leaning BinOp tree.
/// `match_op` is infallible: the grammar guarantees the operator token.
fn fold_left_binop<F, M>(pair: Pair<Rule>, mut build_child: F, match_op: M) -> Expr
where
F: FnMut(Pair<Rule>) -> Expr,
M: Fn(&str) -> BinOp,
{
let mut pairs = pair.into_inner();
let mut left = build_child(pairs.next().expect(GRAMMAR_INVARIANT));
while let Some(op_pair) = pairs.next() {
let op = match_op(op_pair.as_str());
let right = build_child(pairs.next().expect(GRAMMAR_INVARIANT));
left = Expr::BinOp(op, Box::new(left), Box::new(right));
}
left
}
#[cfg(test)]
mod tests {
use super::{parse_agg_func, parse_cmp_op, parse_formula};
use crate::{AggFunc, BinOp, Expr};
/// Every `primary` alternative routes to its own `Expr` — see
/// context/test-evidence/formula-and-io.md.
#[test]
fn each_primary_alternative_builds_its_own_expr() {
let of = |src: &str| {
parse_formula(&format!("X = {src}"), "_Measure")
.unwrap()
.expr
};
assert!(matches!(of("42"), Expr::Number(n) if n == 42.0));
assert!(matches!(of("SUM(Rev)"), Expr::Agg(..)));
assert!(matches!(of("IF(a < b, 1, 2)"), Expr::If(..)));
assert!(matches!(of("(Rev)"), Expr::Ref(ref r) if r == "Rev"));
assert!(matches!(of("Region IS East"), Expr::Is(_)));
assert!(matches!(of("Rev"), Expr::Ref(ref r) if r == "Rev"));
assert!(matches!(of("-Rev"), Expr::UnaryMinus(_)));
// filter_value's three alternatives, which share a dispatch.
let item = |src: &str| {
parse_formula(&format!("X = Y WHERE C = {src}"), "_M")
.unwrap()
.filter
.map(|f| f.item)
};
assert_eq!(item("\"a b\"").as_deref(), Some("a b"));
assert_eq!(item("|a b|").as_deref(), Some("a b"));
assert_eq!(item("ab").as_deref(), Some("ab"));
}
/// improvise-492x — see context/test-evidence/formula-and-io.md.
#[test]
fn every_grammar_aggregate_and_comparison_is_spelled_out() {
assert!(matches!(parse_agg_func("SUM"), AggFunc::Sum));
assert!(matches!(parse_agg_func("AVG"), AggFunc::Avg));
assert!(matches!(parse_agg_func("MIN"), AggFunc::Min));
assert!(matches!(parse_agg_func("MAX"), AggFunc::Max));
assert!(matches!(parse_agg_func("COUNT"), AggFunc::Count));
for (token, want) in [
("=", BinOp::Eq),
("!=", BinOp::Ne),
("<", BinOp::Lt),
(">", BinOp::Gt),
("<=", BinOp::Le),
(">=", BinOp::Ge),
] {
assert_eq!(parse_cmp_op(token), want, "cmp_op {token}");
}
}
/// improvise-492x: a new `agg_func` alternative refuses loudly rather
/// than becoming COUNT — see context/test-evidence/formula-and-io.md.
#[test]
#[should_panic(expected = "grammar invariant violated")]
fn an_aggregate_the_walker_does_not_know_is_not_count() {
let _ = parse_agg_func("MEDIAN");
}
/// improvise-492x: and a new `cmp_op` alternative is not `>=`.
#[test]
#[should_panic(expected = "grammar invariant violated")]
fn a_comparison_the_walker_does_not_know_is_not_ge() {
let _ = parse_cmp_op("<>");
}
#[test]
fn parse_simple_subtraction() {
let f = parse_formula("Profit = Revenue - Cost", "Foo").unwrap();
assert_eq!(f.target, "Profit");
assert_eq!(f.target_category, "Foo");
assert!(matches!(f.expr, Expr::BinOp(BinOp::Sub, _, _)));
}
#[test]
fn parse_where_clause() {
let f = parse_formula("EastRev = Revenue WHERE Region = \"East\"", "Foo").unwrap();
assert_eq!(f.target, "EastRev");
let filter = f.filter.as_ref().unwrap();
assert_eq!(filter.category, "Region");
assert_eq!(filter.item, "East");
}
#[test]
fn parse_sum_aggregation() {
let f = parse_formula("Total = SUM(Revenue)", "Foo").unwrap();
assert!(matches!(f.expr, Expr::Agg(AggFunc::Sum, _, _)));
}
#[test]
fn parse_avg_aggregation() {
let f = parse_formula("Avg = AVG(Revenue)", "Foo").unwrap();
assert!(matches!(f.expr, Expr::Agg(AggFunc::Avg, _, _)));
}
#[test]
fn parse_if_expression() {
let f = parse_formula("Capped = IF(Revenue > 1000, 1000, Revenue)", "Foo").unwrap();
assert!(matches!(f.expr, Expr::If(_, _, _)));
}
#[test]
fn parse_numeric_literal() {
let f = parse_formula("Fixed = 42", "Foo").unwrap();
assert!(matches!(f.expr, Expr::Number(n) if (n - 42.0).abs() < 1e-10));
}
#[test]
fn parse_chained_arithmetic() {
parse_formula("X = (A + B) * (C - D)", "Cat").unwrap();
}
#[test]
fn parse_missing_equals_returns_error() {
assert!(parse_formula("BadFormula Revenue Cost", "Cat").is_err());
}
// ── Aggregate functions ─────────────────────────────────────────────
#[test]
fn parse_min_aggregation() {
let f = parse_formula("Lo = MIN(Revenue)", "Foo").unwrap();
assert!(matches!(f.expr, Expr::Agg(AggFunc::Min, _, _)));
}
#[test]
fn parse_max_aggregation() {
let f = parse_formula("Hi = MAX(Revenue)", "Foo").unwrap();
assert!(matches!(f.expr, Expr::Agg(AggFunc::Max, _, _)));
}
#[test]
fn parse_count_aggregation() {
let f = parse_formula("N = COUNT(Revenue)", "Foo").unwrap();
assert!(matches!(f.expr, Expr::Agg(AggFunc::Count, _, _)));
}
// ── Aggregate with WHERE filter ─────────────────────────────────────
#[test]
fn parse_sum_with_top_level_where_works() {
let f = parse_formula("EastTotal = SUM(Revenue) WHERE Region = \"East\"", "Foo").unwrap();
assert!(matches!(f.expr, Expr::Agg(AggFunc::Sum, _, _)));
let filter = f.filter.as_ref().unwrap();
assert_eq!(filter.category, "Region");
assert_eq!(filter.item, "East");
}
/// Regression: WHERE inside aggregate parens must parse as the
/// aggregate's inline filter, not as a top-level WHERE clause.
#[test]
fn parse_sum_with_inline_where_filter() {
let f = parse_formula("EastTotal = SUM(Revenue WHERE Region = \"East\")", "Foo").unwrap();
assert!(matches!(
&f.expr,
Expr::Agg(AggFunc::Sum, inner, Some(filter))
if matches!(**inner, Expr::Ref(_))
&& filter.category == "Region"
&& filter.item == "East"
));
}
// ── Comparison operators ────────────────────────────────────────────
#[test]
fn parse_if_with_comparison_operators() {
let f = parse_formula("X = IF(A != 0, A, 1)", "Cat").unwrap();
assert!(matches!(f.expr, Expr::If(_, _, _)));
let f = parse_formula("X = IF(A < 10, A, 10)", "Cat").unwrap();
assert!(matches!(f.expr, Expr::If(_, _, _)));
let f = parse_formula("X = IF(A <= 10, A, 10)", "Cat").unwrap();
assert!(matches!(f.expr, Expr::If(_, _, _)));
let f = parse_formula("X = IF(A >= 10, 10, A)", "Cat").unwrap();
assert!(matches!(f.expr, Expr::If(_, _, _)));
let f = parse_formula("X = IF(A = B, 1, 0)", "Cat").unwrap();
assert!(matches!(f.expr, Expr::If(_, _, _)));
}
// ── Quoted strings in WHERE ─────────────────────────────────────────
#[test]
fn parse_where_with_quoted_string_inside_expression() {
let f = parse_formula("X = Revenue WHERE Region = \"West Coast\"", "Foo").unwrap();
let filter = f.filter.as_ref().unwrap();
assert_eq!(filter.item, "West Coast");
}
// ── Power operator ──────────────────────────────────────────────────
#[test]
fn parse_power_operator() {
let f = parse_formula("Sq = X ^ 2", "Cat").unwrap();
assert!(matches!(f.expr, Expr::BinOp(BinOp::Pow, _, _)));
}
// ── Unary minus ─────────────────────────────────────────────────────
#[test]
fn parse_unary_minus() {
let f = parse_formula("Neg = -Revenue", "Foo").unwrap();
assert!(matches!(f.expr, Expr::UnaryMinus(_)));
}
// ── Division and multiplication ─────────────────────────────────────
#[test]
fn parse_multiplication() {
let f = parse_formula("Double = Revenue * 2", "Foo").unwrap();
assert!(matches!(f.expr, Expr::BinOp(BinOp::Mul, _, _)));
}
#[test]
fn parse_division() {
let f = parse_formula("Half = Revenue / 2", "Foo").unwrap();
assert!(matches!(f.expr, Expr::BinOp(BinOp::Div, _, _)));
}
// ── Parenthesized expression ────────────────────────────────────────
#[test]
fn parse_nested_parens() {
let f = parse_formula("X = ((A + B))", "Cat").unwrap();
assert!(matches!(f.expr, Expr::BinOp(BinOp::Add, _, _)));
}
// ── Aggregate function name used as ref (no parens) ─────────────────
#[test]
fn parse_aggregate_name_without_parens_is_ref() {
// "SUM" without parens should be treated as a reference, not a function
let f = parse_formula("X = SUM + 1", "Cat").unwrap();
assert!(matches!(
&f.expr,
Expr::BinOp(BinOp::Add, lhs, _) if matches!(**lhs, Expr::Ref(_))
));
}
#[test]
fn parse_if_without_parens_is_ref() {
// "IF" without parens should be treated as a reference
let f = parse_formula("X = IF + 1", "Cat").unwrap();
assert!(matches!(
&f.expr,
Expr::BinOp(BinOp::Add, lhs, _) if matches!(**lhs, Expr::Ref(_))
));
}
// ── Quoted string in WHERE ──────────────────────────────────────────
#[test]
fn parse_quoted_string_in_where() {
let f = parse_formula("X = Revenue WHERE Region = \"East\"", "Cat").unwrap();
let filter = f.filter.as_ref().unwrap();
assert_eq!(filter.item, "East");
}
#[test]
fn where_does_not_swallow_the_start_of_a_longer_word() {
assert!(parse_formula("X = SUM(Rev WHEREAS x = y)", "Cat").is_err());
assert!(parse_formula("X = Revenue WHEREAS x = y", "Cat").is_err());
assert!(parse_formula("X = Revenue WHEREAS=y", "Cat").is_err());
assert!(parse_formula("X = SUM(Rev WHEREAS=y)", "Cat").is_err());
let inline = parse_formula("X = SUM(Rev WHERE Region = East)", "Cat").unwrap();
assert!(
matches!(&inline.expr, Expr::Agg(_, _, Some(_))),
"{:?}",
inline.expr
);
let trailing = parse_formula("X = Revenue WHERE Region = East", "Cat").unwrap();
assert_eq!(trailing.filter.as_ref().unwrap().category, "Region");
}
/// `sp+` is the boundary, so the keyword needs a space after it exactly as
/// `IS` does — a quoted category no longer abuts it.
#[test]
fn where_requires_a_space_after_the_keyword() {
assert!(parse_formula("X = Revenue WHERE|Region Name|=|East Coast|", "Cat").is_err());
let f = parse_formula("X = Revenue WHERE |Region Name|=|East Coast|", "Cat").unwrap();
let filter = f.filter.as_ref().unwrap();
assert_eq!(
(filter.category.as_str(), filter.item.as_str()),
("Region Name", "East Coast")
);
}
// ── IS, the coordinate predicate (improvise-4zl7) ───────────────────
#[test]
fn parse_is_expression() {
let f = parse_formula("X = Region IS East", "Cat").unwrap();
let Expr::Is(filter) = &f.expr else {
panic!("expected an IS, got {:?}", f.expr);
};
assert_eq!(
(filter.category.as_str(), filter.item.as_str()),
("Region", "East")
);
}
/// The shape the emitter actually writes: a coordinate predicate as the left
/// side of an `IF`'s comparison.
#[test]
fn parse_is_inside_an_if_condition() {
let f = parse_formula("X = IF(Region IS East < 0.5, 1, 2)", "Cat").unwrap();
let Expr::If(cond, ..) = &f.expr else {
panic!("expected an IF, got {:?}", f.expr);
};
let Expr::BinOp(BinOp::Lt, lhs, _) = &**cond else {
panic!("expected a `<`, got {cond:?}");
};
assert!(matches!(&**lhs, Expr::Is(_)), "got {lhs:?}");
}
/// Both halves quote through `improvise_formula::quote`'s one home, so a name
/// with spaces, a pipe or a keyword spelling survives (gotcha 34).
#[test]
fn parse_is_with_awkward_names() {
for (cat, item) in [
("Region Name", "East Coast"),
("IS", "IF"),
("a|b", "c\\d"),
("SUM", "9lives"),
] {
let text = format!(
"X = {} IS {}",
crate::quote::quote_name(cat),
crate::quote::quote_name(item)
);
let f = parse_formula(&text, "Cat")
.unwrap_or_else(|e| panic!("{text:?} did not parse: {e}"));
let Expr::Is(filter) = &f.expr else {
panic!("{text:?} is not an IS: {:?}", f.expr);
};
assert_eq!(
(filter.category.as_str(), filter.item.as_str()),
(cat, item)
);
}
}
/// `ref_expr` must not win the race: a bare name still parses as a reference,
/// and only the `IS` keyword turns it into a predicate.
#[test]
fn a_bare_name_is_still_a_reference() {
let f = parse_formula("X = Region", "Cat").unwrap();
assert!(
matches!(f.expr, Expr::Ref(ref n) if n == "Region"),
"{:?}",
f.expr
);
let f = parse_formula("X = IS", "Cat").unwrap();
assert!(
matches!(f.expr, Expr::Ref(ref n) if n == "IS"),
"{:?}",
f.expr
);
}
/// `IS` needs a trailing word boundary, or `Region ISLAND` reads as
/// `Is(Region, "LAND")` — silently wrong where it used to be a parse error.
#[test]
fn is_does_not_swallow_the_start_of_a_longer_word() {
for text in ["X = Region ISLAND", "X = Region isotope", "X = Region IS_A"] {
assert!(
parse_formula(text, "Cat").is_err(),
"{text:?} must not parse as a coordinate predicate"
);
}
// …and the boundary does not reject the real thing.
for text in ["X = Region IS East", "X = Region IS |East Coast|"] {
let f = parse_formula(text, "Cat").unwrap_or_else(|e| panic!("{text:?}: {e}"));
assert!(matches!(f.expr, Expr::Is(_)), "{text:?}: {:?}", f.expr);
}
}
// ── Error paths ─────────────────────────────────────────────────────
#[test]
fn parse_unexpected_token_error() {
use super::parse_expr;
// Extra tokens after a valid expression
assert!(parse_expr("1 + 2 3").is_err());
}
#[test]
fn parse_unexpected_character_error() {
use super::parse_expr;
assert!(parse_expr("@invalid").is_err());
}
#[test]
fn parse_empty_expression_error() {
use super::parse_expr;
assert!(parse_expr("").is_err());
}
// ── Multi-word identifiers must be pipe-quoted ──────────────────────
#[test]
fn multi_word_bare_identifier_is_rejected() {
// Multi-word identifiers must be pipe-quoted; bare multi-word fails
// the `bare_name`-compatible grammar rule.
assert!(parse_formula("Total Revenue = Base Revenue + Bonus", "Foo").is_err());
}
// ── WHERE inside quotes in the expression ───────────────────────────
#[test]
fn where_inside_quotes_is_not_a_keyword() {
// A filter value containing the literal text "WHERE" is parsed as
// a string, not as a nested WHERE keyword.
let f = parse_formula("X = Revenue WHERE Region = \"WHERE\"", "Foo").unwrap();
let filter = f.filter.as_ref().unwrap();
assert_eq!(filter.item, "WHERE");
}
// ── Pipe-quoted identifiers ─────────────────────────────────────────
#[test]
fn pipe_quoted_identifier_in_expression() {
let f = parse_formula("|Total Revenue| = |Base Revenue| + Bonus", "Foo").unwrap();
assert_eq!(f.target, "Total Revenue");
assert!(matches!(
&f.expr,
Expr::BinOp(BinOp::Add, lhs, rhs)
if matches!(**lhs, Expr::Ref(ref s) if s == "Base Revenue")
&& matches!(**rhs, Expr::Ref(ref s) if s == "Bonus")
));
}
#[test]
fn pipe_quoted_keyword_as_identifier() {
// A category named "WHERE" can be referenced with pipes
let f = parse_formula("X = |WHERE| + |SUM|", "Cat").unwrap();
assert!(matches!(
&f.expr,
Expr::BinOp(BinOp::Add, lhs, rhs)
if matches!(**lhs, Expr::Ref(ref s) if s == "WHERE")
&& matches!(**rhs, Expr::Ref(ref s) if s == "SUM")
));
}
#[test]
fn pipe_quoted_identifier_with_special_chars() {
// Pipes allow characters that would normally break tokenization
let f = parse_formula("X = |Revenue (USD)| + |Cost + Tax|", "Cat").unwrap();
assert!(matches!(
&f.expr,
Expr::BinOp(BinOp::Add, lhs, rhs)
if matches!(**lhs, Expr::Ref(ref s) if s == "Revenue (USD)")
&& matches!(**rhs, Expr::Ref(ref s) if s == "Cost + Tax")
));
}
#[test]
fn pipe_quoted_in_aggregate() {
let f = parse_formula("X = SUM(|Net Revenue|)", "Cat").unwrap();
assert!(matches!(
&f.expr,
Expr::Agg(AggFunc::Sum, inner, None)
if matches!(**inner, Expr::Ref(ref s) if s == "Net Revenue")
));
}
#[test]
fn pipe_quoted_in_where_filter_value() {
let f = parse_formula("X = Revenue WHERE Region = |East Coast|", "Foo").unwrap();
let filter = f.filter.as_ref().unwrap();
assert_eq!(filter.item, "East Coast");
}
#[test]
fn pipe_quoted_in_inline_where() {
let f =
parse_formula("X = SUM(Revenue WHERE |Region Name| = |East Coast|)", "Foo").unwrap();
assert!(matches!(
&f.expr,
Expr::Agg(AggFunc::Sum, _, Some(filter))
if filter.category == "Region Name" && filter.item == "East Coast"
));
}
// ── Pipe-quoted escape semantics ────────────────────────────────────
#[test]
fn pipe_quoted_escape_literal_pipe() {
// \| inside a pipe-quoted identifier is a literal pipe
let f = parse_formula("X = |A\\|B|", "Cat").unwrap();
assert!(matches!(&f.expr, Expr::Ref(s) if s == "A|B"));
}
#[test]
fn pipe_quoted_escape_double_backslash() {
// \\ inside a pipe-quoted identifier is a literal backslash
let f = parse_formula("X = |A\\\\B|", "Cat").unwrap();
assert!(matches!(&f.expr, Expr::Ref(s) if s == "A\\B"));
}
#[test]
fn pipe_quoted_escape_newline() {
// \n inside a pipe-quoted identifier is a literal newline
let f = parse_formula("X = |A\\nB|", "Cat").unwrap();
assert!(matches!(&f.expr, Expr::Ref(s) if s == "A\nB"));
}
#[test]
fn pipe_quoted_unknown_escape_preserved() {
// Any other escape is preserved verbatim: the grammar allows any
// character after a backslash; we just don't interpret it.
let f = parse_formula("X = |A\\zB|", "Cat").unwrap();
assert!(matches!(&f.expr, Expr::Ref(s) if s == "A\\zB"));
}
// ── Operator precedence and associativity ──────────────────────────
#[test]
fn mul_binds_tighter_than_add() {
// `A + B * C` must parse as Add(A, Mul(B, C))
let f = parse_formula("X = A + B * C", "Cat").unwrap();
assert!(matches!(
&f.expr,
Expr::BinOp(BinOp::Add, lhs, rhs)
if matches!(**lhs, Expr::Ref(_))
&& matches!(**rhs, Expr::BinOp(BinOp::Mul, _, _))
));
}
#[test]
fn pow_binds_tighter_than_mul() {
// `A * B ^ C` must parse as Mul(A, Pow(B, C))
let f = parse_formula("X = A * B ^ C", "Cat").unwrap();
assert!(matches!(
&f.expr,
Expr::BinOp(BinOp::Mul, lhs, rhs)
if matches!(**lhs, Expr::Ref(_))
&& matches!(**rhs, Expr::BinOp(BinOp::Pow, _, _))
));
}
#[test]
fn subtraction_is_left_associative() {
// `A - B - C` must parse as Sub(Sub(A, B), C), not Sub(A, Sub(B, C))
let f = parse_formula("X = A - B - C", "Cat").unwrap();
assert!(matches!(
&f.expr,
Expr::BinOp(BinOp::Sub, lhs, rhs)
if matches!(**lhs, Expr::BinOp(BinOp::Sub, _, _))
&& matches!(**rhs, Expr::Ref(ref s) if s == "C")
));
}
#[test]
fn division_is_left_associative() {
// `A / B / C` must parse as Div(Div(A, B), C)
let f = parse_formula("X = A / B / C", "Cat").unwrap();
assert!(matches!(
&f.expr,
Expr::BinOp(BinOp::Div, lhs, rhs)
if matches!(**lhs, Expr::BinOp(BinOp::Div, _, _))
&& matches!(**rhs, Expr::Ref(ref s) if s == "C")
));
}
#[test]
fn unary_minus_before_pow() {
// `-A ^ B` is Pow(UnaryMinus(A), B): `-` binds through
// `unary_minus = { "-" ~ primary }`, giving the pow_expr's base.
let f = parse_formula("X = -A ^ B", "Cat").unwrap();
assert!(matches!(
&f.expr,
Expr::BinOp(BinOp::Pow, lhs, rhs)
if matches!(**lhs, Expr::UnaryMinus(_))
&& matches!(**rhs, Expr::Ref(ref s) if s == "B")
));
}
// ── Number literal variants ────────────────────────────────────────
#[test]
fn integer_literal() {
let f = parse_formula("X = 42", "Cat").unwrap();
assert!(matches!(f.expr, Expr::Number(n) if (n - 42.0).abs() < 1e-10));
}
#[test]
fn zero_literal() {
let f = parse_formula("X = 0", "Cat").unwrap();
assert!(matches!(f.expr, Expr::Number(n) if n == 0.0));
}
#[test]
fn decimal_literal_without_integer_part() {
let f = parse_formula("X = .5", "Cat").unwrap();
assert!(matches!(f.expr, Expr::Number(n) if (n - 0.5).abs() < 1e-10));
}
#[test]
fn decimal_literal_with_trailing_dot() {
let f = parse_formula("X = 5.", "Cat").unwrap();
assert!(matches!(f.expr, Expr::Number(n) if (n - 5.0).abs() < 1e-10));
}
#[test]
fn decimal_literal_with_integer_and_fraction() {
let f = parse_formula("X = 123.456", "Cat").unwrap();
assert!(matches!(f.expr, Expr::Number(n) if (n - 123.456).abs() < 1e-10));
}
// ── Filter value variants ──────────────────────────────────────────
#[test]
fn where_with_bare_identifier_value() {
// filter_value = { string | pipe_quoted | bare_ident } — exercise
// the bare_ident branch.
let f = parse_formula("X = Revenue WHERE Region = East", "Cat").unwrap();
let filter = f.filter.as_ref().unwrap();
assert_eq!(filter.category, "Region");
assert_eq!(filter.item, "East");
}
// ── Nested constructs ──────────────────────────────────────────────
#[test]
fn nested_sum_aggregate() {
// Nested aggregates — outer SUM wrapping an inner SUM.
let f = parse_formula("X = SUM(SUM(Revenue))", "Cat").unwrap();
assert!(matches!(
&f.expr,
Expr::Agg(AggFunc::Sum, outer_inner, None)
if matches!(**outer_inner, Expr::Agg(AggFunc::Sum, _, None))
));
}
#[test]
fn deeply_nested_parens() {
// Parens should flatten away without affecting the AST.
let f = parse_formula("X = (((((A + B)))))", "Cat").unwrap();
assert!(matches!(f.expr, Expr::BinOp(BinOp::Add, _, _)));
}
#[test]
fn nested_if_expression() {
// IF in the then-branch of another IF.
let f = parse_formula("X = IF(A > B, IF(C > D, 1, 2), 3)", "Cat").unwrap();
assert!(matches!(
&f.expr,
Expr::If(_, then_e, else_e)
if matches!(**then_e, Expr::If(_, _, _))
&& matches!(**else_e, Expr::Number(n) if n == 3.0)
));
}
// ── Whitespace tolerance ───────────────────────────────────────────
#[test]
fn tolerates_tabs_between_tokens() {
let f = parse_formula("X\t=\tA\t+\tB", "Cat").unwrap();
assert!(matches!(f.expr, Expr::BinOp(BinOp::Add, _, _)));
}
#[test]
fn tolerates_extra_spaces_between_tokens() {
let f = parse_formula("X = A + B", "Cat").unwrap();
assert!(matches!(f.expr, Expr::BinOp(BinOp::Add, _, _)));
}
#[test]
fn tolerates_leading_and_trailing_whitespace() {
let f = parse_formula(" X = A + B ", "Cat").unwrap();
assert!(matches!(f.expr, Expr::BinOp(BinOp::Add, _, _)));
}
// ── Case insensitivity of keywords ─────────────────────────────────
#[test]
fn aggregate_function_is_case_insensitive() {
// The grammar uses ^"SUM" which is case-insensitive.
let f = parse_formula("X = sum(Revenue)", "Cat").unwrap();
assert!(matches!(f.expr, Expr::Agg(AggFunc::Sum, _, _)));
let f = parse_formula("X = Sum(Revenue)", "Cat").unwrap();
assert!(matches!(f.expr, Expr::Agg(AggFunc::Sum, _, _)));
}
#[test]
fn if_keyword_is_case_insensitive() {
let f = parse_formula("X = if(A > B, 1, 0)", "Cat").unwrap();
assert!(matches!(f.expr, Expr::If(_, _, _)));
}
#[test]
fn where_keyword_is_case_insensitive() {
let f = parse_formula("X = Revenue where Region = \"East\"", "Cat").unwrap();
assert!(f.filter.is_some());
}
// ── Target variants ────────────────────────────────────────────────
#[test]
fn target_with_underscore_and_hyphen() {
let f = parse_formula("my_target-name = A", "Cat").unwrap();
assert_eq!(f.target, "my_target-name");
}
/// Regression: `east-coast` used to parse as `east - coast`. Evidence:
/// `context/test-evidence/formula-and-io.md`.
#[test]
fn hyphen_in_bare_identifier_reference() {
let f = parse_formula("Total = east-coast + west-coast", "Cat").unwrap();
assert!(matches!(
&f.expr,
Expr::BinOp(BinOp::Add, lhs, rhs)
if matches!(**lhs, Expr::Ref(ref s) if s == "east-coast")
&& matches!(**rhs, Expr::Ref(ref s) if s == "west-coast")
));
}
#[test]
fn hyphen_in_bare_identifier_inside_aggregate() {
// Same fix, but inside a SUM() to exercise the aggregate path as well.
let f = parse_formula("X = SUM(east-coast)", "Cat").unwrap();
assert!(matches!(
&f.expr,
Expr::Agg(AggFunc::Sum, inner, None)
if matches!(**inner, Expr::Ref(ref s) if s == "east-coast")
));
}
/// Regression: a pipe-quoted target kept its delimiters and so created a
/// ghost item. Evidence: `context/test-evidence/formula-and-io.md`.
#[test]
fn pipe_quoted_target_is_unquoted_like_a_reference() {
let f = parse_formula("|My Target| = |My Target|", "Cat").unwrap();
assert_eq!(f.target, "My Target");
assert!(
matches!(&f.expr, Expr::Ref(s) if s == &f.target),
"a target and a reference spelled identically must name the same item, got {:?}",
f.expr
);
}
/// The escapes apply to the target too — the same ones `Expr::Ref` gets.
#[test]
fn pipe_quoted_target_applies_escapes() {
let f = parse_formula(r"|a\|b| = A", "Cat").unwrap();
assert_eq!(f.target, "a|b");
}
/// A bare target is unaffected: there is nothing to strip.
#[test]
fn bare_target_is_unchanged() {
let f = parse_formula("Profit = A", "Cat").unwrap();
assert_eq!(f.target, "Profit");
}
}
// ---- grammar-driven proptests: pest_meta reads formula.pest at test time
// and walks its AST. A new production needs generator coverage (context doc).
#[cfg(test)]
mod generator {
use pest_meta::ast::{Expr, RuleType};
use pest_meta::parser;
use proptest::prelude::*;
use std::collections::HashMap;
use std::sync::OnceLock;
use std::sync::atomic::{AtomicUsize, Ordering};
/// Counts how often formula.pest is actually parsed; the guard below keeps it
/// from becoming a per-case parse again.
static GRAMMAR_PARSES: AtomicUsize = AtomicUsize::new(0);
/// The parsed grammar, read once for the process. Evidence:
/// `context/test-evidence/formula-and-io.md`.
fn grammar() -> &'static HashMap<String, (RuleType, Expr)> {
static RULES: OnceLock<HashMap<String, (RuleType, Expr)>> = OnceLock::new();
RULES.get_or_init(load_grammar)
}
/// Parse formula.pest and return rules keyed by name.
fn load_grammar() -> HashMap<String, (RuleType, Expr)> {
GRAMMAR_PARSES.fetch_add(1, Ordering::Relaxed);
let grammar = include_str!("formula.pest");
let pairs = parser::parse(parser::Rule::grammar_rules, grammar)
.unwrap_or_else(|e| panic!("Bad grammar: {e}"));
let rules = parser::consume_rules(pairs).unwrap_or_else(|e| panic!("{e:?}"));
rules
.into_iter()
.map(|r| (r.name.clone(), (r.ty, r.expr)))
.collect()
}
/// Recursive string generator driven by a pest `Expr`; `choices` is consumed
/// left-to-right. The `atomic` flag is explained in the context doc.
pub struct Gen<'g> {
rules: &'g HashMap<String, (RuleType, Expr)>,
choices: Vec<u8>,
pos: usize,
}
impl<'g> Gen<'g> {
pub fn new(rules: &'g HashMap<String, (RuleType, Expr)>, choices: Vec<u8>) -> Self {
Self {
rules,
choices,
pos: 0,
}
}
fn pick(&mut self) -> u8 {
let v = self.choices.get(self.pos).copied().unwrap_or(0);
self.pos += 1;
v
}
fn emit(&mut self, expr: &Expr, out: &mut String, atomic: bool) {
match expr {
Expr::Str(s) => out.push_str(s),
Expr::Insens(s) => out.push_str(s),
Expr::Ident(name) => self.emit_ident(name, out, atomic),
Expr::Seq(a, b) => {
self.emit(a, out, atomic);
if !atomic {
out.push(' ');
}
self.emit(b, out, atomic);
}
Expr::Choice(a, b) => {
// 50/50 per Choice; nested Choices make deeper branches
// rarer — good enough for random generation.
if self.pick().is_multiple_of(2) {
self.emit(a, out, atomic);
} else {
self.emit(b, out, atomic);
}
}
Expr::Opt(inner) => {
// ~66% chance of emitting the optional branch.
if !self.pick().is_multiple_of(3) {
self.emit(inner, out, atomic);
}
}
Expr::Rep(inner) => {
let count = self.pick() % 4;
for i in 0..count {
if i > 0 && !atomic {
out.push(' ');
}
self.emit(inner, out, atomic);
}
}
Expr::RepOnce(inner) => {
// `+` occurs only inside atomic rules here (ASCII_DIGIT+),
// so inner repetitions never need whitespace separation.
let count = 1 + self.pick() % 3;
for _ in 0..count {
self.emit(inner, out, atomic);
}
}
// Lookaheads emit nothing, as does any Expr variant formula.pest
// does not use — generated formulas then fail to parse, loudly.
_ => {}
}
}
fn emit_ident(&mut self, name: &str, out: &mut String, atomic: bool) {
match name {
"ANY" | "ASCII_ALPHA" => {
out.push((b'a' + self.pick() % 26) as char);
}
"ASCII_ALPHANUMERIC" => {
if self.pick().is_multiple_of(2) {
out.push((b'a' + self.pick() % 26) as char);
} else {
out.push((b'0' + self.pick() % 10) as char);
}
}
"ASCII_DIGIT" => {
out.push((b'0' + self.pick() % 10) as char);
}
"NEWLINE" => out.push('\n'),
"SOI" | "EOI" => {}
_ => {
// Every Ident names a rule in the grammar file, or the
// grammar would not compile: `expect` is a bug marker.
let (ty, inner) = self
.rules
.get(name)
.cloned()
.expect("rule referenced by grammar exists");
let inner_atomic =
atomic || matches!(ty, RuleType::Atomic | RuleType::CompoundAtomic);
self.emit(&inner, out, inner_atomic);
}
}
}
pub fn generate(&mut self, rule_name: &str) -> String {
let mut out = String::new();
let (ty, expr) = self
.rules
.get(rule_name)
.cloned()
.expect("entry rule exists in formula.pest");
let atomic = matches!(ty, RuleType::Atomic | RuleType::CompoundAtomic);
self.emit(&expr, &mut out, atomic);
out
}
}
/// Proptest strategy: generate a random valid formula by walking the
/// grammar's `formula` rule.
pub fn formula_string() -> impl Strategy<Value = String> {
prop::collection::vec(any::<u8>(), 32..=128)
.prop_map(|choices| Gen::new(grammar(), choices).generate("formula"))
}
/// Proptest strategy: generate a random valid standalone expression.
pub fn expr_string() -> impl Strategy<Value = String> {
prop::collection::vec(any::<u8>(), 32..=128)
.prop_map(|choices| Gen::new(grammar(), choices).generate("expr_eoi"))
}
/// improvise-i5z6. Evidence: `context/test-evidence/formula-and-io.md`.
#[test]
fn the_grammar_is_parsed_once_however_many_formulas_the_strategies_yield() {
use proptest::strategy::ValueTree;
use proptest::test_runner::TestRunner;
let mut runner = TestRunner::deterministic();
let (formulas, exprs) = (formula_string(), expr_string());
for _ in 0..32 {
let _ = formulas.new_tree(&mut runner).unwrap().current();
let _ = exprs.new_tree(&mut runner).unwrap().current();
}
assert_eq!(
GRAMMAR_PARSES.load(Ordering::Relaxed),
1,
"formula.pest was re-parsed per generated case"
);
}
}
#[cfg(test)]
mod grammar_prop_tests {
use super::generator;
use super::{parse_expr, parse_formula};
use proptest::prelude::*;
proptest! {
#![proptest_config(ProptestConfig::with_cases(256))]
/// Every generator-produced formula parses without error.
#[test]
fn generated_formula_parses(formula in generator::formula_string()) {
let result = parse_formula(&formula, "Cat");
prop_assert!(
result.is_ok(),
"Generated formula failed to parse:\n{}\nError: {}",
formula,
result.unwrap_err()
);
}
/// Every generator-produced standalone expression parses.
#[test]
fn generated_expr_parses(expr in generator::expr_string()) {
let result = parse_expr(&expr);
prop_assert!(
result.is_ok(),
"Generated expression failed to parse:\n{}\nError: {}",
expr,
result.unwrap_err()
);
}
/// Parsing the same input twice is deterministic — the debug
/// representation of the resulting AST is identical.
#[test]
fn parse_is_deterministic(formula in generator::formula_string()) {
let r1 = parse_formula(&formula, "Cat");
let r2 = parse_formula(&formula, "Cat");
prop_assume!(r1.is_ok() && r2.is_ok());
let f1 = r1.unwrap();
let f2 = r2.unwrap();
prop_assert_eq!(&f1.target, &f2.target);
prop_assert_eq!(format!("{:?}", f1.expr), format!("{:?}", f2.expr));
}
}
}