Resolve strict Clippy findings across all targets, preserve public API compatibility with scoped lint exceptions, and fix sourced messages retaining media references. Replace meval and its future-incompatible nom dependency with a bounded internal expression parser and regression tests.
278 lines
8.8 KiB
Rust
278 lines
8.8 KiB
Rust
/// Evaluate a self-contained mathematical expression without executing code or
|
|
/// resolving external variables.
|
|
pub(super) fn evaluate(input: &str) -> Result<f64, String> {
|
|
const MAX_EXPRESSION_BYTES: usize = 4096;
|
|
if input.len() > MAX_EXPRESSION_BYTES {
|
|
return Err(format!(
|
|
"expression exceeds the {MAX_EXPRESSION_BYTES}-byte limit"
|
|
));
|
|
}
|
|
let mut parser = Parser {
|
|
input,
|
|
position: 0,
|
|
depth: 0,
|
|
};
|
|
let value = parser.parse_expression()?;
|
|
parser.skip_whitespace();
|
|
if parser.position != input.len() {
|
|
return Err(parser.error("unexpected trailing input"));
|
|
}
|
|
Ok(value)
|
|
}
|
|
|
|
struct Parser<'a> {
|
|
input: &'a str,
|
|
position: usize,
|
|
depth: usize,
|
|
}
|
|
|
|
impl Parser<'_> {
|
|
fn parse_expression(&mut self) -> Result<f64, String> {
|
|
let mut value = self.parse_term()?;
|
|
loop {
|
|
if self.consume(b'+') {
|
|
value += self.parse_term()?;
|
|
} else if self.consume(b'-') {
|
|
value -= self.parse_term()?;
|
|
} else {
|
|
return Ok(value);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn parse_term(&mut self) -> Result<f64, String> {
|
|
let mut value = self.parse_unary()?;
|
|
loop {
|
|
if self.consume(b'*') {
|
|
value *= self.parse_unary()?;
|
|
} else if self.consume(b'/') {
|
|
value /= self.parse_unary()?;
|
|
} else if self.consume(b'%') {
|
|
value %= self.parse_unary()?;
|
|
} else {
|
|
return Ok(value);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn parse_unary(&mut self) -> Result<f64, String> {
|
|
if self.consume(b'+') {
|
|
self.nested(Self::parse_unary)
|
|
} else if self.consume(b'-') {
|
|
Ok(-self.nested(Self::parse_unary)?)
|
|
} else {
|
|
self.parse_power()
|
|
}
|
|
}
|
|
|
|
fn parse_power(&mut self) -> Result<f64, String> {
|
|
let base = self.parse_primary()?;
|
|
if self.consume(b'^') {
|
|
Ok(base.powf(self.nested(Self::parse_unary)?))
|
|
} else {
|
|
Ok(base)
|
|
}
|
|
}
|
|
|
|
fn parse_primary(&mut self) -> Result<f64, String> {
|
|
self.skip_whitespace();
|
|
match self.peek() {
|
|
Some(b'(') => {
|
|
self.position += 1;
|
|
let value = self.nested(Self::parse_expression)?;
|
|
if !self.consume(b')') {
|
|
return Err(self.error("expected ')'"));
|
|
}
|
|
Ok(value)
|
|
}
|
|
Some(byte) if byte.is_ascii_digit() || byte == b'.' => self.parse_number(),
|
|
Some(byte) if byte.is_ascii_alphabetic() || byte == b'_' => self.parse_identifier(),
|
|
Some(_) => Err(self.error("expected a number, constant, function, or '('")),
|
|
None => Err(self.error("unexpected end of expression")),
|
|
}
|
|
}
|
|
|
|
fn parse_number(&mut self) -> Result<f64, String> {
|
|
self.skip_whitespace();
|
|
let start = self.position;
|
|
let mut digits = 0;
|
|
while self.peek().is_some_and(|byte| byte.is_ascii_digit()) {
|
|
self.position += 1;
|
|
digits += 1;
|
|
}
|
|
if self.peek() == Some(b'.') {
|
|
self.position += 1;
|
|
while self.peek().is_some_and(|byte| byte.is_ascii_digit()) {
|
|
self.position += 1;
|
|
digits += 1;
|
|
}
|
|
}
|
|
if digits == 0 {
|
|
return Err(self.error("invalid number"));
|
|
}
|
|
if matches!(self.peek(), Some(b'e' | b'E')) {
|
|
self.position += 1;
|
|
if matches!(self.peek(), Some(b'+' | b'-')) {
|
|
self.position += 1;
|
|
}
|
|
let exponent_start = self.position;
|
|
while self.peek().is_some_and(|byte| byte.is_ascii_digit()) {
|
|
self.position += 1;
|
|
}
|
|
if self.position == exponent_start {
|
|
return Err(self.error("invalid numeric exponent"));
|
|
}
|
|
}
|
|
|
|
self.input[start..self.position]
|
|
.parse::<f64>()
|
|
.map_err(|_| self.error("invalid number"))
|
|
}
|
|
|
|
fn parse_identifier(&mut self) -> Result<f64, String> {
|
|
self.skip_whitespace();
|
|
let start = self.position;
|
|
while self
|
|
.peek()
|
|
.is_some_and(|byte| byte.is_ascii_alphanumeric() || byte == b'_')
|
|
{
|
|
self.position += 1;
|
|
}
|
|
let name = self.input[start..self.position].to_ascii_lowercase();
|
|
self.skip_whitespace();
|
|
if self.peek() != Some(b'(') {
|
|
return match name.as_str() {
|
|
"pi" => Ok(std::f64::consts::PI),
|
|
"e" => Ok(std::f64::consts::E),
|
|
_ => Err(self.error(&format!("unknown constant or variable '{name}'"))),
|
|
};
|
|
}
|
|
|
|
self.position += 1;
|
|
let mut arguments = Vec::new();
|
|
self.skip_whitespace();
|
|
if self.peek() != Some(b')') {
|
|
loop {
|
|
arguments.push(self.nested(Self::parse_expression)?);
|
|
if self.consume(b',') {
|
|
continue;
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
if !self.consume(b')') {
|
|
return Err(self.error("expected ')' after function arguments"));
|
|
}
|
|
apply_function(&name, &arguments).map_err(|message| self.error(&message))
|
|
}
|
|
|
|
fn consume(&mut self, expected: u8) -> bool {
|
|
self.skip_whitespace();
|
|
if self.peek() == Some(expected) {
|
|
self.position += 1;
|
|
true
|
|
} else {
|
|
false
|
|
}
|
|
}
|
|
|
|
fn skip_whitespace(&mut self) {
|
|
while self.peek().is_some_and(|byte| byte.is_ascii_whitespace()) {
|
|
self.position += 1;
|
|
}
|
|
}
|
|
|
|
fn peek(&self) -> Option<u8> {
|
|
self.input.as_bytes().get(self.position).copied()
|
|
}
|
|
|
|
fn nested<T>(&mut self, parse: fn(&mut Self) -> Result<T, String>) -> Result<T, String> {
|
|
const MAX_PARSE_DEPTH: usize = 128;
|
|
if self.depth >= MAX_PARSE_DEPTH {
|
|
return Err(self.error("expression nesting limit exceeded"));
|
|
}
|
|
self.depth += 1;
|
|
let result = parse(self);
|
|
self.depth -= 1;
|
|
result
|
|
}
|
|
|
|
fn error(&self, message: &str) -> String {
|
|
format!("{message} at byte {}", self.position)
|
|
}
|
|
}
|
|
|
|
fn apply_function(name: &str, arguments: &[f64]) -> Result<f64, String> {
|
|
let unary = |function: fn(f64) -> f64| match arguments {
|
|
[value] => Ok(function(*value)),
|
|
_ => Err(format!("function '{name}' expects one argument")),
|
|
};
|
|
match name {
|
|
"sqrt" => unary(f64::sqrt),
|
|
"abs" => unary(f64::abs),
|
|
"exp" => unary(f64::exp),
|
|
"ln" => unary(f64::ln),
|
|
"log2" => unary(f64::log2),
|
|
"log10" => unary(f64::log10),
|
|
"sin" => unary(f64::sin),
|
|
"cos" => unary(f64::cos),
|
|
"tan" => unary(f64::tan),
|
|
"asin" => unary(f64::asin),
|
|
"acos" => unary(f64::acos),
|
|
"atan" => unary(f64::atan),
|
|
"sinh" => unary(f64::sinh),
|
|
"cosh" => unary(f64::cosh),
|
|
"tanh" => unary(f64::tanh),
|
|
"asinh" => unary(f64::asinh),
|
|
"acosh" => unary(f64::acosh),
|
|
"atanh" => unary(f64::atanh),
|
|
"floor" => unary(f64::floor),
|
|
"ceil" => unary(f64::ceil),
|
|
"round" => unary(f64::round),
|
|
"signum" => unary(f64::signum),
|
|
"atan2" => match arguments {
|
|
[y, x] => Ok(y.atan2(*x)),
|
|
_ => Err("function 'atan2' expects two arguments".to_string()),
|
|
},
|
|
"min" => arguments
|
|
.iter()
|
|
.copied()
|
|
.reduce(f64::min)
|
|
.ok_or_else(|| "function 'min' expects at least one argument".to_string()),
|
|
"max" => arguments
|
|
.iter()
|
|
.copied()
|
|
.reduce(f64::max)
|
|
.ok_or_else(|| "function 'max' expects at least one argument".to_string()),
|
|
_ => Err(format!("unknown function '{name}'")),
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn respects_precedence_and_right_associative_power() {
|
|
assert_eq!(evaluate("15*3+5^(2+1)").unwrap(), 170.0);
|
|
assert_eq!(evaluate("2^3^2").unwrap(), 512.0);
|
|
assert_eq!(evaluate("-2^2").unwrap(), -4.0);
|
|
}
|
|
|
|
#[test]
|
|
fn supports_constants_functions_and_scientific_notation() {
|
|
assert_eq!(evaluate("sqrt(1.44e2)").unwrap(), 12.0);
|
|
assert_eq!(evaluate("max(1, 2, 3) + min(4, 5)").unwrap(), 7.0);
|
|
assert!((evaluate("sin(pi / 2)").unwrap() - 1.0).abs() < f64::EPSILON);
|
|
}
|
|
|
|
#[test]
|
|
fn rejects_unknown_names_and_trailing_input() {
|
|
assert!(evaluate("unknown").is_err());
|
|
assert!(evaluate("1 + 2 garbage").is_err());
|
|
assert!(evaluate("sqrt() ").is_err());
|
|
assert!(evaluate(&"(".repeat(129)).is_err());
|
|
assert!(evaluate(&"1+".repeat(3000)).is_err());
|
|
}
|
|
}
|