forked from 1b-agent/opencadstudio-upstream
- Dynamic-input fields take ' and " and read 2'9" / 3' as lengths (#937) - STRETCH on a selection made by a crossing window stretches through that window and goes straight to the base point (#1068) - STRETCH's crossing-window steps show no coordinate boxes (#657) - Collapsed ribbon panel titles wrap at words, never mid-word (#920) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
775 lines
26 KiB
Rust
775 lines
26 KiB
Rust
/// Math expression evaluator — recursive-descent parser.
|
|
///
|
|
/// Supports operators: `+ - * / ^ %` with standard precedence.
|
|
/// Supports functions: `abs sin cos tan sqrt ceil floor round log exp`
|
|
/// Supports coord functions: `PtDist PtAngle`
|
|
/// Supports constant: `pi`
|
|
/// Case-insensitive for function names and constant.
|
|
|
|
// ── Token ───────────────────────────────────────────────────────────
|
|
|
|
#[derive(Debug, Clone, PartialEq)]
|
|
enum Token {
|
|
Number(f64),
|
|
Ident(String),
|
|
Plus,
|
|
Minus,
|
|
Star,
|
|
Slash,
|
|
Caret,
|
|
Percent,
|
|
LParen,
|
|
RParen,
|
|
Comma,
|
|
}
|
|
|
|
// ── Tokenizer ───────────────────────────────────────────────────────
|
|
|
|
fn tokenize(input: &str) -> Result<Vec<Token>, ()> {
|
|
let mut tokens = Vec::new();
|
|
let mut chars = input.chars().peekable();
|
|
|
|
while let Some(ch) = chars.next() {
|
|
if ch.is_whitespace() {
|
|
continue;
|
|
}
|
|
match ch {
|
|
'(' => tokens.push(Token::LParen),
|
|
')' => tokens.push(Token::RParen),
|
|
',' => tokens.push(Token::Comma),
|
|
'+' => tokens.push(Token::Plus),
|
|
'-' => tokens.push(Token::Minus),
|
|
'*' => tokens.push(Token::Star),
|
|
'/' => tokens.push(Token::Slash),
|
|
'^' => tokens.push(Token::Caret),
|
|
'%' => tokens.push(Token::Percent),
|
|
c if c.is_ascii_digit() || (c == '.' && chars.peek().map_or(false, |c| c.is_ascii_digit())) => {
|
|
let mut num_str = String::new();
|
|
if c == '.' {
|
|
num_str.push('.');
|
|
} else {
|
|
num_str.push(c);
|
|
}
|
|
while let Some(&c) = chars.peek() {
|
|
if c.is_ascii_digit() || c == '.' {
|
|
num_str.push(c);
|
|
chars.next();
|
|
} else {
|
|
break;
|
|
}
|
|
}
|
|
match num_str.parse::<f64>() {
|
|
Ok(v) => tokens.push(Token::Number(v)),
|
|
Err(_) => return Err(()),
|
|
}
|
|
}
|
|
c if c.is_ascii_alphabetic() || c == '_' => {
|
|
let mut ident = String::new();
|
|
ident.push(c.to_ascii_lowercase());
|
|
while let Some(&c) = chars.peek() {
|
|
if c.is_ascii_alphanumeric() || c == '_' {
|
|
ident.push(c.to_ascii_lowercase());
|
|
chars.next();
|
|
} else {
|
|
break;
|
|
}
|
|
}
|
|
tokens.push(Token::Ident(ident));
|
|
}
|
|
_ => return Err(()),
|
|
}
|
|
}
|
|
|
|
Ok(tokens)
|
|
}
|
|
|
|
// ── Parser ──────────────────────────────────────────────────────────
|
|
|
|
struct Parser {
|
|
tokens: Vec<Token>,
|
|
pos: usize,
|
|
}
|
|
|
|
/// Maximum recursion depth (parser stack frames) for the recursive-descent
|
|
/// parser. Deeply nested expressions otherwise overflow the stack. This counts
|
|
/// *frames*, not nesting levels: each parenthesis level descends ~6 methods
|
|
/// (expr→sum→product→power→unary→atom), so 256 frames ≈ 41 nested parens — well
|
|
/// past anything a human types, while still capping the stack at a safe depth.
|
|
const MAX_RECURSION: usize = 256;
|
|
|
|
/// Maximum input length for `eval_to_string`'s embedded-expression fallback
|
|
/// scan. The scan is super-linear in the input length, so this bounds it to a
|
|
/// worst case of a few milliseconds. Command-line / dynamic-input fields are
|
|
/// never legitimately this long; longer text is returned unchanged.
|
|
const MAX_EVAL_SCAN_LEN: usize = 256;
|
|
|
|
impl Parser {
|
|
fn new(tokens: Vec<Token>) -> Self {
|
|
Self { tokens, pos: 0 }
|
|
}
|
|
|
|
fn peek(&self) -> Option<&Token> {
|
|
self.tokens.get(self.pos)
|
|
}
|
|
|
|
fn advance(&mut self) -> Option<Token> {
|
|
let t = self.tokens.get(self.pos).cloned();
|
|
self.pos += 1;
|
|
t
|
|
}
|
|
|
|
fn expect_paren(&mut self) -> Result<(), ()> {
|
|
match self.advance() {
|
|
Some(Token::LParen) => Ok(()),
|
|
_ => Err(()),
|
|
}
|
|
}
|
|
|
|
fn expect_rparen(&mut self) -> Result<(), ()> {
|
|
match self.advance() {
|
|
Some(Token::RParen) => Ok(()),
|
|
_ => Err(()),
|
|
}
|
|
}
|
|
|
|
// expr → sum
|
|
fn parse_expr(&mut self, depth: usize) -> Result<f64, ()> {
|
|
if depth > MAX_RECURSION {
|
|
return Err(());
|
|
}
|
|
self.parse_sum(depth + 1)
|
|
}
|
|
|
|
// sum → product (('+' | '-') product)*
|
|
fn parse_sum(&mut self, depth: usize) -> Result<f64, ()> {
|
|
if depth > MAX_RECURSION {
|
|
return Err(());
|
|
}
|
|
let mut left = self.parse_product(depth + 1)?;
|
|
loop {
|
|
let op = match self.peek().cloned() {
|
|
Some(Token::Plus) => Some("+"),
|
|
Some(Token::Minus) => Some("-"),
|
|
_ => None,
|
|
};
|
|
match op {
|
|
Some("+") => {
|
|
self.advance();
|
|
let right = self.parse_product(depth + 1)?;
|
|
left = left + right;
|
|
}
|
|
Some("-") => {
|
|
self.advance();
|
|
let right = self.parse_product(depth + 1)?;
|
|
left = left - right;
|
|
}
|
|
_ => break,
|
|
}
|
|
}
|
|
Ok(left)
|
|
}
|
|
|
|
// product → power (('*' | '/' | '%') power)*
|
|
fn parse_product(&mut self, depth: usize) -> Result<f64, ()> {
|
|
if depth > MAX_RECURSION {
|
|
return Err(());
|
|
}
|
|
let mut left = self.parse_power(depth + 1)?;
|
|
loop {
|
|
let op = match self.peek().cloned() {
|
|
Some(Token::Star) => Some("*"),
|
|
Some(Token::Slash) => Some("/"),
|
|
Some(Token::Percent) => Some("%"),
|
|
_ => None,
|
|
};
|
|
match op {
|
|
Some("*") => {
|
|
self.advance();
|
|
let right = self.parse_power(depth + 1)?;
|
|
left = left * right;
|
|
}
|
|
Some("/") => {
|
|
self.advance();
|
|
let right = self.parse_power(depth + 1)?;
|
|
if right == 0.0 {
|
|
return Ok(if left > 0.0 { f64::INFINITY } else if left < 0.0 { f64::NEG_INFINITY } else { f64::NAN });
|
|
}
|
|
left = left / right;
|
|
}
|
|
Some("%") => {
|
|
self.advance();
|
|
let right = self.parse_power(depth + 1)?;
|
|
left = left % right;
|
|
}
|
|
_ => break,
|
|
}
|
|
}
|
|
Ok(left)
|
|
}
|
|
|
|
// power → unary ('^' power)?
|
|
//
|
|
// The exponent recurses into `parse_power`, not `parse_unary`, so `^` is
|
|
// right-associative — `2^3^2` is `2^(3^2)`, the standard reading. Binding
|
|
// it to the left both gave the wrong number and left the trailing `^2`
|
|
// unconsumed, which the caller's scan then handed back as literal text:
|
|
// `2^3^2` evaluated to the string "8^2" instead of 512.
|
|
fn parse_power(&mut self, depth: usize) -> Result<f64, ()> {
|
|
if depth > MAX_RECURSION {
|
|
return Err(());
|
|
}
|
|
let base = self.parse_unary(depth + 1)?;
|
|
if let Some(Token::Caret) = self.peek() {
|
|
self.advance();
|
|
let exp = self.parse_power(depth + 1)?;
|
|
return Ok(base.powf(exp));
|
|
}
|
|
Ok(base)
|
|
}
|
|
|
|
// unary → ('-' | '+') unary | atom
|
|
fn parse_unary(&mut self, depth: usize) -> Result<f64, ()> {
|
|
if depth > MAX_RECURSION {
|
|
return Err(());
|
|
}
|
|
if let Some(Token::Minus) = self.peek() {
|
|
self.advance();
|
|
return Ok(-self.parse_unary(depth + 1)?);
|
|
}
|
|
if let Some(Token::Plus) = self.peek() {
|
|
self.advance();
|
|
return Ok(self.parse_unary(depth + 1)?);
|
|
}
|
|
self.parse_atom(depth + 1)
|
|
}
|
|
|
|
// atom → NUMBER | NAME | NAME '(' args ')' | 'pi'
|
|
fn parse_atom(&mut self, depth: usize) -> Result<f64, ()> {
|
|
if depth > MAX_RECURSION {
|
|
return Err(());
|
|
}
|
|
let next = match self.peek() {
|
|
Some(t) => t.clone(),
|
|
None => return Err(()),
|
|
};
|
|
match next {
|
|
Token::LParen => {
|
|
self.advance();
|
|
let result = self.parse_expr(depth + 1)?;
|
|
self.expect_rparen()?;
|
|
Ok(result)
|
|
}
|
|
Token::Number(v) => {
|
|
self.advance();
|
|
Ok(v)
|
|
}
|
|
Token::Ident(name) => {
|
|
self.advance();
|
|
// Check for function call
|
|
if let Some(Token::LParen) = self.peek() {
|
|
self.expect_paren()?;
|
|
let args = self.parse_args(depth + 1)?;
|
|
self.expect_rparen()?;
|
|
return Self::call_function(&name, &args);
|
|
}
|
|
// Check for constant
|
|
if name == "pi" {
|
|
Ok(std::f64::consts::PI)
|
|
} else {
|
|
Err(())
|
|
}
|
|
}
|
|
_ => Err(()),
|
|
}
|
|
}
|
|
|
|
fn parse_args(&mut self, depth: usize) -> Result<Vec<f64>, ()> {
|
|
if depth > MAX_RECURSION {
|
|
return Err(());
|
|
}
|
|
let mut args = Vec::new();
|
|
if matches!(self.peek(), Some(Token::RParen)) {
|
|
return Ok(args);
|
|
}
|
|
args.push(self.parse_expr(depth + 1)?);
|
|
loop {
|
|
if matches!(self.peek(), Some(Token::RParen)) {
|
|
break;
|
|
}
|
|
if let Some(Token::Comma) = self.peek() {
|
|
self.advance();
|
|
args.push(self.parse_expr(depth + 1)?);
|
|
} else {
|
|
break;
|
|
}
|
|
}
|
|
Ok(args)
|
|
}
|
|
|
|
fn call_function(name: &str, args: &[f64]) -> Result<f64, ()> {
|
|
let n = args.len();
|
|
match name {
|
|
"abs" => {
|
|
if n != 1 { return Err(()); }
|
|
Ok(args[0].abs())
|
|
}
|
|
"sin" => {
|
|
if n != 1 { return Err(()); }
|
|
Ok(args[0].sin())
|
|
}
|
|
"cos" => {
|
|
if n != 1 { return Err(()); }
|
|
Ok(args[0].cos())
|
|
}
|
|
"tan" => {
|
|
if n != 1 { return Err(()); }
|
|
Ok(args[0].tan())
|
|
}
|
|
"sqrt" => {
|
|
if n != 1 { return Err(()); }
|
|
Ok(args[0].sqrt().max(0.0))
|
|
}
|
|
"ceil" => {
|
|
if n != 1 { return Err(()); }
|
|
Ok(args[0].ceil())
|
|
}
|
|
"floor" => {
|
|
if n != 1 { return Err(()); }
|
|
Ok(args[0].floor())
|
|
}
|
|
"round" => {
|
|
if n != 1 { return Err(()); }
|
|
Ok(args[0].round())
|
|
}
|
|
"log" => {
|
|
if n != 1 { return Err(()); }
|
|
if args[0] <= 0.0 { return Err(()); }
|
|
Ok(args[0].ln())
|
|
}
|
|
"exp" => {
|
|
if n != 1 { return Err(()); }
|
|
Ok(args[0].exp())
|
|
}
|
|
"atand" => {
|
|
if n == 1 {
|
|
Ok(args[0].to_degrees())
|
|
} else if n == 2 {
|
|
Ok((args[1] / args[0]).atan().to_degrees())
|
|
} else {
|
|
Err(())
|
|
}
|
|
}
|
|
"ptdist" => {
|
|
if n != 4 { return Err(()); }
|
|
let dx = args[2] - args[0];
|
|
let dy = args[3] - args[1];
|
|
Ok((dx * dx + dy * dy).sqrt())
|
|
}
|
|
"ptangle" => {
|
|
if n != 4 { return Err(()); }
|
|
let dx = args[2] - args[0];
|
|
let dy = args[3] - args[1];
|
|
Ok(dy.atan2(dx))
|
|
}
|
|
_ => Err(()),
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Public API ──────────────────────────────────────────────────────
|
|
|
|
/// Evaluate a string as an f64 expression.
|
|
/// Returns None on parse error.
|
|
pub fn eval_number(input: &str) -> Option<f64> {
|
|
// Feet and inches (`2'9"`, `3'`, `9 1/2"`) are lengths, not arithmetic (#937).
|
|
if input.contains(['\'', '"']) {
|
|
return crate::entities::common::parse_typed_length(input);
|
|
}
|
|
let tokens = tokenize(input.trim()).ok()?;
|
|
let mut parser = Parser::new(tokens);
|
|
let result = parser.parse_expr(0).ok()?;
|
|
if parser.pos != parser.tokens.len() {
|
|
return None;
|
|
}
|
|
Some(result)
|
|
}
|
|
|
|
/// Evaluate a string as a number expression, returning the result as a string.
|
|
///
|
|
/// Plain numbers pass through unchanged. The entire string is evaluated if it
|
|
/// forms a valid expression. When the input contains a mix of expressions and
|
|
/// non-expression characters (commas, `@`, `#`, letters, etc.), the function
|
|
/// scans character-by-character, finds maximal contiguous substrings that parse
|
|
/// as valid expressions, evaluates each independently, and leaves everything
|
|
/// else as-is.
|
|
///
|
|
/// # Examples
|
|
///
|
|
/// ```ignore
|
|
/// // eval_to_string("10-5,0") == "5,0"
|
|
/// // eval_to_string("@10-5,0") == "@5,0"
|
|
/// // eval_to_string("def10*10abc") == "def100abc"
|
|
/// // eval_to_string("50") == "50"
|
|
/// // eval_to_string("10*5") == "50"
|
|
/// ```
|
|
pub fn eval_to_string(input: &str) -> String {
|
|
let trimmed = input.trim();
|
|
// Fast path: plain number or whole-string expression
|
|
if trimmed.parse::<f64>().is_ok() {
|
|
return trimmed.to_string();
|
|
}
|
|
if let Some(v) = eval_number(trimmed) {
|
|
return format!("{}", v);
|
|
}
|
|
|
|
// Length cap on the embedded-expression fallback below. That greedy
|
|
// longest-match rescan re-tokenises every (start, end) window, so its cost
|
|
// is super-linear (≈O(n³)): without a cap a few-KB paste of non-evaluable
|
|
// text freezes this synchronous, UI-thread caller for seconds. Whole-string
|
|
// numbers and expressions of any length already returned via the fast paths
|
|
// above; only mixed free-text scanning is bounded here. Over the cap we hand
|
|
// the text back unchanged rather than hunting for embedded sub-expressions.
|
|
if trimmed.len() > MAX_EVAL_SCAN_LEN {
|
|
return trimmed.to_string();
|
|
}
|
|
|
|
// Fallback: scan character-by-character, finding maximal expression substrings
|
|
let chars: Vec<char> = trimmed.chars().collect();
|
|
let len = chars.len();
|
|
let mut result = String::with_capacity(len);
|
|
let mut i = 0;
|
|
|
|
while i < len {
|
|
let mut found = false;
|
|
// Try longest substring first (greedy)
|
|
for end in (i + 1..=len).rev() {
|
|
if !starts_and_ends_on_word_boundary(&chars, i, end) {
|
|
continue;
|
|
}
|
|
let sub: String = chars[i..end].iter().collect();
|
|
if let Some(v) = eval_number(&sub) {
|
|
result.push_str(&format!("{}", v));
|
|
i = end;
|
|
found = true;
|
|
break;
|
|
}
|
|
}
|
|
if !found {
|
|
result.push(chars[i]);
|
|
i += 1;
|
|
}
|
|
}
|
|
result
|
|
}
|
|
|
|
/// Rejects expression candidates cut from a surrounding identifier.
|
|
fn starts_and_ends_on_word_boundary(chars: &[char], start: usize, end: usize) -> bool {
|
|
fn is_word(c: char) -> bool {
|
|
c.is_alphanumeric() || c == '_'
|
|
}
|
|
fn starts_ident(c: char) -> bool {
|
|
c.is_alphabetic() || c == '_'
|
|
}
|
|
|
|
// First token is an identifier: a word character just before it means this
|
|
// is the tail of a longer word rather than a name of its own.
|
|
if starts_ident(chars[start]) && start > 0 && is_word(chars[start - 1]) {
|
|
return false;
|
|
}
|
|
|
|
// Last token is an identifier when the trailing run of word characters
|
|
// begins with a letter or `_`. A word character just after `end` means that
|
|
// identifier continues past the candidate.
|
|
if end < chars.len() && is_word(chars[end]) {
|
|
let mut run = end;
|
|
while run > start && is_word(chars[run - 1]) {
|
|
run -= 1;
|
|
}
|
|
if run < end && starts_ident(chars[run]) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
true
|
|
}
|
|
|
|
// ── Tests ───────────────────────────────────────────────────────────
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn plain_number_passthrough() {
|
|
assert_eq!(eval_to_string("50"), "50");
|
|
assert_eq!(eval_to_string("-3.14"), "-3.14");
|
|
assert_eq!(eval_to_string("0"), "0");
|
|
assert_eq!(eval_to_string("42.0"), "42.0");
|
|
}
|
|
|
|
#[test]
|
|
fn arithmetic() {
|
|
assert_eq!(eval_to_string("10*5"), "50");
|
|
assert_eq!(eval_to_string("10 + 20"), "30");
|
|
assert_eq!(eval_to_string("100/4"), "25");
|
|
assert_eq!(eval_to_string("10-3"), "7");
|
|
assert_eq!(eval_to_string("2^3"), "8");
|
|
assert_eq!(eval_to_string("10%3"), "1");
|
|
assert_eq!(eval_to_string("100 * 2 + 50"), "250");
|
|
}
|
|
|
|
#[test]
|
|
fn precedence() {
|
|
assert_eq!(eval_to_string("2+3*4"), "14");
|
|
assert_eq!(eval_to_string("(2+3)*4"), "20");
|
|
assert_eq!(eval_to_string("2^3*4"), "32");
|
|
assert_eq!(eval_to_string("10/2+3"), "8");
|
|
assert_eq!(eval_to_string("-2+4"), "2");
|
|
assert_eq!(eval_to_string("3+4*5-2"), "21");
|
|
assert_eq!(eval_to_string("(1+2)*(3+4)"), "21");
|
|
}
|
|
|
|
#[test]
|
|
fn math_functions() {
|
|
assert_eq!(eval_to_string("sqrt(4)"), "2");
|
|
assert_eq!(eval_to_string("abs(-5)"), "5");
|
|
assert_eq!(eval_to_string("round(3.7)"), "4");
|
|
assert_eq!(eval_to_string("ceil(3.1)"), "4");
|
|
assert_eq!(eval_to_string("floor(3.9)"), "3");
|
|
assert_eq!(eval_to_string("sin(0)"), "0");
|
|
assert_eq!(eval_to_string("cos(0)"), "1");
|
|
assert_eq!(eval_to_string("exp(0)"), "1");
|
|
assert_eq!(eval_to_string("log(1)"), "0");
|
|
}
|
|
|
|
#[test]
|
|
fn constants() {
|
|
let pi: f64 = eval_to_string("pi").parse().unwrap();
|
|
assert!((pi - std::f64::consts::PI).abs() < 1e-10);
|
|
}
|
|
|
|
#[test]
|
|
fn coord_functions() {
|
|
assert_eq!(eval_to_string("ptdist(0,0,3,4)"), "5");
|
|
let angle: f64 = eval_to_string("ptangle(0,0,3,4)").parse().unwrap();
|
|
assert!((angle - 0.9272952180016122).abs() < 1e-10);
|
|
}
|
|
|
|
#[test]
|
|
fn nested() {
|
|
assert_eq!(eval_to_string("sqrt(16)+abs(-3)"), "7");
|
|
assert_eq!(eval_to_string("(2+3)*(4-1)"), "15");
|
|
let v: f64 = eval_to_string("sqrt(2^2+2^2)").parse().unwrap();
|
|
assert!((v - 2.8284271247461903).abs() < 1e-10);
|
|
}
|
|
|
|
#[test]
|
|
fn case_insensitive() {
|
|
assert_eq!(eval_to_string("SQRT(4)"), "2");
|
|
assert_eq!(eval_to_string("Abs(-5)"), "5");
|
|
assert_eq!(eval_to_string("Pi"), eval_to_string("pi"));
|
|
assert_eq!(eval_to_string("PI"), eval_to_string("pi"));
|
|
assert_eq!(eval_to_string("SIN(0)"), "0");
|
|
}
|
|
|
|
#[test]
|
|
fn invalid_passthrough() {
|
|
assert_eq!(eval_to_string(""), "");
|
|
assert_eq!(eval_to_string("abc"), "abc");
|
|
assert_eq!(eval_to_string("(5"), "(5");
|
|
assert_eq!(eval_to_string("foo(1,2)"), "foo(1,2)");
|
|
}
|
|
|
|
#[test]
|
|
fn eval_number_direct() {
|
|
assert_eq!(eval_number("10*5"), Some(50.0));
|
|
assert_eq!(eval_number("sqrt(4)"), Some(2.0));
|
|
assert_eq!(eval_number("abc"), None);
|
|
assert_eq!(eval_number("(5"), None);
|
|
assert_eq!(eval_number(""), None);
|
|
}
|
|
|
|
#[test]
|
|
fn division_by_zero() {
|
|
assert_eq!(eval_number("10/0"), Some(f64::INFINITY));
|
|
assert_eq!(eval_number("-5/0"), Some(f64::NEG_INFINITY));
|
|
}
|
|
|
|
#[test]
|
|
fn nested_parens() {
|
|
assert_eq!(eval_to_string("((2+3))"), "5");
|
|
assert_eq!(eval_to_string("(((10)))"), "10");
|
|
}
|
|
|
|
// `^` is right-associative, so a chained exponent evaluates as one number
|
|
// instead of stopping at the first `^` and leaving the rest as text.
|
|
#[test]
|
|
fn power_is_right_associative() {
|
|
assert_eq!(eval_to_string("2^3^2"), "512");
|
|
assert_eq!(eval_to_string("2^3^2+1"), "513");
|
|
assert_eq!(eval_to_string("4^0.5^2"), "1.4142135623730951"); // 4^(1/4)
|
|
assert_eq!(eval_to_string("2^2^2^2"), "65536");
|
|
// A single exponent, and a parenthesised one, are unaffected.
|
|
assert_eq!(eval_to_string("2^3"), "8");
|
|
assert_eq!(eval_to_string("2^(3^2)"), "512");
|
|
assert_eq!(eval_to_string("(2^3)^2"), "64");
|
|
assert_eq!(eval_to_string("2^-1"), "0.5");
|
|
}
|
|
|
|
#[test]
|
|
fn unary_ops() {
|
|
assert_eq!(eval_to_string("--5"), "5");
|
|
assert_eq!(eval_to_string("-(-3)"), "3");
|
|
assert_eq!(eval_to_string("+-5"), "-5");
|
|
}
|
|
|
|
#[test]
|
|
fn trig_functions() {
|
|
let result: f64 = eval_to_string("sin(pi/2)").parse().unwrap();
|
|
assert!((result - 1.0).abs() < 1e-10);
|
|
}
|
|
|
|
#[test]
|
|
fn complex_expr() {
|
|
assert_eq!(eval_to_string("2*(3+4)-5/2"), "11.5");
|
|
assert_eq!(eval_to_string("sqrt(3^2+4^2)"), "5");
|
|
}
|
|
|
|
// ── Embedded expression tests ─────────────────────────────────────
|
|
|
|
#[test]
|
|
fn embedded_expr_in_coordinate() {
|
|
assert_eq!(eval_to_string("10-5,0"), "5,0");
|
|
assert_eq!(eval_to_string("10+5,20-3"), "15,17");
|
|
assert_eq!(eval_to_string("2*3,4-1"), "6,3");
|
|
}
|
|
|
|
#[test]
|
|
fn embedded_expr_with_at_prefix() {
|
|
assert_eq!(eval_to_string("@10-5,0"), "@5,0");
|
|
assert_eq!(eval_to_string("@10+5,20-3"), "@15,17");
|
|
}
|
|
|
|
#[test]
|
|
fn embedded_expr_with_hash_prefix() {
|
|
assert_eq!(eval_to_string("#10-5,0"), "#5,0");
|
|
assert_eq!(eval_to_string("#2*3,4+1"), "#6,5");
|
|
}
|
|
|
|
#[test]
|
|
fn embedded_expr_with_surrounding_text() {
|
|
assert_eq!(eval_to_string("def10*10abc"), "def100abc");
|
|
assert_eq!(eval_to_string("x=5*3+end"), "x=15+end");
|
|
}
|
|
|
|
#[test]
|
|
fn embedded_expr_in_parentheses() {
|
|
assert_eq!(eval_to_string("(10+5),0"), "15,0");
|
|
assert_eq!(eval_to_string("(2+3)*(4-1),0"), "15,0");
|
|
}
|
|
|
|
#[test]
|
|
fn embedded_expr_with_functions() {
|
|
assert_eq!(eval_to_string("sqrt(4),3"), "2,3");
|
|
assert_eq!(eval_to_string("abs(-5),ceil(3.1)"), "5,4");
|
|
}
|
|
|
|
#[test]
|
|
fn embedded_identifier_inside_a_word_is_left_alone() {
|
|
// Known names must stay unchanged inside identifiers.
|
|
assert_eq!(eval_to_string("ASPIRATORE"), "ASPIRATORE");
|
|
assert_eq!(eval_to_string("PIRATE"), "PIRATE");
|
|
assert_eq!(eval_to_string("copies"), "copies");
|
|
assert_eq!(eval_to_string("TOPI"), "TOPI");
|
|
assert_eq!(eval_to_string("pi_2"), "pi_2");
|
|
assert_eq!(eval_to_string("şpiş"), "şpiş");
|
|
assert_eq!(eval_to_string("копияpi"), "копияpi");
|
|
// A standalone `pi` still is a constant.
|
|
assert_eq!(
|
|
eval_to_string("pipe,pi"),
|
|
format!("pipe,{}", std::f64::consts::PI)
|
|
);
|
|
assert_eq!(
|
|
eval_to_string("2*pi,0"),
|
|
format!("{},0", 2.0 * std::f64::consts::PI)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn embedded_expr_no_evaluables() {
|
|
assert_eq!(eval_to_string("abc,def"), "abc,def");
|
|
assert_eq!(eval_to_string("@,,"), "@,,");
|
|
}
|
|
|
|
#[test]
|
|
fn embedded_expr_multiple_sub_expressions() {
|
|
assert_eq!(eval_to_string("1+2,3*4,5-6"), "3,12,-1");
|
|
assert_eq!(eval_to_string("2*3+1,4/2-1"), "7,1");
|
|
}
|
|
|
|
#[test]
|
|
fn embedded_expr_text_with_3_commas() {
|
|
assert_eq!(eval_to_string("10-5,0,2+3,4"), "5,0,5,4");
|
|
assert_eq!(eval_to_string("@1+1,2*2,3-1,5/5"), "@2,4,2,1");
|
|
}
|
|
|
|
#[test]
|
|
fn embedded_expr_text_complex() {
|
|
assert_eq!(eval_to_string("start2*3end,5+5"), "start6end,10");
|
|
assert_eq!(eval_to_string("a10+5b,3*2,3@10-5@"), "a15b,6,3@5@");
|
|
assert_eq!(eval_to_string("#10+10,#5+5"), "#20,#10");
|
|
assert_eq!(eval_to_string("sqrt(9)+1,cos(0)+1"), "4,2");
|
|
}
|
|
|
|
#[test]
|
|
fn deeply_nested_parens_returns_none() {
|
|
let expr = "(".repeat(1000) + "1" + &")".repeat(1000);
|
|
assert_eq!(eval_number(&expr), None, "deeply nested expression should be rejected");
|
|
}
|
|
|
|
#[test]
|
|
fn moderately_nested_parens_still_evaluate() {
|
|
let expr = "(".repeat(30) + "1" + &")".repeat(30);
|
|
assert_eq!(eval_number(&expr), Some(1.0), "moderately nested expression should evaluate");
|
|
}
|
|
|
|
#[test]
|
|
fn nesting_limit_boundary_is_pinned() {
|
|
// ~6 parser frames per paren level → 41 accepted, 42 rejected. Pinned so
|
|
// a refactor that changes the method-chain length can't silently shift
|
|
// the real depth limit without a failing test.
|
|
let ok = "(".repeat(41) + "1" + &")".repeat(41);
|
|
let over = "(".repeat(42) + "1" + &")".repeat(42);
|
|
assert_eq!(eval_number(&ok), Some(1.0), "41 levels must still evaluate");
|
|
assert_eq!(eval_number(&over), None, "42 levels must be rejected");
|
|
}
|
|
|
|
#[test]
|
|
fn deep_unary_chain_is_rejected_not_overflow() {
|
|
// The unary recursion (parse_unary → parse_unary) is also depth-guarded:
|
|
// a long sign run must return None, not overflow the stack.
|
|
let expr = "-".repeat(100_000) + "1";
|
|
assert_eq!(eval_number(&expr), None);
|
|
}
|
|
|
|
#[test]
|
|
fn deep_function_arg_nesting_is_rejected_not_overflow() {
|
|
// parse_atom → parse_args → parse_expr is the third recursion entry.
|
|
let expr = "abs(".repeat(100_000) + "1" + &")".repeat(100_000);
|
|
assert_eq!(eval_number(&expr), None);
|
|
}
|
|
|
|
#[test]
|
|
fn eval_to_string_over_cap_returns_promptly_unchanged() {
|
|
// Above MAX_EVAL_SCAN_LEN the super-linear fallback scan must be skipped
|
|
// entirely (DoS guard): a long non-evaluable string returns unchanged
|
|
// without freezing. Without the cap this input takes seconds.
|
|
let junk = "x".repeat(50_000);
|
|
assert_eq!(eval_to_string(&junk), junk);
|
|
// Nested parens (the class the depth guard targets) routed through the
|
|
// production entry point must also return promptly, not hang.
|
|
let nested = "(".repeat(50_000) + "1" + &")".repeat(50_000);
|
|
assert_eq!(eval_to_string(&nested), nested);
|
|
}
|
|
}
|