use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum Expr {
Null,
BoolLit(bool),
IntLit(String),
FloatLit(String),
StringLit(String),
DateTimeLit { keyword: String, body: String },
BindRef(String),
SubstitutionRef { name: String, sticky: bool },
Name(NameRef),
Call { callee: NameRef, args: Vec<Expr> },
Binary {
op: String,
lhs: Box<Expr>,
rhs: Box<Expr>,
},
Unary { op: String, operand: Box<Expr> },
Raw {
text: String,
reason: UnknownExprReason,
},
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct NameRef {
pub parts: Vec<String>,
pub display: String,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum UnknownExprReason {
UnrecognizedShape,
UnbalancedParens,
UnterminatedString,
ExprDepthLimit,
}
pub const MAX_EXPR_DEPTH: usize = 256;
#[must_use]
pub fn lower_expression(source: &str) -> Expr {
lower_expression_depth(source, 0)
}
#[must_use]
fn lower_expression_depth(source: &str, depth: usize) -> Expr {
if depth >= MAX_EXPR_DEPTH {
return Expr::Raw {
text: source.to_string(),
reason: UnknownExprReason::ExprDepthLimit,
};
}
let trimmed = source.trim().trim_end_matches(';').trim();
if trimmed.is_empty() {
return Expr::Null;
}
if let Some(lit) = recognise_keyword_literal(trimmed) {
return lit;
}
if let Some(lit) = recognise_string_literal(trimmed) {
return lit;
}
if let Some(lit) = recognise_datetime_literal(trimmed) {
return lit;
}
if let Some(lit) = recognise_numeric_literal(trimmed) {
return lit;
}
if let Some(b) = recognise_bind(trimmed) {
return b;
}
if let Some(s) = recognise_substitution(trimmed) {
return s;
}
if let Some(e) = recognise_paren_group(trimmed, depth) {
return e;
}
if let Some(e) = recognise_top_level_binary(trimmed, depth) {
return e;
}
if let Some(e) = recognise_call(trimmed, depth) {
return e;
}
if let Some(e) = recognise_unary(trimmed, depth) {
return e;
}
if is_dotted_name(trimmed) {
return Expr::Name(name_ref_from(trimmed));
}
Expr::Raw {
text: source.to_string(),
reason: UnknownExprReason::UnrecognizedShape,
}
}
fn recognise_keyword_literal(text: &str) -> Option<Expr> {
let upper = text.to_ascii_uppercase();
match upper.as_str() {
"NULL" => Some(Expr::Null),
"TRUE" => Some(Expr::BoolLit(true)),
"FALSE" => Some(Expr::BoolLit(false)),
_ => None,
}
}
fn recognise_string_literal(text: &str) -> Option<Expr> {
if !text.starts_with('\'') || !text.ends_with('\'') || text.len() < 2 {
return None;
}
let inner = &text[1..text.len() - 1];
let mut out = String::with_capacity(inner.len());
let mut chars = inner.chars().peekable();
while let Some(c) = chars.next() {
if c == '\'' {
if chars.peek() == Some(&'\'') {
chars.next();
out.push('\'');
continue;
}
return None;
}
out.push(c);
}
Some(Expr::StringLit(out))
}
fn recognise_datetime_literal(text: &str) -> Option<Expr> {
let upper = text.to_ascii_uppercase();
let keyword = if upper.starts_with("DATE") {
"DATE"
} else if upper.starts_with("TIMESTAMP") {
"TIMESTAMP"
} else if upper.starts_with("INTERVAL") {
"INTERVAL"
} else {
return None;
};
let after = &text[keyword.len()..];
let next_byte = after.bytes().next();
if let Some(b) = next_byte
&& (b.is_ascii_alphanumeric() || b == b'_' || b == b'$' || b == b'#')
{
return None;
}
let trimmed = after.trim_start();
if trimmed.len() < 2 || !trimmed.starts_with('\'') || !trimmed.ends_with('\'') {
return None;
}
let body = &trimmed[1..trimmed.len() - 1];
Some(Expr::DateTimeLit {
keyword: keyword.to_string(),
body: body.to_string(),
})
}
fn recognise_numeric_literal(text: &str) -> Option<Expr> {
let bytes = text.as_bytes();
if bytes.is_empty() {
return None;
}
let first = bytes[0];
if !(first.is_ascii_digit() || (first == b'.' && bytes.len() > 1)) {
return None;
}
let mut saw_dot = false;
let mut saw_e = false;
for &b in bytes {
if b.is_ascii_digit() {
continue;
}
if b == b'.' && !saw_dot && !saw_e {
saw_dot = true;
continue;
}
if (b == b'e' || b == b'E') && !saw_e {
saw_e = true;
saw_dot = true;
continue;
}
if (b == b'+' || b == b'-') && saw_e {
continue;
}
return None;
}
if saw_dot || saw_e {
Some(Expr::FloatLit(text.to_string()))
} else {
Some(Expr::IntLit(text.to_string()))
}
}
fn recognise_bind(text: &str) -> Option<Expr> {
text.strip_prefix(':')
.map(|rest| Expr::BindRef(rest.to_string()))
}
fn recognise_substitution(text: &str) -> Option<Expr> {
if let Some(rest) = text.strip_prefix("&&") {
return Some(Expr::SubstitutionRef {
name: rest.to_string(),
sticky: true,
});
}
if let Some(rest) = text.strip_prefix('&') {
return Some(Expr::SubstitutionRef {
name: rest.to_string(),
sticky: false,
});
}
None
}
fn recognise_paren_group(text: &str, depth: usize) -> Option<Expr> {
let bytes = text.as_bytes();
if bytes.first() != Some(&b'(') || bytes.last() != Some(&b')') {
return None;
}
let mut pdepth: i32 = 0;
let mut in_string = false;
let last = bytes.len() - 1;
let mut i = 0usize;
while i < bytes.len() {
let b = bytes[i];
if b >= 0x80 {
i += 1;
continue;
}
if b == b'\'' {
in_string = !in_string;
i += 1;
continue;
}
if in_string {
i += 1;
continue;
}
if b == b'(' {
pdepth += 1;
} else if b == b')' {
pdepth -= 1;
if pdepth == 0 {
if i != last {
return None;
}
let inner = &text[1..last];
return Some(lower_expression_depth(inner, depth + 1));
}
if pdepth < 0 {
return Some(Expr::Raw {
text: text.to_string(),
reason: UnknownExprReason::UnbalancedParens,
});
}
}
i += 1;
}
Some(Expr::Raw {
text: text.to_string(),
reason: UnknownExprReason::UnbalancedParens,
})
}
fn recognise_top_level_binary(text: &str, depth: usize) -> Option<Expr> {
let precedence: &[&[&str]] = &[
&["OR"],
&["AND"],
&["<>", "!=", "<=", ">=", "=", "<", ">"],
&["||"],
&["+", "-"],
&["*", "/"],
];
for tier in precedence {
let (operands, ops) = find_all_top_level_ops(text, tier);
if ops.is_empty() {
continue;
}
let n = operands.len();
let cap = MAX_EXPR_DEPTH.saturating_sub(depth).min(n - 1);
let mut acc = if cap < n - 1 {
Expr::Raw {
text: join_operand_tail(text, &operands, cap),
reason: UnknownExprReason::ExprDepthLimit,
}
} else {
lower_expression_depth(operands[n - 1], depth + n)
};
for idx in (0..cap).rev() {
let lhs = lower_expression_depth(operands[idx], depth + idx + 1);
acc = Expr::Binary {
op: ops[idx].to_string(),
lhs: Box::new(lhs),
rhs: Box::new(acc),
};
}
return Some(acc);
}
None
}
fn find_all_top_level_ops<'a, 'b>(
text: &'a str,
ops: &'b [&'b str],
) -> (Vec<&'a str>, Vec<&'b str>) {
let bytes = text.as_bytes();
let mut depth: i32 = 0;
let mut in_string = false;
let mut i = 0;
let mut seg_start = 0usize;
let mut operands: Vec<&'a str> = Vec::new();
let mut found_ops: Vec<&'b str> = Vec::new();
while i < bytes.len() {
let b = bytes[i];
if b >= 0x80 {
i += 1;
continue;
}
if b == b'\'' {
in_string = !in_string;
i += 1;
continue;
}
if in_string {
i += 1;
continue;
}
if b == b'(' {
depth += 1;
i += 1;
continue;
}
if b == b')' {
depth -= 1;
i += 1;
continue;
}
if depth != 0 {
i += 1;
continue;
}
let mut matched = false;
for op in ops {
let op_bytes = op.as_bytes();
if i + op_bytes.len() > bytes.len() {
continue;
}
let candidate_bytes = &bytes[i..i + op_bytes.len()];
let matches = candidate_bytes
.iter()
.zip(op_bytes.iter())
.all(|(&cb, &ob)| cb.to_ascii_uppercase() == ob);
if !matches {
continue;
}
if op.chars().all(|c| c.is_ascii_alphabetic()) {
let prev_ok = i == 0 || {
let p = bytes[i - 1];
!(p.is_ascii_alphanumeric() || p == b'_')
};
let next_ok = i + op_bytes.len() == bytes.len() || {
let n = bytes[i + op_bytes.len()];
!(n.is_ascii_alphanumeric() || n == b'_')
};
if !(prev_ok && next_ok) {
continue;
}
}
let lhs = text[seg_start..i].trim();
let rhs = text[i + op_bytes.len()..].trim();
if lhs.is_empty() || rhs.is_empty() {
continue;
}
operands.push(lhs);
found_ops.push(*op);
i += op_bytes.len();
seg_start = i;
matched = true;
break;
}
if !matched {
i += 1;
}
}
if found_ops.is_empty() {
return (Vec::new(), Vec::new());
}
operands.push(text[seg_start..].trim());
(operands, found_ops)
}
fn join_operand_tail(text: &str, operands: &[&str], cap: usize) -> String {
debug_assert!(cap < operands.len());
let base = text.as_ptr() as usize;
let first = operands[cap];
let last = operands[operands.len() - 1];
let start = (first.as_ptr() as usize).saturating_sub(base);
let end_rel = (last.as_ptr() as usize).saturating_sub(base) + last.len();
let end = end_rel.min(text.len());
if start <= end && text.is_char_boundary(start) && text.is_char_boundary(end) {
text[start..end].to_string()
} else {
text.to_string()
}
}
fn recognise_call(text: &str, depth: usize) -> Option<Expr> {
let open = text.find('(')?;
if !text.ends_with(')') {
return None;
}
let name_part = text[..open].trim();
if !is_dotted_name(name_part) {
return None;
}
let inner = &text[open + 1..text.len() - 1];
let args = split_top_level_args(inner)
.into_iter()
.map(|s| lower_expression_depth(&s, depth + 1))
.collect();
Some(Expr::Call {
callee: name_ref_from(name_part),
args,
})
}
fn split_top_level_args(inner: &str) -> Vec<String> {
let mut out: Vec<String> = Vec::new();
let mut buf = String::new();
let mut depth: i32 = 0;
let mut in_string = false;
for c in inner.chars() {
if c == '\'' {
in_string = !in_string;
buf.push(c);
continue;
}
if in_string {
buf.push(c);
continue;
}
if c == '(' {
depth += 1;
} else if c == ')' {
depth -= 1;
} else if c == ',' && depth == 0 {
out.push(std::mem::take(&mut buf));
continue;
}
buf.push(c);
}
if !buf.trim().is_empty() {
out.push(buf);
}
out
}
fn recognise_unary(text: &str, depth: usize) -> Option<Expr> {
let upper = text.to_ascii_uppercase();
if upper.starts_with("NOT ") {
let len = "NOT ".len();
return Some(Expr::Unary {
op: "NOT".into(),
operand: Box::new(lower_expression_depth(&text[len..], depth + 1)),
});
}
if text.starts_with('-') || text.starts_with('+') {
let op = if text.starts_with('-') { "-" } else { "+" };
return Some(Expr::Unary {
op: op.into(),
operand: Box::new(lower_expression_depth(text[1..].trim(), depth + 1)),
});
}
let _ = upper;
None
}
fn is_dotted_name(text: &str) -> bool {
let bytes = text.as_bytes();
if bytes.is_empty() {
return false;
}
if !(bytes[0].is_ascii_alphabetic() || bytes[0] == b'_') {
return false;
}
bytes
.iter()
.all(|&b| b.is_ascii_alphanumeric() || b == b'_' || b == b'$' || b == b'#' || b == b'.')
&& !text.contains("..")
}
fn name_ref_from(text: &str) -> NameRef {
let parts: Vec<String> = text.split('.').map(|p| p.to_ascii_uppercase()).collect();
NameRef {
parts,
display: text.to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn null_literal() {
assert_eq!(lower_expression("NULL"), Expr::Null);
assert_eq!(lower_expression("null"), Expr::Null);
}
#[test]
fn boolean_literals() {
assert_eq!(lower_expression("TRUE"), Expr::BoolLit(true));
assert_eq!(lower_expression("false"), Expr::BoolLit(false));
}
#[test]
fn integer_and_float_literals() {
assert_eq!(lower_expression("42"), Expr::IntLit("42".into()));
assert!(matches!(lower_expression("1.5e+12"), Expr::FloatLit(_)));
assert!(matches!(lower_expression("3.14"), Expr::FloatLit(_)));
}
#[test]
fn string_literal_unescapes_doubled_quotes() {
assert_eq!(
lower_expression("'it''s fine'"),
Expr::StringLit("it's fine".into())
);
}
#[test]
fn string_literal_preserves_non_ascii_utf8() {
let src = "'café — déjà vu — Москва — 日本語'";
let expected = "café — déjà vu — Москва — 日本語";
assert_eq!(
lower_expression(src),
Expr::StringLit(expected.into()),
"non-ASCII string literal must round-trip byte-for-byte"
);
}
#[test]
fn string_literal_unescapes_doubled_quotes_with_non_ascii() {
assert_eq!(
lower_expression("'garçon''s café'"),
Expr::StringLit("garçon's café".into())
);
}
#[test]
fn datetime_literals_word_boundary_safe() {
assert!(matches!(
lower_expression("DATE '2024-05-15'"),
Expr::DateTimeLit { keyword, .. } if keyword == "DATE"
));
assert!(matches!(lower_expression("DATE_HIRED"), Expr::Name(_)));
}
#[test]
fn bind_ref_and_substitution_ref() {
assert_eq!(
lower_expression(":bind_name"),
Expr::BindRef("bind_name".into())
);
assert_eq!(lower_expression(":1"), Expr::BindRef("1".into()));
assert_eq!(
lower_expression("&var"),
Expr::SubstitutionRef {
name: "var".into(),
sticky: false,
}
);
assert_eq!(
lower_expression("&&sticky"),
Expr::SubstitutionRef {
name: "sticky".into(),
sticky: true,
}
);
}
#[test]
fn dotted_name_reference() {
if let Expr::Name(n) = lower_expression("hr.employees.emp_id") {
assert_eq!(n.parts, vec!["HR", "EMPLOYEES", "EMP_ID"]);
assert_eq!(n.display, "hr.employees.emp_id");
} else {
panic!();
}
}
#[test]
fn function_call_with_two_args() {
if let Expr::Call { callee, args } = lower_expression("nvl(v_x, 0)") {
assert_eq!(callee.parts, vec!["NVL"]);
assert_eq!(args.len(), 2);
} else {
panic!();
}
}
#[test]
fn nested_call_arguments_preserved() {
if let Expr::Call { args, .. } = lower_expression("nvl(coalesce(a, b), 0)") {
assert_eq!(args.len(), 2);
assert!(matches!(args[0], Expr::Call { .. }));
} else {
panic!();
}
}
#[test]
fn binary_operator_low_precedence_wins() {
if let Expr::Binary { op, .. } = lower_expression("a AND b OR c") {
assert_eq!(op, "OR");
} else {
panic!();
}
}
#[test]
fn string_concat_is_a_binary() {
if let Expr::Binary { op, .. } = lower_expression("first_name || ' ' || last_name") {
assert_eq!(op, "||");
} else {
panic!();
}
}
#[test]
fn unary_not_negates_inner() {
if let Expr::Unary { op, operand } = lower_expression("NOT v_flag") {
assert_eq!(op, "NOT");
assert!(matches!(*operand, Expr::Name(_)));
} else {
panic!();
}
}
#[test]
fn paren_protects_inner_op_from_top_level_split() {
if let Expr::Binary { op, .. } = lower_expression("(a OR b) AND c") {
assert_eq!(op, "AND");
} else {
panic!();
}
}
#[test]
fn relational_two_char_ops_not_mis_split_on_embedded_equals() {
for (src, expected_op) in [("a <= b", "<="), ("a >= b", ">="), ("a != b", "!=")] {
match lower_expression(src) {
Expr::Binary { op, lhs, rhs } => {
assert_eq!(op, expected_op, "op for {src:?}");
assert!(
matches!(*lhs, Expr::Name(_)),
"lhs of {src:?} must lower to a Name, not Raw: {lhs:?}"
);
assert!(
matches!(*rhs, Expr::Name(_)),
"rhs of {src:?} must lower to a Name: {rhs:?}"
);
}
other => panic!("{src:?} should lower to Binary, got {other:?}"),
}
}
}
#[test]
fn unaffected_comparison_ops_still_split_correctly() {
for (src, expected_op) in [
("a = b", "="),
("a < b", "<"),
("a > b", ">"),
("a <> b", "<>"),
] {
match lower_expression(src) {
Expr::Binary { op, lhs, rhs } => {
assert_eq!(op, expected_op, "op for {src:?}");
assert!(matches!(*lhs, Expr::Name(_)), "lhs of {src:?}: {lhs:?}");
assert!(matches!(*rhs, Expr::Name(_)), "rhs of {src:?}: {rhs:?}");
}
other => panic!("{src:?} should lower to Binary, got {other:?}"),
}
}
}
#[test]
fn call_on_lhs_of_le_is_preserved_for_calls_edge() {
match lower_expression("compute_total(x) <= 10") {
Expr::Binary { op, lhs, .. } => {
assert_eq!(op, "<=");
assert!(
matches!(*lhs, Expr::Call { .. }),
"LHS must lower to a Call so the Calls-edge is emitted: {lhs:?}"
);
}
other => panic!("expected Binary, got {other:?}"),
}
}
#[test]
fn datetime_lone_quote_does_not_panic() {
for src in ["DATE'", "TIMESTAMP '", "INTERVAL '", "DATE '"] {
let e = lower_expression(src);
assert!(
!matches!(e, Expr::DateTimeLit { .. }),
"{src:?} is not a well-formed datetime literal: {e:?}"
);
}
assert!(matches!(
lower_expression("DATE'2020-01-01'"),
Expr::DateTimeLit { .. }
));
}
#[test]
fn unrecognised_expression_lands_as_raw() {
if let Expr::Raw { reason, .. } = lower_expression("@@@") {
assert_eq!(reason, UnknownExprReason::UnrecognizedShape);
} else {
panic!();
}
}
#[test]
fn empty_expression_yields_null() {
assert_eq!(lower_expression(""), Expr::Null);
assert_eq!(lower_expression(" ; "), Expr::Null);
}
#[test]
fn string_with_operator_inside_does_not_split() {
if let Expr::StringLit(s) = lower_expression("'a + b'") {
assert_eq!(s, "a + b");
} else {
panic!();
}
}
fn expr_depth(root: &Expr) -> usize {
let mut max = 0usize;
let mut stack: Vec<(&Expr, usize)> = vec![(root, 1)];
while let Some((e, d)) = stack.pop() {
if d > max {
max = d;
}
match e {
Expr::Binary { lhs, rhs, .. } => {
stack.push((lhs, d + 1));
stack.push((rhs, d + 1));
}
Expr::Unary { operand, .. } => stack.push((operand, d + 1)),
Expr::Call { args, .. } => {
for a in args {
stack.push((a, d + 1));
}
}
_ => {}
}
}
max
}
#[test]
fn flat_binary_chain_left_fold_shape_preserved() {
match lower_expression("a OR b OR c") {
Expr::Binary { op, lhs, rhs } => {
assert_eq!(op, "OR");
assert!(matches!(*lhs, Expr::Name(_)), "outer lhs is `a`: {lhs:?}");
match *rhs {
Expr::Binary {
op: ref iop,
ref lhs,
ref rhs,
} => {
assert_eq!(iop, "OR");
assert!(matches!(**lhs, Expr::Name(_)), "inner lhs is `b`: {lhs:?}");
assert!(matches!(**rhs, Expr::Name(_)), "inner rhs is `c`: {rhs:?}");
}
other => panic!("inner rhs should be Binary, got {other:?}"),
}
}
other => panic!("expected Binary, got {other:?}"),
}
}
#[test]
fn mixed_same_tier_ops_fold_in_source_order() {
match lower_expression("a - b + c") {
Expr::Binary { op, rhs, .. } => {
assert_eq!(op, "-", "outer op is the leftmost `-`");
match *rhs {
Expr::Binary { op: iop, .. } => assert_eq!(iop, "+"),
other => panic!("inner should be `+` Binary, got {other:?}"),
}
}
other => panic!("expected Binary, got {other:?}"),
}
}
#[test]
fn wide_or_chain_does_not_stack_overflow_and_is_depth_bounded() {
let n = 1_000_000usize;
let mut chain = String::with_capacity(n * 5);
for i in 0..n {
if i > 0 {
chain.push_str(" OR ");
}
chain.push('a');
}
let lowered = lower_expression(&chain);
assert!(
matches!(lowered, Expr::Binary { .. }),
"wide chain should lower to a Binary spine: {lowered:?}"
);
let depth = expr_depth(&lowered);
assert!(
depth <= MAX_EXPR_DEPTH + 1,
"produced tree depth {depth} must stay bounded by the cap \
(MAX_EXPR_DEPTH={MAX_EXPR_DEPTH}); an unbounded spine would \
overflow the downstream walkers"
);
assert!(
contains_depth_limit_raw(&lowered),
"an over-deep chain must surface an ExprDepthLimit Raw (R13)"
);
}
fn contains_depth_limit_raw(root: &Expr) -> bool {
let mut stack: Vec<&Expr> = vec![root];
while let Some(e) = stack.pop() {
match e {
Expr::Raw { reason, .. } if *reason == UnknownExprReason::ExprDepthLimit => {
return true;
}
Expr::Binary { lhs, rhs, .. } => {
stack.push(lhs);
stack.push(rhs);
}
Expr::Unary { operand, .. } => stack.push(operand),
Expr::Call { args, .. } => stack.extend(args.iter()),
_ => {}
}
}
false
}
#[test]
fn deeply_nested_parens_degrade_to_depth_limit_not_overflow() {
let depth = MAX_EXPR_DEPTH + 50;
let mut s = String::new();
for _ in 0..depth {
s.push('(');
}
s.push_str("a OR a");
for _ in 0..depth {
s.push(')');
}
let src = format!("NOT {s}");
let lowered = lower_expression(&src);
assert!(
expr_depth(&lowered) <= MAX_EXPR_DEPTH + 1,
"deep paren/unary spine must stay depth-bounded: {lowered:?}"
);
}
#[test]
fn short_chain_within_budget_keeps_all_operands() {
let n = 64usize;
let chain = vec!["x"; n].join(" OR ");
let lowered = lower_expression(&chain);
assert!(
!contains_depth_limit_raw(&lowered),
"a {n}-operand chain is well within MAX_EXPR_DEPTH and must \
not be truncated"
);
let mut names = 0usize;
let mut bins = 0usize;
let mut stack: Vec<&Expr> = vec![&lowered];
while let Some(e) = stack.pop() {
match e {
Expr::Name(_) => names += 1,
Expr::Binary { op, lhs, rhs } => {
assert_eq!(op, "OR");
bins += 1;
stack.push(lhs);
stack.push(rhs);
}
other => panic!("unexpected node {other:?}"),
}
}
assert_eq!(names, n, "all operands preserved");
assert_eq!(bins, n - 1, "one OR per gap");
}
#[test]
fn paren_group_unwraps_to_inner_name() {
match lower_expression("(p_user)") {
Expr::Name(n) => assert_eq!(n.parts, vec!["P_USER"]),
other => panic!("expected Name, got {other:?}"),
}
}
#[test]
fn whole_expression_paren_group_unwraps_then_splits_inner_op() {
match lower_expression("('SELECT ' || p_user)") {
Expr::Binary { op, .. } => assert_eq!(op, "||"),
other => panic!("expected `||` Binary, got {other:?}"),
}
}
#[test]
fn paren_group_unwraps_to_inner_call() {
match lower_expression("(compute(x))") {
Expr::Call { callee, .. } => assert_eq!(callee.parts, vec!["COMPUTE"]),
other => panic!("expected Call, got {other:?}"),
}
}
#[test]
fn two_adjacent_groups_are_not_stripped_as_one() {
match lower_expression("(a) + (b)") {
Expr::Binary { op, lhs, rhs } => {
assert_eq!(op, "+");
assert!(matches!(*lhs, Expr::Name(_)), "lhs `(a)` → Name: {lhs:?}");
assert!(matches!(*rhs, Expr::Name(_)), "rhs `(b)` → Name: {rhs:?}");
}
other => panic!("expected `+` Binary over two Names, got {other:?}"),
}
}
#[test]
fn unbalanced_paren_group_degrades_to_typed_reason() {
match lower_expression("((a)") {
Expr::Raw { reason, .. } => {
assert_eq!(reason, UnknownExprReason::UnbalancedParens)
}
other => panic!("expected UnbalancedParens Raw, got {other:?}"),
}
}
}