From 8ce1faefe96abd8fb457ff45c2c4f60997799920 Mon Sep 17 00:00:00 2001 From: Keavon Chambers Date: Sun, 26 Jul 2026 13:15:53 -0700 Subject: [PATCH 1/2] Fix parsing regressions, make parsing 2.5x faster than the old pest parser, and clean up the math-parser rewrite --- Cargo.lock | 1 - libraries/math-parser/Cargo.toml | 1 - libraries/math-parser/src/constants.rs | 995 ++++++++++--------------- libraries/math-parser/src/executer.rs | 46 +- libraries/math-parser/src/lexer.rs | 51 +- libraries/math-parser/src/lib.rs | 56 +- libraries/math-parser/src/parser.rs | 120 ++- libraries/math-parser/src/value.rs | 40 +- 8 files changed, 587 insertions(+), 723 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 3e1634bd90..55f66d822c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3393,7 +3393,6 @@ version = "0.0.0" dependencies = [ "chumsky", "criterion", - "lazy_static", "num-complex", "thiserror 2.0.18", ] diff --git a/libraries/math-parser/Cargo.toml b/libraries/math-parser/Cargo.toml index 91e7d6b8da..acdfa7e3dc 100644 --- a/libraries/math-parser/Cargo.toml +++ b/libraries/math-parser/Cargo.toml @@ -9,7 +9,6 @@ license = "MIT OR Apache-2.0" [dependencies] thiserror = "2.0" -lazy_static = "1.5" num-complex = "0.4" chumsky = { version = "0.10", default-features = false, features = ["std"] } diff --git a/libraries/math-parser/src/constants.rs b/libraries/math-parser/src/constants.rs index 6abf90da7b..6598baae98 100644 --- a/libraries/math-parser/src/constants.rs +++ b/libraries/math-parser/src/constants.rs @@ -1,637 +1,422 @@ use crate::value::{Number, Value}; -use lazy_static::lazy_static; -use num_complex::{Complex, ComplexFloat}; -use std::collections::HashMap; +use num_complex::ComplexFloat; use std::f64::consts::{LN_2, PI}; -type FunctionImplementation = Box Option + Send + Sync>; -lazy_static! { - pub static ref DEFAULT_FUNCTIONS: HashMap<&'static str, FunctionImplementation> = { - let mut map: HashMap<&'static str, FunctionImplementation> = HashMap::new(); - - map.insert( - "sqrt", - Box::new(|values| match values{ - [Value::Number(Number::Real(real))] => Some(Value::Number(Number::Real(real.sqrt()))), - [Value::Number(Number::Complex(complex))] => Some(Value::Number(Number::Complex(complex.sqrt()))), - _ => None, - }) - ); - map.insert( - "sin", - Box::new(|values| match values { - [Value::Number(Number::Real(real))] => Some(Value::Number(Number::Real(real.sin()))), - [Value::Number(Number::Complex(complex))] => Some(Value::Number(Number::Complex(complex.sin()))), - _ => None, - }), - ); - - map.insert( - "cos", - Box::new(|values| match values { - [Value::Number(Number::Real(real))] => Some(Value::Number(Number::Real(real.cos()))), - [Value::Number(Number::Complex(complex))] => Some(Value::Number(Number::Complex(complex.cos()))), - _ => None, - }), - ); - - map.insert( - "tan", - Box::new(|values| match values { - [Value::Number(Number::Real(real))] => Some(Value::Number(Number::Real(real.tan()))), - [Value::Number(Number::Complex(complex))] => Some(Value::Number(Number::Complex(complex.tan()))), - _ => None, - }), - ); - - map.insert( - "csc", - Box::new(|values| match values { - [Value::Number(Number::Real(real))] => Some(Value::Number(Number::Real(real.sin().recip()))), - [Value::Number(Number::Complex(complex))] => Some(Value::Number(Number::Complex(complex.sin().recip()))), - _ => None, - }), - ); - - map.insert( - "sec", - Box::new(|values| match values { - [Value::Number(Number::Real(real))] => Some(Value::Number(Number::Real(real.cos().recip()))), - [Value::Number(Number::Complex(complex))] => Some(Value::Number(Number::Complex(complex.cos().recip()))), - _ => None, - }), - ); - - map.insert( - "cot", - Box::new(|values| match values { - [Value::Number(Number::Real(real))] => Some(Value::Number(Number::Real(real.tan().recip()))), - [Value::Number(Number::Complex(complex))] => Some(Value::Number(Number::Complex(complex.tan().recip()))), - _ => None, - }), - ); +pub type BuiltinFunction = fn(&[Value]) -> Option; + +/// Looks up a built-in math function by name, returning a plain function pointer so dispatch avoids hashing and dynamic allocation. +pub fn builtin_function(name: &str) -> Option { + Some(match name { + "sin" => |values| match values { + [Value::Number(Number::Real(real))] => Some(Value::Number(Number::Real(real.sin()))), + [Value::Number(Number::Complex(complex))] => Some(Value::Number(Number::Complex(complex.sin()))), + _ => None, + }, + + "cos" => |values| match values { + [Value::Number(Number::Real(real))] => Some(Value::Number(Number::Real(real.cos()))), + [Value::Number(Number::Complex(complex))] => Some(Value::Number(Number::Complex(complex.cos()))), + _ => None, + }, + + "tan" => |values| match values { + [Value::Number(Number::Real(real))] => Some(Value::Number(Number::Real(real.tan()))), + [Value::Number(Number::Complex(complex))] => Some(Value::Number(Number::Complex(complex.tan()))), + _ => None, + }, + + "csc" => |values| match values { + [Value::Number(Number::Real(real))] => Some(Value::Number(Number::Real(real.sin().recip()))), + [Value::Number(Number::Complex(complex))] => Some(Value::Number(Number::Complex(complex.sin().recip()))), + _ => None, + }, + + "sec" => |values| match values { + [Value::Number(Number::Real(real))] => Some(Value::Number(Number::Real(real.cos().recip()))), + [Value::Number(Number::Complex(complex))] => Some(Value::Number(Number::Complex(complex.cos().recip()))), + _ => None, + }, + + "cot" => |values| match values { + [Value::Number(Number::Real(real))] => Some(Value::Number(Number::Real(real.tan().recip()))), + [Value::Number(Number::Complex(complex))] => Some(Value::Number(Number::Complex(complex.tan().recip()))), + _ => None, + }, // Inverse trig with legacy names and standard aliases - map.insert( - "invsin", - Box::new(|values| match values { - [Value::Number(Number::Real(real))] => Some(Value::Number(Number::Real(real.asin()))), - [Value::Number(Number::Complex(complex))] => Some(Value::Number(Number::Complex(complex.asin()))), - _ => None, - }), - ); - map.insert( - "asin", - Box::new(|values| match values { - [Value::Number(Number::Real(real))] => Some(Value::Number(Number::Real(real.asin()))), - [Value::Number(Number::Complex(complex))] => Some(Value::Number(Number::Complex(complex.asin()))), - _ => None, - }), - ); - - map.insert( - "invcos", - Box::new(|values| match values { - [Value::Number(Number::Real(real))] => Some(Value::Number(Number::Real(real.acos()))), - [Value::Number(Number::Complex(complex))] => Some(Value::Number(Number::Complex(complex.acos()))), - _ => None, - }), - ); - map.insert( - "acos", - Box::new(|values| match values { - [Value::Number(Number::Real(real))] => Some(Value::Number(Number::Real(real.acos()))), - [Value::Number(Number::Complex(complex))] => Some(Value::Number(Number::Complex(complex.acos()))), - _ => None, - }), - ); - - map.insert( - "invtan", - Box::new(|values| match values { - [Value::Number(Number::Real(real))] => Some(Value::Number(Number::Real(real.atan()))), - [Value::Number(Number::Complex(complex))] => Some(Value::Number(Number::Complex(complex.atan()))), - _ => None, - }), - ); - map.insert( - "atan", - Box::new(|values| match values { - [Value::Number(Number::Real(real))] => Some(Value::Number(Number::Real(real.atan()))), - [Value::Number(Number::Complex(complex))] => Some(Value::Number(Number::Complex(complex.atan()))), - _ => None, - }), - ); - - map.insert( - "invcsc", - Box::new(|values| match values { - [Value::Number(Number::Real(real))] => Some(Value::Number(Number::Real(real.recip().asin()))), - [Value::Number(Number::Complex(complex))] => Some(Value::Number(Number::Complex(complex.recip().asin()))), - _ => None, - }), - ); - map.insert( - "acsc", - Box::new(|values| match values { - [Value::Number(Number::Real(real))] => Some(Value::Number(Number::Real(real.recip().asin()))), - [Value::Number(Number::Complex(complex))] => Some(Value::Number(Number::Complex(complex.recip().asin()))), - _ => None, - }), - ); - - map.insert( - "invsec", - Box::new(|values| match values { - [Value::Number(Number::Real(real))] => Some(Value::Number(Number::Real(real.recip().acos()))), - [Value::Number(Number::Complex(complex))] => Some(Value::Number(Number::Complex(complex.recip().acos()))), - _ => None, - }), - ); - map.insert( - "asec", - Box::new(|values| match values { - [Value::Number(Number::Real(real))] => Some(Value::Number(Number::Real(real.recip().acos()))), - [Value::Number(Number::Complex(complex))] => Some(Value::Number(Number::Complex(complex.recip().acos()))), - _ => None, - }), - ); - - map.insert( - "invcot", - Box::new(|values| match values { - [Value::Number(Number::Real(real))] => { - Some(Value::Number(Number::Real(real.recip().atan()))) - } - [Value::Number(Number::Complex(complex))] => { - Some(Value::Number(Number::Complex(complex.recip().atan()))) - } - _ => None, - }), - ); - map.insert( - "acot", - Box::new(|values| match values { - [Value::Number(Number::Real(real))] => { - Some(Value::Number(Number::Real(real.recip().atan()))) - } - [Value::Number(Number::Complex(complex))] => { - Some(Value::Number(Number::Complex(complex.recip().atan()))) - } - _ => None, - }), - ); + "invsin" => |values| match values { + [Value::Number(Number::Real(real))] => Some(Value::Number(Number::Real(real.asin()))), + [Value::Number(Number::Complex(complex))] => Some(Value::Number(Number::Complex(complex.asin()))), + _ => None, + }, + "asin" => |values| match values { + [Value::Number(Number::Real(real))] => Some(Value::Number(Number::Real(real.asin()))), + [Value::Number(Number::Complex(complex))] => Some(Value::Number(Number::Complex(complex.asin()))), + _ => None, + }, + + "invcos" => |values| match values { + [Value::Number(Number::Real(real))] => Some(Value::Number(Number::Real(real.acos()))), + [Value::Number(Number::Complex(complex))] => Some(Value::Number(Number::Complex(complex.acos()))), + _ => None, + }, + "acos" => |values| match values { + [Value::Number(Number::Real(real))] => Some(Value::Number(Number::Real(real.acos()))), + [Value::Number(Number::Complex(complex))] => Some(Value::Number(Number::Complex(complex.acos()))), + _ => None, + }, + + "invtan" => |values| match values { + [Value::Number(Number::Real(real))] => Some(Value::Number(Number::Real(real.atan()))), + [Value::Number(Number::Complex(complex))] => Some(Value::Number(Number::Complex(complex.atan()))), + _ => None, + }, + "atan" => |values| match values { + [Value::Number(Number::Real(real))] => Some(Value::Number(Number::Real(real.atan()))), + [Value::Number(Number::Complex(complex))] => Some(Value::Number(Number::Complex(complex.atan()))), + _ => None, + }, + + "invcsc" => |values| match values { + [Value::Number(Number::Real(real))] => Some(Value::Number(Number::Real(real.recip().asin()))), + [Value::Number(Number::Complex(complex))] => Some(Value::Number(Number::Complex(complex.recip().asin()))), + _ => None, + }, + "acsc" => |values| match values { + [Value::Number(Number::Real(real))] => Some(Value::Number(Number::Real(real.recip().asin()))), + [Value::Number(Number::Complex(complex))] => Some(Value::Number(Number::Complex(complex.recip().asin()))), + _ => None, + }, + + "invsec" => |values| match values { + [Value::Number(Number::Real(real))] => Some(Value::Number(Number::Real(real.recip().acos()))), + [Value::Number(Number::Complex(complex))] => Some(Value::Number(Number::Complex(complex.recip().acos()))), + _ => None, + }, + "asec" => |values| match values { + [Value::Number(Number::Real(real))] => Some(Value::Number(Number::Real(real.recip().acos()))), + [Value::Number(Number::Complex(complex))] => Some(Value::Number(Number::Complex(complex.recip().acos()))), + _ => None, + }, + + "invcot" => |values| match values { + [Value::Number(Number::Real(real))] => Some(Value::Number(Number::Real(real.recip().atan()))), + [Value::Number(Number::Complex(complex))] => Some(Value::Number(Number::Complex(complex.recip().atan()))), + _ => None, + }, + "acot" => |values| match values { + [Value::Number(Number::Real(real))] => Some(Value::Number(Number::Real(real.recip().atan()))), + [Value::Number(Number::Complex(complex))] => Some(Value::Number(Number::Complex(complex.recip().atan()))), + _ => None, + }, // Hyperbolic Functions - map.insert( - "sinh", - Box::new(|values| match values { - [Value::Number(Number::Real(real))] => Some(Value::Number(Number::Real(real.sinh()))), - [Value::Number(Number::Complex(complex))] => Some(Value::Number(Number::Complex(complex.sinh()))), - _ => None, - }), - ); - - map.insert( - "cosh", - Box::new(|values| match values { - [Value::Number(Number::Real(real))] => Some(Value::Number(Number::Real(real.cosh()))), - [Value::Number(Number::Complex(complex))] => Some(Value::Number(Number::Complex(complex.cosh()))), - _ => None, - }), - ); - - map.insert( - "tanh", - Box::new(|values| match values { - [Value::Number(Number::Real(real))] => Some(Value::Number(Number::Real(real.tanh()))), - [Value::Number(Number::Complex(complex))] => Some(Value::Number(Number::Complex(complex.tanh()))), - _ => None, - }), - ); + "sinh" => |values| match values { + [Value::Number(Number::Real(real))] => Some(Value::Number(Number::Real(real.sinh()))), + [Value::Number(Number::Complex(complex))] => Some(Value::Number(Number::Complex(complex.sinh()))), + _ => None, + }, + + "cosh" => |values| match values { + [Value::Number(Number::Real(real))] => Some(Value::Number(Number::Real(real.cosh()))), + [Value::Number(Number::Complex(complex))] => Some(Value::Number(Number::Complex(complex.cosh()))), + _ => None, + }, + + "tanh" => |values| match values { + [Value::Number(Number::Real(real))] => Some(Value::Number(Number::Real(real.tanh()))), + [Value::Number(Number::Complex(complex))] => Some(Value::Number(Number::Complex(complex.tanh()))), + _ => None, + }, // Reciprocal hyperbolic functions - map.insert( - "csch", - Box::new(|values| match values { - [Value::Number(Number::Real(real))] => Some(Value::Number(Number::Real(real.sinh().recip()))), - [Value::Number(Number::Complex(complex))] => Some(Value::Number(Number::Complex(complex.sinh().recip()))), - _ => None, - }), - ); - - map.insert( - "sech", - Box::new(|values| match values { - [Value::Number(Number::Real(real))] => Some(Value::Number(Number::Real(real.cosh().recip()))), - [Value::Number(Number::Complex(complex))] => Some(Value::Number(Number::Complex(complex.cosh().recip()))), - _ => None, - }), - ); - - map.insert( - "coth", - Box::new(|values| match values { - [Value::Number(Number::Real(real))] => Some(Value::Number(Number::Real(real.tanh().recip()))), - [Value::Number(Number::Complex(complex))] => Some(Value::Number(Number::Complex(complex.tanh().recip()))), - _ => None, - }), - ); + "csch" => |values| match values { + [Value::Number(Number::Real(real))] => Some(Value::Number(Number::Real(real.sinh().recip()))), + [Value::Number(Number::Complex(complex))] => Some(Value::Number(Number::Complex(complex.sinh().recip()))), + _ => None, + }, + + "sech" => |values| match values { + [Value::Number(Number::Real(real))] => Some(Value::Number(Number::Real(real.cosh().recip()))), + [Value::Number(Number::Complex(complex))] => Some(Value::Number(Number::Complex(complex.cosh().recip()))), + _ => None, + }, + + "coth" => |values| match values { + [Value::Number(Number::Real(real))] => Some(Value::Number(Number::Real(real.tanh().recip()))), + [Value::Number(Number::Complex(complex))] => Some(Value::Number(Number::Complex(complex.tanh().recip()))), + _ => None, + }, // Inverse Hyperbolic Functions - map.insert( - "asinh", - Box::new(|values| match values { - [Value::Number(Number::Real(real))] => Some(Value::Number(Number::Real(real.asinh()))), - [Value::Number(Number::Complex(complex))] => Some(Value::Number(Number::Complex(complex.asinh()))), - _ => None, - }), - ); - - map.insert( - "acosh", - Box::new(|values| match values { - [Value::Number(Number::Real(real))] => Some(Value::Number(Number::Real(real.acosh()))), - [Value::Number(Number::Complex(complex))] => Some(Value::Number(Number::Complex(complex.acosh()))), - _ => None, - }), - ); - - map.insert( - "atanh", - Box::new(|values| match values { - [Value::Number(Number::Real(real))] => Some(Value::Number(Number::Real(real.atanh()))), - [Value::Number(Number::Complex(complex))] => Some(Value::Number(Number::Complex(complex.atanh()))), - _ => None, - }), - ); + "asinh" => |values| match values { + [Value::Number(Number::Real(real))] => Some(Value::Number(Number::Real(real.asinh()))), + [Value::Number(Number::Complex(complex))] => Some(Value::Number(Number::Complex(complex.asinh()))), + _ => None, + }, + + "acosh" => |values| match values { + [Value::Number(Number::Real(real))] => Some(Value::Number(Number::Real(real.acosh()))), + [Value::Number(Number::Complex(complex))] => Some(Value::Number(Number::Complex(complex.acosh()))), + _ => None, + }, + + "atanh" => |values| match values { + [Value::Number(Number::Real(real))] => Some(Value::Number(Number::Real(real.atanh()))), + [Value::Number(Number::Complex(complex))] => Some(Value::Number(Number::Complex(complex.atanh()))), + _ => None, + }, // Inverse reciprocal hyperbolic functions - map.insert( - "acsch", - Box::new(|values| match values { - [Value::Number(Number::Real(real))] => Some(Value::Number(Number::Real(real.recip().asinh()))), - [Value::Number(Number::Complex(complex))] => Some(Value::Number(Number::Complex(complex.recip().asinh()))), - _ => None, - }), - ); - - map.insert( - "asech", - Box::new(|values| match values { - [Value::Number(Number::Real(real))] => Some(Value::Number(Number::Real(real.recip().acosh()))), - [Value::Number(Number::Complex(complex))] => Some(Value::Number(Number::Complex(complex.recip().acosh()))), - _ => None, - }), - ); - - map.insert( - "acoth", - Box::new(|values| match values { - [Value::Number(Number::Real(real))] => Some(Value::Number(Number::Real(real.recip().atanh()))), - [Value::Number(Number::Complex(complex))] => Some(Value::Number(Number::Complex(complex.recip().atanh()))), - _ => None, - }), - ); + "acsch" => |values| match values { + [Value::Number(Number::Real(real))] => Some(Value::Number(Number::Real(real.recip().asinh()))), + [Value::Number(Number::Complex(complex))] => Some(Value::Number(Number::Complex(complex.recip().asinh()))), + _ => None, + }, + + "asech" => |values| match values { + [Value::Number(Number::Real(real))] => Some(Value::Number(Number::Real(real.recip().acosh()))), + [Value::Number(Number::Complex(complex))] => Some(Value::Number(Number::Complex(complex.recip().acosh()))), + _ => None, + }, + + "acoth" => |values| match values { + [Value::Number(Number::Real(real))] => Some(Value::Number(Number::Real(real.recip().atanh()))), + [Value::Number(Number::Complex(complex))] => Some(Value::Number(Number::Complex(complex.recip().atanh()))), + _ => None, + }, // Logarithm Functions - map.insert( - "ln", - Box::new(|values| match values { - [Value::Number(Number::Real(real))] => Some(Value::Number(Number::Real(real.ln()))), - [Value::Number(Number::Complex(complex))] => Some(Value::Number(Number::Complex(complex.ln()))), - _ => None, - }), - ); + "ln" => |values| match values { + [Value::Number(Number::Real(real))] => Some(Value::Number(Number::Real(real.ln()))), + [Value::Number(Number::Complex(complex))] => Some(Value::Number(Number::Complex(complex.ln()))), + _ => None, + }, // Exponential / power helpers - map.insert( - "exp", - Box::new(|values| match values { - [Value::Number(Number::Real(real))] => Some(Value::Number(Number::Real(real.exp()))), - [Value::Number(Number::Complex(complex))] => Some(Value::Number(Number::Complex(complex.exp()))), - _ => None, - }), - ); - - map.insert( - "pow", - Box::new(|values| match values { - [Value::Number(Number::Real(x)), Value::Number(Number::Real(n))] => { - Some(Value::Number(Number::Real(x.powf(*n)))) - } - [Value::Number(Number::Complex(x)), Value::Number(Number::Real(n))] => { - Some(Value::Number(Number::Complex(x.powf(*n)))) - } - [Value::Number(Number::Complex(x)), Value::Number(Number::Complex(n))] => { - Some(Value::Number(Number::Complex(x.powc(*n)))) - } - _ => None, - }), - ); - - map.insert( - "root", - Box::new(|values| match values { - [Value::Number(Number::Real(x)), Value::Number(Number::Real(n))] => { - Some(Value::Number(Number::Real(x.powf(1. / *n)))) - } - [Value::Number(Number::Complex(x)), Value::Number(Number::Real(n))] => { - Some(Value::Number(Number::Complex(x.powf(1. / *n)))) - } - _ => None, - }), - ); - - map.insert( - "log", - Box::new(|values| match values { - [Value::Number(Number::Real(real))] => Some(Value::Number(Number::Real(real.log10()))), - [Value::Number(Number::Complex(complex))] => Some(Value::Number(Number::Complex(complex.log10()))), - [Value::Number(n), Value::Number(base)] => { - // Custom base logarithm using change of base formula - let compute_log = |x: f64, b: f64| -> f64 { x.ln() / b.ln() }; - match (n, base) { - (Number::Real(x), Number::Real(b)) => Some(Value::Number(Number::Real(compute_log(*x, *b)))), - _ => None, - } + "exp" => |values| match values { + [Value::Number(Number::Real(real))] => Some(Value::Number(Number::Real(real.exp()))), + [Value::Number(Number::Complex(complex))] => Some(Value::Number(Number::Complex(complex.exp()))), + _ => None, + }, + + "pow" => |values| match values { + [Value::Number(Number::Real(x)), Value::Number(Number::Real(n))] => Some(Value::Number(Number::Real(x.powf(*n)))), + [Value::Number(Number::Complex(x)), Value::Number(Number::Real(n))] => Some(Value::Number(Number::Complex(x.powf(*n)))), + [Value::Number(Number::Complex(x)), Value::Number(Number::Complex(n))] => Some(Value::Number(Number::Complex(x.powc(*n)))), + _ => None, + }, + + "root" => |values| match values { + [Value::Number(Number::Real(x)), Value::Number(Number::Real(n))] => { + // Odd integer roots of negative reals are real, which powf alone would report as NaN + let root = if *x < 0. && n.rem_euclid(2.) == 1. { -(-x).powf(1. / *n) } else { x.powf(1. / *n) }; + Some(Value::Number(Number::Real(root))) + } + [Value::Number(Number::Complex(x)), Value::Number(Number::Real(n))] => Some(Value::Number(Number::Complex(x.powf(1. / *n)))), + _ => None, + }, + + "log" => |values| match values { + [Value::Number(Number::Real(real))] => Some(Value::Number(Number::Real(real.log10()))), + [Value::Number(Number::Complex(complex))] => Some(Value::Number(Number::Complex(complex.log10()))), + [Value::Number(n), Value::Number(base)] => { + // Custom base logarithm using change of base formula + let compute_log = |x: f64, b: f64| -> f64 { x.ln() / b.ln() }; + match (n, base) { + (Number::Real(x), Number::Real(b)) => Some(Value::Number(Number::Real(compute_log(*x, *b)))), + _ => None, } - _ => None, - }), - ); - - map.insert( - "log2", - Box::new(|values| match values { - [Value::Number(Number::Real(real))] => Some(Value::Number(Number::Real(real.log2()))), - [Value::Number(Number::Complex(complex))] => Some(Value::Number(Number::Complex(complex / LN_2))), - _ => None, - }), - ); + } + _ => None, + }, + + "log2" => |values| match values { + [Value::Number(Number::Real(real))] => Some(Value::Number(Number::Real(real.log2()))), + [Value::Number(Number::Complex(complex))] => Some(Value::Number(Number::Complex(complex.ln() / LN_2))), + _ => None, + }, // Root Functions - map.insert( - "sqrt", - Box::new(|values| match values { - [Value::Number(Number::Real(real))] => Some(Value::Number(Number::Real(real.sqrt()))), - [Value::Number(Number::Complex(complex))] => Some(Value::Number(Number::Complex(complex.sqrt()))), - _ => None, - }), - ); - - map.insert( - "cbrt", - Box::new(|values| match values { - [Value::Number(Number::Real(real))] => Some(Value::Number(Number::Real(real.cbrt()))), - [Value::Number(Number::Complex(complex))] => Some(Value::Number(Number::Complex(complex.powf(1./3.)))), - _ => None, - }), - ); + "sqrt" => |values| match values { + [Value::Number(Number::Real(real))] => Some(Value::Number(Number::Real(real.sqrt()))), + [Value::Number(Number::Complex(complex))] => Some(Value::Number(Number::Complex(complex.sqrt()))), + _ => None, + }, + + "cbrt" => |values| match values { + [Value::Number(Number::Real(real))] => Some(Value::Number(Number::Real(real.cbrt()))), + [Value::Number(Number::Complex(complex))] => Some(Value::Number(Number::Complex(complex.powf(1. / 3.)))), + _ => None, + }, // Geometry Functions - map.insert( - "hypot", - Box::new(|values| match values { - [Value::Number(Number::Real(a)), Value::Number(Number::Real(b))] => { - Some(Value::Number(Number::Real(a.hypot(*b)))) - }, - _ => None, - }), - ); - - map.insert( - "atan2", - Box::new(|values| match values { - [Value::Number(Number::Real(y)), Value::Number(Number::Real(x))] => { - Some(Value::Number(Number::Real(y.atan2(*x)))) - } - _ => None, - }), - ); + "hypot" => |values| match values { + [Value::Number(Number::Real(a)), Value::Number(Number::Real(b))] => Some(Value::Number(Number::Real(a.hypot(*b)))), + _ => None, + }, + + "atan2" => |values| match values { + [Value::Number(Number::Real(y)), Value::Number(Number::Real(x))] => Some(Value::Number(Number::Real(y.atan2(*x)))), + _ => None, + }, // Mapping Functions - map.insert( - "abs", - Box::new(|values| match values { - [Value::Number(Number::Real(real))] => Some(Value::Number(Number::Real(real.abs()))), - [Value::Number(Number::Complex(complex))] => Some(Value::Number(Number::Real(complex.abs()))), - _ => None, - }), - ); - - map.insert( - "floor", - Box::new(|values| match values { - [Value::Number(Number::Real(real))] => Some(Value::Number(Number::Real(real.floor()))), - _ => None, - }), - ); - - map.insert( - "ceil", - Box::new(|values| match values { - [Value::Number(Number::Real(real))] => Some(Value::Number(Number::Real(real.ceil()))), - _ => None, - }), - ); - - map.insert( - "round", - Box::new(|values| match values { - [Value::Number(Number::Real(real))] => Some(Value::Number(Number::Real(real.round()))), - _ => None, - }), - ); - - map.insert( - "clamp", - Box::new(|values| match values { - [Value::Number(Number::Real(x)), Value::Number(Number::Real(min)), Value::Number(Number::Real(max))] => { - Some(Value::Number(Number::Real(x.clamp(*min, *max)))) - }, - _ => None, - }), - ); - - map.insert( - "lerp", - Box::new(|values| match values { - [Value::Number(Number::Real(a)), Value::Number(Number::Real(b)), Value::Number(Number::Real(t))] => { - Some(Value::Number(Number::Real(a + (b - a) * t))) - }, - _ => None, - }), - ); - - map.insert( - "remap", - Box::new(|values| match values { - [ - Value::Number(Number::Real(value)), - Value::Number(Number::Real(in_a)), - Value::Number(Number::Real(in_b)), - Value::Number(Number::Real(out_a)), - Value::Number(Number::Real(out_b)), - ] => { - let t = (*value - *in_a) / (*in_b - *in_a); - Some(Value::Number(Number::Real(out_a + t * (out_b - out_a)))) + "abs" => |values| match values { + [Value::Number(Number::Real(real))] => Some(Value::Number(Number::Real(real.abs()))), + [Value::Number(Number::Complex(complex))] => Some(Value::Number(Number::Real(complex.abs()))), + _ => None, + }, + + "floor" => |values| match values { + [Value::Number(Number::Real(real))] => Some(Value::Number(Number::Real(real.floor()))), + _ => None, + }, + + "ceil" => |values| match values { + [Value::Number(Number::Real(real))] => Some(Value::Number(Number::Real(real.ceil()))), + _ => None, + }, + + "round" => |values| match values { + [Value::Number(Number::Real(real))] => Some(Value::Number(Number::Real(real.round()))), + _ => None, + }, + + "clamp" => |values| match values { + [Value::Number(Number::Real(x)), Value::Number(Number::Real(min)), Value::Number(Number::Real(max))] => Some(Value::Number(Number::Real(x.clamp(*min, *max)))), + _ => None, + }, + + "lerp" => |values| match values { + [Value::Number(Number::Real(a)), Value::Number(Number::Real(b)), Value::Number(Number::Real(t))] => Some(Value::Number(Number::Real(a + (b - a) * t))), + _ => None, + }, + + "remap" => |values| match values { + [ + Value::Number(Number::Real(value)), + Value::Number(Number::Real(in_a)), + Value::Number(Number::Real(in_b)), + Value::Number(Number::Real(out_a)), + Value::Number(Number::Real(out_b)), + ] => { + let t = (*value - *in_a) / (*in_b - *in_a); + Some(Value::Number(Number::Real(out_a + t * (out_b - out_a)))) + } + _ => None, + }, + + "trunc" => |values| match values { + [Value::Number(Number::Real(real))] => Some(Value::Number(Number::Real(real.trunc()))), + _ => None, + }, + + "fract" => |values| match values { + [Value::Number(Number::Real(real))] => Some(Value::Number(Number::Real(real.fract()))), + _ => None, + }, + + "sign" => |values| match values { + [Value::Number(Number::Real(real))] => { + let s = if *real > 0. { + 1. + } else if *real < 0. { + -1. + } else { + 0. + }; + Some(Value::Number(Number::Real(s))) + } + _ => None, + }, + + "gcd" => |values| match values { + [Value::Number(Number::Real(a)), Value::Number(Number::Real(b))] => { + if !a.is_finite() || !b.is_finite() { + return Some(Value::Number(Number::Real(f64::NAN))); } - _ => None, - }), - ); - - map.insert( - "trunc", - Box::new(|values| match values { - [Value::Number(Number::Real(real))] => Some(Value::Number(Number::Real(real.trunc()))), - _ => None, - }), - ); - - map.insert( - "fract", - Box::new(|values| match values { - [Value::Number(Number::Real(real))] => Some(Value::Number(Number::Real(real.fract()))), - _ => None, - }), - ); - - map.insert( - "sign", - Box::new(|values| match values { - [Value::Number(Number::Real(real))] => { - let s = if *real > 0. { - 1. - } else if *real < 0. { - -1. - } else { - 0. - }; - Some(Value::Number(Number::Real(s))) + let mut x = (a.trunc() as i64).unsigned_abs(); + let mut y = (b.trunc() as i64).unsigned_abs(); + while y != 0 { + let r = x % y; + x = y; + y = r; } - _ => None, - }), - ); - - map.insert( - "gcd", - Box::new(|values| match values { - [Value::Number(Number::Real(a)), Value::Number(Number::Real(b))] => { - let mut x = a.trunc() as i64; - let mut y = b.trunc() as i64; - if x == 0 && y == 0 { - return Some(Value::Number(Number::Real(0.))); - } - x = x.abs(); - y = y.abs(); - while y != 0 { - let r = x % y; - x = y; - y = r; - } - Some(Value::Number(Number::Real(x as f64))) + Some(Value::Number(Number::Real(x as f64))) + } + _ => None, + }, + + "lcm" => |values| match values { + [Value::Number(Number::Real(a)), Value::Number(Number::Real(b))] => { + if !a.is_finite() || !b.is_finite() { + return Some(Value::Number(Number::Real(f64::NAN))); } - _ => None, - }), - ); - - map.insert( - "lcm", - Box::new(|values| match values { - [Value::Number(Number::Real(a)), Value::Number(Number::Real(b))] => { - let mut x = a.trunc() as i64; - let mut y = b.trunc() as i64; - x = x.abs(); - y = y.abs(); - if x == 0 || y == 0 { - return Some(Value::Number(Number::Real(0.))); - } - - // gcd - let mut gx = x; - let mut gy = y; - while gy != 0 { - let r = gx % gy; - gx = gy; - gy = r; - } - let lcm = (x / gx) * y; - Some(Value::Number(Number::Real(lcm as f64))) + let x = (a.trunc() as i64).unsigned_abs(); + let y = (b.trunc() as i64).unsigned_abs(); + if x == 0 || y == 0 { + return Some(Value::Number(Number::Real(0.))); } - _ => None, - }), - ); - // Complex Number Functions - map.insert( - "real", - Box::new(|values| match values { - [Value::Number(Number::Complex(complex))] => Some(Value::Number(Number::Real(complex.re))), - [Value::Number(Number::Real(real))] => Some(Value::Number(Number::Real(*real))), - _ => None, - }), - ); - - map.insert( - "imag", - Box::new(|values| match values { - [Value::Number(Number::Complex(complex))] => Some(Value::Number(Number::Real(complex.im))), - [Value::Number(Number::Real(_))] => Some(Value::Number(Number::Real(0.))), - _ => None, - }), - ); - - map.insert( - "conj", - Box::new(|values| match values { - [Value::Number(Number::Complex(complex))] => { - Some(Value::Number(Number::Complex(complex.conj()))) - } - [Value::Number(Number::Real(real))] => Some(Value::Number(Number::Real(*real))), - _ => None, - }), - ); - - map.insert( - "arg", - Box::new(|values| match values { - [Value::Number(Number::Complex(complex))] => { - Some(Value::Number(Number::Real(complex.arg()))) + // gcd + let mut gx = x; + let mut gy = y; + while gy != 0 { + let r = gx % gy; + gx = gy; + gy = r; } - [Value::Number(Number::Real(real))] => { - let angle = if *real >= 0. { 0. } else { PI }; - Some(Value::Number(Number::Real(angle))) - } - _ => None, - }), - ); + + // Multiply in f64 so huge inputs can't overflow the integer range + let lcm = (x / gx) as f64 * y as f64; + Some(Value::Number(Number::Real(lcm))) + } + _ => None, + }, + + // Complex Number Functions + "real" => |values| match values { + [Value::Number(Number::Complex(complex))] => Some(Value::Number(Number::Real(complex.re))), + [Value::Number(Number::Real(real))] => Some(Value::Number(Number::Real(*real))), + _ => None, + }, + + "imag" => |values| match values { + [Value::Number(Number::Complex(complex))] => Some(Value::Number(Number::Real(complex.im))), + [Value::Number(Number::Real(_))] => Some(Value::Number(Number::Real(0.))), + _ => None, + }, + + "conj" => |values| match values { + [Value::Number(Number::Complex(complex))] => Some(Value::Number(Number::Complex(complex.conj()))), + [Value::Number(Number::Real(real))] => Some(Value::Number(Number::Real(*real))), + _ => None, + }, + + "arg" => |values| match values { + [Value::Number(Number::Complex(complex))] => Some(Value::Number(Number::Real(complex.arg()))), + [Value::Number(Number::Real(real))] => { + let angle = if *real >= 0. { 0. } else { PI }; + Some(Value::Number(Number::Real(angle))) + } + _ => None, + }, // Logical Functions - map.insert( - "isnan", - Box::new(|values| match values { - [Value::Number(Number::Real(real))] => Some(Value::Number(Number::Real(if real.is_nan() { 1. } else { 0. }))), - _ => None, - }), - ); - - map.insert( - "eq", - Box::new(|values| match values { - [Value::Number(a), Value::Number(b)] => Some(Value::Number(Number::Real(if a == b { 1. } else { 0. }))), - _ => None, - }), - ); - - map.insert( - "greater", - Box::new(|values| match values { - [Value::Number(Number::Real(a)), Value::Number(Number::Real(b))] => { - Some(Value::Number(Number::Real(if a > b { 1. } else { 0. }))) - }, - _ => None, - }), - ); - - map - }; + "isnan" => |values| match values { + [Value::Number(Number::Real(real))] => Some(Value::Number(Number::Real(if real.is_nan() { 1. } else { 0. }))), + _ => None, + }, + + "eq" => |values| match values { + [Value::Number(a), Value::Number(b)] => Some(Value::Number(Number::Real(if a == b { 1. } else { 0. }))), + _ => None, + }, + + "greater" => |values| match values { + [Value::Number(Number::Real(a)), Value::Number(Number::Real(b))] => Some(Value::Number(Number::Real(if a > b { 1. } else { 0. }))), + _ => None, + }, + _ => return None, + }) } diff --git a/libraries/math-parser/src/executer.rs b/libraries/math-parser/src/executer.rs index d2706a843c..10ccb9b469 100644 --- a/libraries/math-parser/src/executer.rs +++ b/libraries/math-parser/src/executer.rs @@ -1,5 +1,5 @@ use crate::ast::{Literal, Node}; -use crate::constants::DEFAULT_FUNCTIONS; +use crate::constants::builtin_function; use crate::context::{EvalContext, FunctionProvider, ValueProvider}; use crate::value::{Number, Value}; use num_complex::Complex; @@ -12,8 +12,12 @@ pub enum EvalError { #[error("Missing function: {0}")] MissingFunction(String), - #[error("Wrong type for function call")] + + #[error("Wrong argument types for function call")] TypeError, + + #[error("Unsupported operand types for operator")] + OperatorTypeError, } impl Node { @@ -25,27 +29,49 @@ impl Node { }, Node::BinOp { lhs, op, rhs } => match (lhs.eval(context)?, rhs.eval(context)?) { - (Value::Number(lhs), Value::Number(rhs)) => Ok(Value::Number(lhs.binary_op(*op, rhs).ok_or(EvalError::TypeError)?)), + (Value::Number(lhs), Value::Number(rhs)) => Ok(Value::Number(lhs.binary_op(*op, rhs).ok_or(EvalError::OperatorTypeError)?)), }, Node::UnaryOp { expr, op } => match expr.eval(context)? { Value::Number(num) => Ok(Value::Number(num.unary_op(*op))), }, Node::Var(name) => context.get_value(name).ok_or_else(|| EvalError::MissingValue(name.clone())), Node::FnCall { name, expr } => { - let values = expr.iter().map(|expr| expr.eval(context)).collect::, EvalError>>()?; + // Arguments land in a stack buffer when they fit (builtins take at most 5), avoiding a heap allocation per call + let mut stack_values = [Value::from_f64(0.); 5]; + let heap_values: Vec; + let values: &[Value] = if expr.len() <= stack_values.len() { + for (slot, argument) in stack_values.iter_mut().zip(expr) { + *slot = argument.eval(context)?; + } + &stack_values[..expr.len()] + } else { + heap_values = expr.iter().map(|argument| argument.eval(context)).collect::, EvalError>>()?; + &heap_values + }; - if let Some(function) = DEFAULT_FUNCTIONS.get(&name.as_str()) { - function(&values).ok_or(EvalError::TypeError) - } else if let Some(val) = context.run_function(name, &values) { + if let Some(function) = builtin_function(name) { + function(values).ok_or(EvalError::TypeError) + } else if let Some(val) = context.run_function(name, values) { Ok(val) } else { context.get_value(name).ok_or_else(|| EvalError::MissingFunction(name.to_string())) } } Node::Conditional { condition, if_block, else_block } => { + // A NaN condition yields NaN rather than arbitrarily picking a branch let condition = match condition.eval(context)? { - Value::Number(Number::Real(number)) => number != 0., - Value::Number(Number::Complex(number)) => number != Complex::ZERO, + Value::Number(Number::Real(number)) => { + if number.is_nan() { + return Ok(Value::from_f64(f64::NAN)); + } + number != 0. + } + Value::Number(Number::Complex(number)) => { + if number.re.is_nan() || number.im.is_nan() { + return Ok(Value::from_f64(f64::NAN)); + } + number != Complex::ZERO + } }; if condition { if_block.eval(context) } else { else_block.eval(context) } @@ -57,7 +83,7 @@ impl Node { #[cfg(test)] mod tests { use crate::ast::{BinaryOp, Literal, Node, UnaryOp}; - use crate::context::{EvalContext, ValueMap}; + use crate::context::EvalContext; use crate::value::Value; macro_rules! eval_tests { diff --git a/libraries/math-parser/src/lexer.rs b/libraries/math-parser/src/lexer.rs index c04ba3d476..752040b6b3 100644 --- a/libraries/math-parser/src/lexer.rs +++ b/libraries/math-parser/src/lexer.rs @@ -1,14 +1,9 @@ use crate::ast::Literal; use chumsky::input::{Input, ValueInput}; -use chumsky::prelude::*; use chumsky::span::SimpleSpan; -use chumsky::text::{ident, int}; -use core::f64; use num_complex::Complex64; use std::fmt; -use std::iter::Peekable; use std::ops::Range; -use std::str::Chars; pub type Span = SimpleSpan; @@ -40,6 +35,9 @@ pub enum Token<'src> { EqEq, If, + + /// An unrecognized character; the parser never matches this, forcing a parse error rather than silently truncating the input. + Error, } impl<'src> fmt::Display for Token<'src> { @@ -71,6 +69,8 @@ impl<'src> fmt::Display for Token<'src> { Token::EqEq => f.write_str("=="), Token::If => f.write_str("if"), + + Token::Error => f.write_str(""), } } } @@ -162,15 +162,12 @@ impl<'a> Lexer<'a> { &self.input[start..self.pos] } - fn lex_ident(&mut self) -> &'a str { - self.consume_while(|c| c.is_alphanumeric() || c == '_') - } - - fn lex_uint(&mut self) -> Option<(u64, usize)> { - let mut v = 0u64; + // Digit runs accumulate in f64 so arbitrarily long literals approximate instead of overflowing an integer type + fn lex_uint(&mut self) -> Option<(f64, usize)> { + let mut v = 0_f64; let mut digits = 0; while let Some(d) = self.peek().and_then(|c| c.to_digit(10)) { - v = v * 10 + d as u64; + v = v * 10. + d as f64; digits += 1; self.bump(); } @@ -179,14 +176,14 @@ impl<'a> Lexer<'a> { fn lex_number(&mut self) -> Option { let start_pos = self.pos; - let (int_val, int_digits) = self.lex_uint().unwrap_or((0, 0)); + let (int_val, int_digits) = self.lex_uint().unwrap_or((0., 0)); let mut got_digit = int_digits > 0; - let mut num = int_val as f64; + let mut num = int_val; if self.peek() == Some('.') { self.bump(); if let Some((frac_val, frac_digits)) = self.lex_uint() { - num += (frac_val as f64) / 10f64.powi(frac_digits as i32); + num += frac_val / 10f64.powi(frac_digits as i32); got_digit = true; } } @@ -212,6 +209,12 @@ impl<'a> Lexer<'a> { } } + // A numeric literal cannot be glued directly to another by a stray decimal point or digit (e.g. `1..5`, `1.5.5`), so reject rather than letting it parse as implicit multiplication. + if got_digit && self.peek().is_some_and(|c| c == '.' || c.is_ascii_digit()) { + self.pos = start_pos; + return None; + } + got_digit.then_some(num) } @@ -231,7 +234,7 @@ impl<'a> Lexer<'a> { self.bump(); AndAnd } else { - return None; + Error } } '|' => { @@ -239,7 +242,7 @@ impl<'a> Lexer<'a> { self.bump(); OrOr } else { - return None; + Error } } @@ -287,13 +290,21 @@ impl<'a> Lexer<'a> { self.bump(); EqEq } else { - return None; + Error } } c if c.is_ascii_digit() || (c == '.' && self.peek().is_some_and(|c| c.is_ascii_digit())) => { self.pos = start; - Float(self.lex_number()?) + match self.lex_number() { + Some(number) => Float(number), + // `lex_number` resets `pos` on failure, so advance past one character to guarantee forward progress. + None => { + self.pos = start; + self.bump(); + Error + } + } } _ => { @@ -307,7 +318,7 @@ impl<'a> Lexer<'a> { } else if ch.is_alphanumeric() { Ident(ident) } else { - return None; + Error } } }; diff --git a/libraries/math-parser/src/lib.rs b/libraries/math-parser/src/lib.rs index 6209e65e05..854677d683 100644 --- a/libraries/math-parser/src/lib.rs +++ b/libraries/math-parser/src/lib.rs @@ -1,5 +1,3 @@ -#![allow(unused)] - pub mod ast; mod constants; pub mod context; @@ -8,8 +6,7 @@ pub mod lexer; pub mod parser; pub mod value; -use ast::Unit; -use context::{EvalContext, ValueMap}; +use context::EvalContext; use executer::EvalError; use parser::ParseError; use value::Value; @@ -23,25 +20,38 @@ pub fn evaluate(expression: &str) -> Result, ParseError #[cfg(test)] mod tests { use super::*; - use ast::Unit; use value::Number; const EPSILON: f64 = 1e-10_f64; + #[test] + fn malformed_juxtaposed_numbers_fail_to_parse() { + // Two numbers cannot be glued together by a stray decimal point (they must not parse as implicit multiplication). + for input in ["1..5", "1.5.5", "1..", ".5.5"] { + assert!(evaluate(input).is_err(), "expected `{input}` to be a parse error"); + } + } + + #[test] + fn unrecognized_characters_fail_to_parse() { + // Unrecognized trailing input must be rejected rather than silently dropped after a valid prefix. + for input in ["2@", "5#", "2 $ 3", "sqrt(4)@", "5 & 3", "5 | 3", "2 = 3"] { + assert!(evaluate(input).is_err(), "expected `{input}` to be a parse error"); + } + } + fn run_end_to_end_test(input: &str, expected_value: Value) { let expr = match ast::Node::try_parse_from_str(input) { Ok(expr) => expr, Err(err) => panic!("failed to parse `{input}`: {err}"), }; - dbg!(&expr); let context = EvalContext::default(); let actual_value = match expr.eval(&context) { Ok(v) => v, - Err(err) => panic!("failed to evaluate {input} becuase of error {err}"), + Err(err) => panic!("failed to evaluate `{input}` because of error {err}"), }; - // compare match (actual_value, expected_value) { (Value::Number(Number::Complex(a)), Value::Number(Number::Complex(e))) => { // real part @@ -128,6 +138,17 @@ mod tests { exponent_single: "2^3" => 8., exponent_mixed_operations: "2^3 + 4^2" => 24., exponent_nested: "2^(3+1)" => 16., + exponent_right_associative: "2^2^3" => 256., + exponent_unary_operand: "2^-1" => 0.5, + + // Implicit multiplication binds like `*`/`/`: tighter than `+`, looser than `^`, left to right + implicit_multiplication_constant: "2pi" => 2. * std::f64::consts::PI, + implicit_multiplication_before_addition: "2pi + 1" => 2. * std::f64::consts::PI + 1., + implicit_multiplication_shares_division: "1/2pi" => std::f64::consts::PI / 2., + implicit_multiplication_left_to_right: "6/2pi" => 3. * std::f64::consts::PI, + implicit_multiplication_power_operand: "2pi^2" => 2. * std::f64::consts::PI.powi(2), + implicit_multiplication_function: "2sqrt(4)" => 4., + implicit_multiplication_excludes_unary_minus: "2 -3" => -1., // Factorial (postfix !) factorial_simple: "5!" => 120., @@ -275,5 +296,24 @@ mod tests { // Complex nested expressions if_nested_expr: "if((sqrt(16) + 2) * (sin(pi) + 1), 3 + 4 * 2, 5 - 2 / 1)" => 11., + + // Overflow-safe evaluation + factorial_overflows_to_infinity: "171!" => f64::INFINITY, + factorial_huge_input: "10000000000000000000000!" => f64::INFINITY, + lcm_huge_no_overflow: "lcm(1099511627776, 1099511627775)" => 1099511627776. * 1099511627775., + gcd_non_finite: "gcd(inf, 6)" => f64::NAN, + long_literal: "10000000000000000000000" => 1e22, + huge_exponent_saturates: "1e4294967296" => f64::INFINITY, + + // Odd integer roots of negative values are real + root_negative_odd: "root(-8, 3)" => -2., + root_negative_odd_reciprocal: "root(-8, -3)" => -0.5, + root_negative_even: "root(-4, 2)" => f64::NAN, + + // NaN poisons conditions and logic instead of acting as a boolean + if_nan_condition: "if(sqrt(-1), 1, 2)" => f64::NAN, + nan_and: "sqrt(-1) && 1" => f64::NAN, + nan_or: "sqrt(-1) || 1" => f64::NAN, + nan_not: "!sqrt(-1)" => f64::NAN, } } diff --git a/libraries/math-parser/src/parser.rs b/libraries/math-parser/src/parser.rs index aabc7204c9..f37de39730 100644 --- a/libraries/math-parser/src/parser.rs +++ b/libraries/math-parser/src/parser.rs @@ -1,15 +1,9 @@ -use crate::ast::{BinaryOp, Literal, Node, UnaryOp, Unit}; -use crate::context::EvalContext; +use crate::ast::{BinaryOp, Literal, Node, UnaryOp}; use crate::lexer::{Lexer, Span, Token}; -use crate::value::{Complex, Number, Value}; -use chumsky::container::Seq; -use chumsky::input::{BorrowInput, ValueInput}; +use chumsky::error::LabelError; +use chumsky::input::ValueInput; use chumsky::{Parser, prelude::*}; -use lazy_static::lazy_static; -use num_complex::ComplexFloat; use std::fmt; -use std::num::{ParseFloatError, ParseIntError}; -use thiserror::Error; /// One message per parse failure, each tagged with its byte range in the source expression. #[derive(Debug)] @@ -31,18 +25,23 @@ impl std::error::Error for ParseError {} impl Node { pub fn try_parse_from_str(src: &str) -> Result { - let tokens = Lexer::new(src); + // Parse with zero-cost errors first (several times faster), then re-parse invalid input with rich errors to build the messages + if let Ok(ast) = parser::().parse(Lexer::new(src)).into_result() { + return Ok(ast); + } - match parser().parse(tokens).into_result() { + match parser::>>().parse(Lexer::new(src)).into_result() { Ok(ast) => Ok(ast), Err(parse_errs) => Err(ParseError(parse_errs.into_iter().map(|e| format!("{e} at {}", e.span())).collect())), } } } -pub fn parser<'src, I>() -> impl Parser<'src, I, Node, extra::Err, Span>>> +pub fn parser<'src, I, E>() -> impl Parser<'src, I, Node, E> where I: ValueInput<'src, Token = Token<'src>, Span = Span>, + E: extra::ParserExtra<'src, I>, + E::Error: LabelError<'src, I, &'static str>, { recursive(|expr| { let constant = select! { @@ -52,31 +51,27 @@ where let args = expr.clone().separated_by(just(Token::Comma)).collect::>().delimited_by(just(Token::LParen), just(Token::RParen)); - let if_expr = just(Token::If) - .ignore_then(args.clone()) // Parses (cond, a, b) - .try_map(|args: Vec, span| { - if args.len() != 3 { - return Err(Rich::custom(span, "Expected 3 arguments in if(cond, a, b)")); - } - let mut iter = args.into_iter(); - let cond = iter.next().unwrap(); - let if_b = iter.next().unwrap(); - let else_b = iter.next().unwrap(); - Ok(Node::Conditional { - condition: Box::new(cond), - if_block: Box::new(if_b), - else_block: Box::new(else_b), - }) - }); + let if_expr = just(Token::If).ignore_then(args.clone()).try_map(|args: Vec, span| { + let [condition, if_block, else_block] = <[Node; 3]>::try_from(args).map_err(|_| LabelError::::expected_found(["3 arguments in if(condition, a, b)"], None, span))?; + + Ok(Node::Conditional { + condition: Box::new(condition), + if_block: Box::new(if_block), + else_block: Box::new(else_block), + }) + }); let ident = select! {Token::Ident(s) => s}.labelled("ident"); - let call = ident.then(args).map(|(name, args): (&str, Vec)| Node::FnCall { name: name.to_string(), expr: args }); + // An ident followed by parenthesized args is a function call, otherwise a variable + let call_or_var = ident.then(args.or_not()).map(|(name, args): (&str, Option>)| match args { + Some(args) => Node::FnCall { name: name.to_string(), expr: args }, + None => Node::Var(name.to_string()), + }); let parens = expr.clone().delimited_by(just(Token::LParen), just(Token::RParen)); - let var = ident.map(|s| Node::Var(s.to_string())); - let atom = choice((constant, if_expr, call, parens, var)).labelled("atom").boxed(); + let atom = choice((constant, if_expr, call_or_var, parens)).labelled("atom"); let add_op = choice((just(Token::Plus).to(BinaryOp::Add), just(Token::Minus).to(BinaryOp::Sub))); let mul_op = choice((just(Token::Star).to(BinaryOp::Mul), just(Token::Slash).to(BinaryOp::Div), just(Token::Modulo).to(BinaryOp::Modulo))); @@ -94,57 +89,48 @@ where )); // Postfix factorial: expr! → UnaryOp::Fac - let postfix = atom - .clone() - .foldl(just(Token::Bang).repeated(), |expr, _| Node::UnaryOp { - op: UnaryOp::Fac, - expr: Box::new(expr), - }) - .boxed(); - - let pow = postfix.clone().foldl( - pow_op - .then(unary_op.clone().repeated().foldr(postfix, |op, expr| Node::UnaryOp { op, expr: Box::new(expr) }).boxed()) - .repeated(), - |lhs, (op, rhs)| Node::BinOp { - lhs: Box::new(lhs), - op, - rhs: Box::new(rhs), - }, - ); - - let unary = unary_op.repeated().foldr(pow, |op, expr| Node::UnaryOp { op, expr: Box::new(expr) }).boxed(); - - let product = unary - .clone() - .foldl(mul_op.then(unary).repeated(), |lhs, (op, rhs)| Node::BinOp { - lhs: Box::new(lhs), - op, - rhs: Box::new(rhs), + let postfix = atom.clone().foldl(just(Token::Bang).repeated(), |expr, _| Node::UnaryOp { + op: UnaryOp::Fac, + expr: Box::new(expr), + }); + + // Exponentiation is right-associative (`2^2^3` is `2^(2^3)`); the exponent may carry unary signs like `2^-3`. + let pow = recursive(|pow| { + let exponent = unary_op.clone().repeated().foldr(pow, |op, expr| Node::UnaryOp { op, expr: Box::new(expr) }); + postfix.clone().then(pow_op.ignore_then(exponent).or_not()).map(|(base, exponent)| match exponent { + Some(exponent) => Node::BinOp { + lhs: Box::new(base), + op: BinaryOp::Pow, + rhs: Box::new(exponent), + }, + None => base, }) - .boxed(); + }); - let add = product.clone().foldl(add_op.then(product).repeated(), |lhs, (op, rhs)| Node::BinOp { + let unary = unary_op.repeated().foldr(pow.clone(), |op, expr| Node::UnaryOp { op, expr: Box::new(expr) }); + + // Juxtaposed factors like `2pi` or `2sqrt(4)` multiply implicitly at the same precedence as `*` and `/`. + // The implicit right operand is a `pow`, not a full unary, so a following `-` stays a subtraction (`2 -3` means `2 - 3`). + let implicit_mul = pow.map(|rhs| (BinaryOp::Mul, rhs)); + let product = unary.clone().foldl(choice((mul_op.then(unary), implicit_mul)).repeated(), |lhs, (op, rhs)| Node::BinOp { lhs: Box::new(lhs), op, rhs: Box::new(rhs), }); - let cmp = add.clone().foldl(cmp_op.then(add).repeated(), |lhs: Node, (op, rhs)| Node::BinOp { + let add = product.clone().foldl(add_op.then(product).repeated(), |lhs, (op, rhs)| Node::BinOp { lhs: Box::new(lhs), op, rhs: Box::new(rhs), }); - // Chain comparisons like `a < b < c` by multiplying the boolean - // (1. / 0.) results, preserving the existing semantics. - let chained_cmp = cmp.clone().foldl(cmp.repeated(), |lhs, rhs| Node::BinOp { + let cmp = add.clone().foldl(cmp_op.then(add).repeated(), |lhs: Node, (op, rhs)| Node::BinOp { lhs: Box::new(lhs), - op: BinaryOp::Mul, + op, rhs: Box::new(rhs), }); - let and = chained_cmp.clone().foldl(and_op.then(chained_cmp).repeated(), |lhs, (op, rhs)| Node::BinOp { + let and = cmp.clone().foldl(and_op.then(cmp).repeated(), |lhs, (op, rhs)| Node::BinOp { lhs: Box::new(lhs), op, rhs: Box::new(rhs), @@ -161,6 +147,8 @@ where #[cfg(test)] mod tests { use super::*; + use crate::value::Complex; + macro_rules! test_parser { ($($name:ident: $input:expr_2021 => $expected:expr_2021),* $(,)?) => { $( diff --git a/libraries/math-parser/src/value.rs b/libraries/math-parser/src/value.rs index a30d578bcf..eb27944076 100644 --- a/libraries/math-parser/src/value.rs +++ b/libraries/math-parser/src/value.rs @@ -1,6 +1,4 @@ use crate::ast::{BinaryOp, UnaryOp}; -use num_complex::ComplexFloat; -use std::f64::consts::PI; pub type Complex = num_complex::Complex; @@ -56,15 +54,18 @@ impl Number { match (self, other) { (Number::Real(lhs), Number::Real(rhs)) => { let result = match op { + // NaN operands poison logic ops so invalid values aren't silently treated as booleans BinaryOp::And => { - let l = lhs != 0.; - let r = rhs != 0.; - if l && r { 1. } else { 0. } + if lhs.is_nan() || rhs.is_nan() { + return Some(Number::Real(f64::NAN)); + } + if lhs != 0. && rhs != 0. { 1. } else { 0. } } BinaryOp::Or => { - let l = lhs != 0.; - let r = rhs != 0.; - if l || r { 1. } else { 0. } + if lhs.is_nan() || rhs.is_nan() { + return Some(Number::Real(f64::NAN)); + } + if lhs != 0. || rhs != 0. { 1. } else { 0. } } BinaryOp::Add => lhs + rhs, BinaryOp::Sub => lhs - rhs, @@ -86,11 +87,17 @@ impl Number { (Number::Complex(lhs), Number::Complex(rhs)) => { let result = match op { BinaryOp::And => { + if lhs.re.is_nan() || lhs.im.is_nan() || rhs.re.is_nan() || rhs.im.is_nan() { + return Some(Number::Real(f64::NAN)); + } let l = lhs != Complex::new(0., 0.); let r = rhs != Complex::new(0., 0.); return Some(Number::Real(if l && r { 1. } else { 0. })); } BinaryOp::Or => { + if lhs.re.is_nan() || lhs.im.is_nan() || rhs.re.is_nan() || rhs.im.is_nan() { + return Some(Number::Real(f64::NAN)); + } let l = lhs != Complex::new(0., 0.); let r = rhs != Complex::new(0., 0.); return Some(Number::Real(if l || r { 1. } else { 0. })); @@ -165,7 +172,12 @@ impl Number { if truncated < 0. || (real - truncated).abs() > f64::EPSILON { return Number::Real(f64::NAN); } + + // 171! already overflows f64, so skip the loop for anything larger let n = truncated as u64; + if n > 170 { + return Number::Real(f64::INFINITY); + } let mut acc = 1_f64; for k in 1..=n { acc *= k as f64; @@ -173,8 +185,10 @@ impl Number { Number::Real(acc) } UnaryOp::Not => { - let is_zero = real == 0.; - Number::Real(if is_zero { 1. } else { 0. }) + if real.is_nan() { + return Number::Real(f64::NAN); + } + Number::Real(if real == 0. { 1. } else { 0. }) } }, @@ -183,8 +197,10 @@ impl Number { UnaryOp::Sqrt => Number::Complex(complex.sqrt()), UnaryOp::Fac => Number::Complex(Complex::new(f64::NAN, f64::NAN)), UnaryOp::Not => { - let is_zero = complex == Complex::new(0., 0.); - Number::Real(if is_zero { 1. } else { 0. }) + if complex.re.is_nan() || complex.im.is_nan() { + return Number::Real(f64::NAN); + } + Number::Real(if complex == Complex::new(0., 0.) { 1. } else { 0. }) } }, } From f5f67f0e08bfbbf6d062a7d7e5c35e27011e7e26 Mon Sep 17 00:00:00 2001 From: Keavon Chambers Date: Sun, 26 Jul 2026 15:30:11 -0700 Subject: [PATCH 2/2] Fix review findings: whitespace-juxtaposed numbers, mixed real/complex logic, correctly rounded literals, unified NaN truthiness, and gcd/lcm range checks --- libraries/math-parser/src/constants.rs | 51 +++++++------- libraries/math-parser/src/executer.rs | 56 ++++++++++----- libraries/math-parser/src/lexer.rs | 74 +++++++++++-------- libraries/math-parser/src/lib.rs | 32 ++++++++- libraries/math-parser/src/parser.rs | 6 +- libraries/math-parser/src/value.rs | 98 +++++++++++--------------- 6 files changed, 179 insertions(+), 138 deletions(-) diff --git a/libraries/math-parser/src/constants.rs b/libraries/math-parser/src/constants.rs index 6598baae98..16577b8bc4 100644 --- a/libraries/math-parser/src/constants.rs +++ b/libraries/math-parser/src/constants.rs @@ -4,6 +4,25 @@ use std::f64::consts::{LN_2, PI}; pub type BuiltinFunction = fn(&[Value]) -> Option; +/// Truncates both operands to nonnegative integers for `gcd`/`lcm`, or `None` when either is non-finite or beyond f64's exactly-representable integer range. +fn integer_operands(a: f64, b: f64) -> Option<(u64, u64)> { + // The largest magnitude below which every integer is exactly representable in f64 + const EXACT_INTEGER_LIMIT: f64 = (1_u64 << f64::MANTISSA_DIGITS) as f64; + + let (a, b) = (a.trunc(), b.trunc()); + if !a.is_finite() || !b.is_finite() || a.abs() > EXACT_INTEGER_LIMIT || b.abs() > EXACT_INTEGER_LIMIT { + return None; + } + Some(((a as i64).unsigned_abs(), (b as i64).unsigned_abs())) +} + +fn euclidean_gcd(mut x: u64, mut y: u64) -> u64 { + while y != 0 { + (x, y) = (y, x % y); + } + x +} + /// Looks up a built-in math function by name, returning a plain function pointer so dispatch avoids hashing and dynamic allocation. pub fn builtin_function(name: &str) -> Option { Some(match name { @@ -332,43 +351,23 @@ pub fn builtin_function(name: &str) -> Option { "gcd" => |values| match values { [Value::Number(Number::Real(a)), Value::Number(Number::Real(b))] => { - if !a.is_finite() || !b.is_finite() { - return Some(Value::Number(Number::Real(f64::NAN))); - } - let mut x = (a.trunc() as i64).unsigned_abs(); - let mut y = (b.trunc() as i64).unsigned_abs(); - while y != 0 { - let r = x % y; - x = y; - y = r; - } - Some(Value::Number(Number::Real(x as f64))) + let gcd = integer_operands(*a, *b).map_or(f64::NAN, |(x, y)| euclidean_gcd(x, y) as f64); + Some(Value::Number(Number::Real(gcd))) } _ => None, }, "lcm" => |values| match values { [Value::Number(Number::Real(a)), Value::Number(Number::Real(b))] => { - if !a.is_finite() || !b.is_finite() { + let Some((x, y)) = integer_operands(*a, *b) else { return Some(Value::Number(Number::Real(f64::NAN))); - } - let x = (a.trunc() as i64).unsigned_abs(); - let y = (b.trunc() as i64).unsigned_abs(); + }; if x == 0 || y == 0 { return Some(Value::Number(Number::Real(0.))); } - // gcd - let mut gx = x; - let mut gy = y; - while gy != 0 { - let r = gx % gy; - gx = gy; - gy = r; - } - - // Multiply in f64 so huge inputs can't overflow the integer range - let lcm = (x / gx) as f64 * y as f64; + // Multiply in f64 so huge results can't overflow the integer range + let lcm = (x / euclidean_gcd(x, y)) as f64 * y as f64; Some(Value::Number(Number::Real(lcm))) } _ => None, diff --git a/libraries/math-parser/src/executer.rs b/libraries/math-parser/src/executer.rs index 10ccb9b469..9dcd6f7639 100644 --- a/libraries/math-parser/src/executer.rs +++ b/libraries/math-parser/src/executer.rs @@ -1,8 +1,7 @@ -use crate::ast::{Literal, Node}; +use crate::ast::{BinaryOp, Literal, Node}; use crate::constants::builtin_function; use crate::context::{EvalContext, FunctionProvider, ValueProvider}; use crate::value::{Number, Value}; -use num_complex::Complex; use thiserror::Error; #[derive(Debug, Error)] @@ -53,26 +52,19 @@ impl Node { function(values).ok_or(EvalError::TypeError) } else if let Some(val) = context.run_function(name, values) { Ok(val) + } else if let Some(Value::Number(value)) = context.get_value(name) + && let [Value::Number(argument)] = values + { + // A known value applied to one argument is implicit multiplication, so `x(2)` matches `2(3)` and `i(16)` + Ok(Value::Number(value.binary_op(BinaryOp::Mul, *argument).ok_or(EvalError::OperatorTypeError)?)) } else { - context.get_value(name).ok_or_else(|| EvalError::MissingFunction(name.to_string())) + Err(EvalError::MissingFunction(name.to_string())) } } Node::Conditional { condition, if_block, else_block } => { // A NaN condition yields NaN rather than arbitrarily picking a branch - let condition = match condition.eval(context)? { - Value::Number(Number::Real(number)) => { - if number.is_nan() { - return Ok(Value::from_f64(f64::NAN)); - } - number != 0. - } - Value::Number(Number::Complex(number)) => { - if number.re.is_nan() || number.im.is_nan() { - return Ok(Value::from_f64(f64::NAN)); - } - number != Complex::ZERO - } - }; + let Value::Number(number) = condition.eval(context)?; + let Some(condition) = number.as_bool() else { return Ok(Value::from_f64(f64::NAN)) }; if condition { if_block.eval(context) } else { else_block.eval(context) } } @@ -83,9 +75,37 @@ impl Node { #[cfg(test)] mod tests { use crate::ast::{BinaryOp, Literal, Node, UnaryOp}; - use crate::context::EvalContext; + use crate::context::{EvalContext, NothingMap, ValueProvider}; use crate::value::Value; + struct SingleValue(f64); + + impl ValueProvider for SingleValue { + fn get_value(&self, name: &str) -> Option { + (name == "x").then(|| Value::from_f64(self.0)) + } + } + + #[test] + fn known_value_with_one_argument_multiplies() { + // `x(2)` juxtaposes like `2(3)` and `i(16)` instead of silently discarding the argument + let call = Node::FnCall { + name: "x".to_string(), + expr: vec![Node::Lit(Literal::Float(2.))], + }; + let result = call.eval(&EvalContext::new(SingleValue(5.), NothingMap)).unwrap(); + assert_eq!(result, Value::from_f64(10.)); + } + + #[test] + fn known_value_with_multiple_arguments_is_an_error() { + let call = Node::FnCall { + name: "x".to_string(), + expr: vec![Node::Lit(Literal::Float(1.)), Node::Lit(Literal::Float(2.))], + }; + assert!(call.eval(&EvalContext::new(SingleValue(5.), NothingMap)).is_err()); + } + macro_rules! eval_tests { ($($name:ident: $expected:expr_2021 => $expr:expr_2021),* $(,)?) => { $( diff --git a/libraries/math-parser/src/lexer.rs b/libraries/math-parser/src/lexer.rs index 752040b6b3..07ed912c78 100644 --- a/libraries/math-parser/src/lexer.rs +++ b/libraries/math-parser/src/lexer.rs @@ -162,60 +162,64 @@ impl<'a> Lexer<'a> { &self.input[start..self.pos] } - // Digit runs accumulate in f64 so arbitrarily long literals approximate instead of overflowing an integer type - fn lex_uint(&mut self) -> Option<(f64, usize)> { - let mut v = 0_f64; + fn consume_digits(&mut self) -> (usize, f64) { + let mut value = 0_f64; let mut digits = 0; while let Some(d) = self.peek().and_then(|c| c.to_digit(10)) { - v = v * 10. + d as f64; + value = value * 10. + d as f64; digits += 1; self.bump(); } - (digits > 0).then_some((v, digits)) + (digits, value) + } + + // A numeric literal cannot follow another operand across whitespace (`10 000`, `sqrt(4).5`), only constants/calls/parens may juxtapose + fn juxtaposes_with_preceding_operand(&self, literal_start: usize) -> bool { + let mut preceding = self.input[..literal_start].trim_end(); + + // A `!` run is postfix factorial only when an operand precedes it, otherwise it's a prefix logical not + while let Some(rest) = preceding.strip_suffix('!') { + preceding = rest.trim_end(); + } + + preceding.chars().next_back().is_some_and(|c| c.is_alphanumeric() || c == '.' || c == ')' || c == '∞') } fn lex_number(&mut self) -> Option { let start_pos = self.pos; - let (int_val, int_digits) = self.lex_uint().unwrap_or((0., 0)); + let (int_digits, int_value) = self.consume_digits(); let mut got_digit = int_digits > 0; - let mut num = int_val; + let mut plain_integer = true; if self.peek() == Some('.') { self.bump(); - if let Some((frac_val, frac_digits)) = self.lex_uint() { - num += frac_val / 10f64.powi(frac_digits as i32); - got_digit = true; - } + plain_integer = false; + got_digit |= self.consume_digits().0 > 0; } - if matches!(self.peek(), Some('e' | 'E')) { + if got_digit && matches!(self.peek(), Some('e' | 'E')) { self.bump(); - let sign = match self.peek() { - Some('+') => { - self.bump(); - 1 - } - Some('-') => { - self.bump(); - -1 - } - _ => 1, - }; - if let Some((exp_val, _)) = self.lex_uint() { - num *= 10f64.powi(sign * exp_val as i32); - } else { + plain_integer = false; + if matches!(self.peek(), Some('+' | '-')) { + self.bump(); + } + if self.consume_digits().0 == 0 { self.pos = start_pos; return None; } } - // A numeric literal cannot be glued directly to another by a stray decimal point or digit (e.g. `1..5`, `1.5.5`), so reject rather than letting it parse as implicit multiplication. - if got_digit && self.peek().is_some_and(|c| c == '.' || c.is_ascii_digit()) { + // A numeric literal cannot be glued directly to another by a stray decimal point or digit (e.g. `1..5`, `1.5.5`), so reject rather than letting it parse as implicit multiplication + if !got_digit || self.peek().is_some_and(|c| c == '.' || c.is_ascii_digit()) || self.juxtaposes_with_preceding_operand(start_pos) { self.pos = start_pos; return None; } - got_digit.then_some(num) + // Accumulation is exact up to 15 digits; longer or fractional literals get std's correctly-rounded parsing + if plain_integer && int_digits <= 15 { + return Some(int_value); + } + self.input[start_pos..self.pos].parse::().ok() } fn skip_ws(&mut self) { @@ -298,10 +302,18 @@ impl<'a> Lexer<'a> { self.pos = start; match self.lex_number() { Some(number) => Float(number), - // `lex_number` resets `pos` on failure, so advance past one character to guarantee forward progress. + // Consume the whole malformed numeric run so the error span covers it and lexing makes forward progress None => { self.pos = start; - self.bump(); + let mut prev = '\0'; + while let Some(c) = self.peek() { + let part_of_number = c.is_ascii_digit() || c == '.' || c == 'e' || c == 'E' || ((c == '+' || c == '-') && matches!(prev, 'e' | 'E')); + if !part_of_number { + break; + } + prev = c; + self.bump(); + } Error } } diff --git a/libraries/math-parser/src/lib.rs b/libraries/math-parser/src/lib.rs index 854677d683..e45c6734da 100644 --- a/libraries/math-parser/src/lib.rs +++ b/libraries/math-parser/src/lib.rs @@ -26,7 +26,7 @@ mod tests { #[test] fn malformed_juxtaposed_numbers_fail_to_parse() { - // Two numbers cannot be glued together by a stray decimal point (they must not parse as implicit multiplication). + // Two numbers cannot be glued together by a stray decimal point (they must not parse as implicit multiplication) for input in ["1..5", "1.5.5", "1..", ".5.5"] { assert!(evaluate(input).is_err(), "expected `{input}` to be a parse error"); } @@ -34,12 +34,27 @@ mod tests { #[test] fn unrecognized_characters_fail_to_parse() { - // Unrecognized trailing input must be rejected rather than silently dropped after a valid prefix. + // Unrecognized trailing input must be rejected rather than silently dropped after a valid prefix for input in ["2@", "5#", "2 $ 3", "sqrt(4)@", "5 & 3", "5 | 3", "2 = 3"] { assert!(evaluate(input).is_err(), "expected `{input}` to be a parse error"); } } + #[test] + fn juxtaposed_numbers_fail_to_parse() { + // Adjacent number literals like digit-grouped `10 000` must not silently multiply + for input in ["2 3", "10 000", "1 .5", "sqrt(4).5", "2 3 + 1"] { + assert!(evaluate(input).is_err(), "expected `{input}` to be a parse error"); + } + } + + #[test] + fn extremely_long_fraction_parses() { + let input = format!("0.{}", "1".repeat(320)); + let value = evaluate(&input).unwrap().unwrap(); + assert_eq!(value.as_real(), Some(1. / 9.)); + } + fn run_end_to_end_test(input: &str, expected_value: Value) { let expr = match ast::Node::try_parse_from_str(input) { Ok(expr) => expr, @@ -315,5 +330,18 @@ mod tests { nan_and: "sqrt(-1) && 1" => f64::NAN, nan_or: "sqrt(-1) || 1" => f64::NAN, nan_not: "!sqrt(-1)" => f64::NAN, + + // Logic and equality span real and complex operands + mixed_equality: "1 == i" => 0., + complex_equality: "i == i" => 1., + mixed_and: "1 && i" => 1., + mixed_nan_and: "sqrt(-1) && i" => f64::NAN, + + // Correctly rounded literals via std parsing + seventeen_digit_literal: "999999999999999999" => 1e18, + long_fraction_literal: "0.1111111111111111111111111111111111111111" => 1. / 9., + + // Integer functions reject inputs beyond f64's exact integer range + gcd_beyond_exact_integers: "gcd(10000000000000000000, 2)" => f64::NAN, } } diff --git a/libraries/math-parser/src/parser.rs b/libraries/math-parser/src/parser.rs index f37de39730..0fc64bd886 100644 --- a/libraries/math-parser/src/parser.rs +++ b/libraries/math-parser/src/parser.rs @@ -94,7 +94,7 @@ where expr: Box::new(expr), }); - // Exponentiation is right-associative (`2^2^3` is `2^(2^3)`); the exponent may carry unary signs like `2^-3`. + // Exponentiation is right-associative (`2^2^3` is `2^(2^3)`) and the exponent may carry unary signs like `2^-3` let pow = recursive(|pow| { let exponent = unary_op.clone().repeated().foldr(pow, |op, expr| Node::UnaryOp { op, expr: Box::new(expr) }); postfix.clone().then(pow_op.ignore_then(exponent).or_not()).map(|(base, exponent)| match exponent { @@ -107,10 +107,10 @@ where }) }); - let unary = unary_op.repeated().foldr(pow.clone(), |op, expr| Node::UnaryOp { op, expr: Box::new(expr) }); + let unary = unary_op.clone().repeated().foldr(pow.clone(), |op, expr| Node::UnaryOp { op, expr: Box::new(expr) }); // Juxtaposed factors like `2pi` or `2sqrt(4)` multiply implicitly at the same precedence as `*` and `/`. - // The implicit right operand is a `pow`, not a full unary, so a following `-` stays a subtraction (`2 -3` means `2 - 3`). + // The implicit operand is a `pow`, not a full unary, so `2 -3` stays a subtraction; the lexer rejects a bare number as the right operand (`10 000` is not `10*000`). let implicit_mul = pow.map(|rhs| (BinaryOp::Mul, rhs)); let product = unary.clone().foldl(choice((mul_op.then(unary), implicit_mul)).repeated(), |lhs, (op, rhs)| Node::BinOp { lhs: Box::new(lhs), diff --git a/libraries/math-parser/src/value.rs b/libraries/math-parser/src/value.rs index eb27944076..39f6750801 100644 --- a/libraries/math-parser/src/value.rs +++ b/libraries/math-parser/src/value.rs @@ -50,23 +50,38 @@ impl std::fmt::Display for Number { } impl Number { + /// The value's truthiness for conditions and logic operators, or `None` for NaN values, which poison the result rather than acting as a boolean. + pub fn as_bool(self) -> Option { + match self { + Number::Real(real) => (!real.is_nan()).then_some(real != 0.), + Number::Complex(complex) => (!complex.re.is_nan() && !complex.im.is_nan()).then_some(complex != Complex::ZERO), + } + } + pub fn binary_op(self, op: BinaryOp, other: Number) -> Option { + // Logic and equality work uniformly across real and complex operands + match op { + BinaryOp::And | BinaryOp::Or => { + let (Some(lhs), Some(rhs)) = (self.as_bool(), other.as_bool()) else { + return Some(Number::Real(f64::NAN)); + }; + let result = if matches!(op, BinaryOp::And) { lhs && rhs } else { lhs || rhs }; + return Some(Number::Real(result as u8 as f64)); + } + BinaryOp::Eq | BinaryOp::Neq => { + let equal = match (self, other) { + (Number::Real(lhs), Number::Real(rhs)) => lhs == rhs, + (Number::Complex(lhs), Number::Complex(rhs)) => lhs == rhs, + (Number::Real(real), Number::Complex(complex)) | (Number::Complex(complex), Number::Real(real)) => complex == Complex::new(real, 0.), + }; + return Some(Number::Real((equal != matches!(op, BinaryOp::Neq)) as u8 as f64)); + } + _ => {} + } + match (self, other) { (Number::Real(lhs), Number::Real(rhs)) => { let result = match op { - // NaN operands poison logic ops so invalid values aren't silently treated as booleans - BinaryOp::And => { - if lhs.is_nan() || rhs.is_nan() { - return Some(Number::Real(f64::NAN)); - } - if lhs != 0. && rhs != 0. { 1. } else { 0. } - } - BinaryOp::Or => { - if lhs.is_nan() || rhs.is_nan() { - return Some(Number::Real(f64::NAN)); - } - if lhs != 0. || rhs != 0. { 1. } else { 0. } - } BinaryOp::Add => lhs + rhs, BinaryOp::Sub => lhs - rhs, BinaryOp::Mul => lhs * rhs, @@ -77,8 +92,7 @@ impl Number { BinaryOp::Lt => (lhs < rhs) as u8 as f64, BinaryOp::Geq => (lhs >= rhs) as u8 as f64, BinaryOp::Gt => (lhs > rhs) as u8 as f64, - BinaryOp::Neq => (lhs != rhs) as u8 as f64, - BinaryOp::Eq => (lhs == rhs) as u8 as f64, + BinaryOp::And | BinaryOp::Or | BinaryOp::Eq | BinaryOp::Neq => unreachable!("handled above"), }; Some(Number::Real(result)) @@ -86,22 +100,6 @@ impl Number { (Number::Complex(lhs), Number::Complex(rhs)) => { let result = match op { - BinaryOp::And => { - if lhs.re.is_nan() || lhs.im.is_nan() || rhs.re.is_nan() || rhs.im.is_nan() { - return Some(Number::Real(f64::NAN)); - } - let l = lhs != Complex::new(0., 0.); - let r = rhs != Complex::new(0., 0.); - return Some(Number::Real(if l && r { 1. } else { 0. })); - } - BinaryOp::Or => { - if lhs.re.is_nan() || lhs.im.is_nan() || rhs.re.is_nan() || rhs.im.is_nan() { - return Some(Number::Real(f64::NAN)); - } - let l = lhs != Complex::new(0., 0.); - let r = rhs != Complex::new(0., 0.); - return Some(Number::Real(if l || r { 1. } else { 0. })); - } BinaryOp::Add => lhs + rhs, BinaryOp::Sub => lhs - rhs, BinaryOp::Mul => lhs * rhs, @@ -111,20 +109,7 @@ impl Number { BinaryOp::Leq | BinaryOp::Lt | BinaryOp::Geq | BinaryOp::Gt => { return None; } - BinaryOp::Neq => { - if lhs != rhs { - return Some(Number::Real(1.)); - } else { - return Some(Number::Real(0.)); - } - } - BinaryOp::Eq => { - if lhs == rhs { - return Some(Number::Real(1.)); - } else { - return Some(Number::Real(0.)); - } - } + BinaryOp::And | BinaryOp::Or | BinaryOp::Eq | BinaryOp::Neq => unreachable!("handled above"), }; Some(Number::Complex(result)) } @@ -158,6 +143,13 @@ impl Number { } pub fn unary_op(self, op: UnaryOp) -> Number { + if matches!(op, UnaryOp::Not) { + return match self.as_bool() { + Some(boolean) => Number::Real(!boolean as u8 as f64), + None => Number::Real(f64::NAN), + }; + } + match self { Number::Real(real) => match op { UnaryOp::Neg => Number::Real(-real), @@ -173,7 +165,7 @@ impl Number { return Number::Real(f64::NAN); } - // 171! already overflows f64, so skip the loop for anything larger + // Return infinity above 170! since that overflows f64, which also keeps huge inputs from spinning the loop let n = truncated as u64; if n > 170 { return Number::Real(f64::INFINITY); @@ -184,24 +176,14 @@ impl Number { } Number::Real(acc) } - UnaryOp::Not => { - if real.is_nan() { - return Number::Real(f64::NAN); - } - Number::Real(if real == 0. { 1. } else { 0. }) - } + UnaryOp::Not => unreachable!("handled above"), }, Number::Complex(complex) => match op { UnaryOp::Neg => Number::Complex(-complex), UnaryOp::Sqrt => Number::Complex(complex.sqrt()), UnaryOp::Fac => Number::Complex(Complex::new(f64::NAN, f64::NAN)), - UnaryOp::Not => { - if complex.re.is_nan() || complex.im.is_nan() { - return Number::Real(f64::NAN); - } - Number::Real(if complex == Complex::new(0., 0.) { 1. } else { 0. }) - } + UnaryOp::Not => unreachable!("handled above"), }, } }