Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 29 additions & 6 deletions libraries/math-parser/src/executer.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
use crate::ast::{BinaryOp, Literal, Node};
use crate::constants::{builtin_function, suffixed_function};
use crate::context::{EvalContext, FunctionProvider, ValueProvider};
use crate::lexer::Constant;
use crate::value::{Number, Value};
use thiserror::Error;

Expand All @@ -19,6 +20,22 @@ pub enum EvalError {
OperatorTypeError,
}

/// Resolves a name against the environment before the builtin constants, so a binding of exactly that spelling shadows the builtin.
/// The `\` prefix skips the environment.
fn resolve_value<V: ValueProvider, F: FunctionProvider>(context: &EvalContext<V, F>, name: &str) -> Option<Value> {
let constant = |name: &str| {
Constant::from_name(name).map(|constant| match constant.value() {
Literal::Float(real) => Value::from_f64(real),
Literal::Complex(complex) => Value::Number(Number::Complex(complex)),
})
};

match name.strip_prefix('\\') {
Some(builtin_name) => constant(builtin_name),
None => context.get_value(name).or_else(|| constant(name)),
}
}

impl Node {
pub fn eval<V: ValueProvider, F: FunctionProvider>(&self, context: &EvalContext<V, F>) -> Result<Value, EvalError> {
match self {
Expand All @@ -33,7 +50,7 @@ impl Node {
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::Var(name) => resolve_value(context, name).ok_or_else(|| EvalError::MissingValue(name.clone())),
Node::FnCall { name, expr } => {
// 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];
Expand All @@ -48,15 +65,21 @@ impl Node {
&heap_values
};

if let Some(function) = builtin_function(name) {
// A host-supplied function shadows the builtin of the same name, unless the `\` prefix asks for the language's own
let (prefixed, bare_name) = match name.strip_prefix('\\') {
Some(bare_name) => (true, bare_name),
None => (false, name.as_str()),
};

if !prefixed && let Some(value) = context.run_function(bare_name, values) {
Ok(value)
} else if let Some(function) = builtin_function(bare_name) {
function(values).ok_or(EvalError::TypeError)
} else if let Some((function, base)) = suffixed_function(name) {
} else if let Some((function, base)) = suffixed_function(bare_name) {
// A base-suffixed call like `log10(x)` runs the two-argument form with the suffix baked in as its second argument
let [value] = values else { return Err(EvalError::TypeError) };
function(&[*value, Value::from_f64(base)]).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)
} else if let Some(Value::Number(value)) = resolve_value(context, 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)`
Expand Down
13 changes: 7 additions & 6 deletions libraries/math-parser/src/lexer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ pub type Span = SimpleSpan;
#[derive(Clone, Debug, PartialEq)]
pub enum Token<'src> {
Float(f64),
Const(Constant),
Ident(&'src str),

AndAnd,
Expand Down Expand Up @@ -45,7 +44,6 @@ impl<'src> fmt::Display for Token<'src> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Token::Float(x) => write!(f, "{x}"),
Token::Const(c) => write!(f, "{c}"),
Token::Ident(name) => write!(f, "{name}"),

Token::AndAnd => f.write_str("&&"),
Expand Down Expand Up @@ -120,7 +118,6 @@ impl Constant {
("φ", Phi),
("inf", Inf),
("infinity", Inf),
("∞", Inf),
("true", True),
("false", False),
];
Expand Down Expand Up @@ -311,6 +308,9 @@ impl<'a> Lexer<'a> {
'^' => Caret,
'≠' => Neq,

// A symbol can't be a name, so unlike `inf`, no binding can shadow `∞`
'∞' => Float(f64::INFINITY),

// Typeset math symbol aliases
'−' => Minus,
'×' | '⋅' => Star,
Expand Down Expand Up @@ -379,16 +379,17 @@ impl<'a> Lexer<'a> {
}

_ => {
self.consume_identifier_body(ch);
let body = self.consume_identifier_body(ch);
let ident = &self.input[start..self.pos];

if ident == "if" {
If
} else if let Some(lit) = Constant::from_name(ident) {
Const(lit)
} else if unicode_ident::is_xid_start(ch) {
// A name is a Unicode identifier, as in Rust, so any script's letters may spell one
Ident(ident)
} else if ch == '\\' && body.chars().next().is_some_and(unicode_ident::is_xid_start) {
// The `\` prefix names the language's own builtin, like `\pi`, and is never an identifier by itself
Ident(ident)
} else {
// Digits, combining marks, invisible formatting characters, and symbols never begin a name, which also
// leaves `#`, `$`, `~`, and `@` free to become namespace prefixes once a host scope needs them
Expand Down
53 changes: 52 additions & 1 deletion libraries/math-parser/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ mod tests {
#[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"] {
for input in ["2@", "5#", "2 $ 3", "sqrt(4)@", "5 & 3", "5 | 3", "2 = 3", "\\", "2 \\ 3", "\\2", "\\_foo"] {
assert!(evaluate(input).is_err(), "expected `{input}` to be a parse error");
}
}
Expand All @@ -56,6 +56,52 @@ mod tests {
}
}

#[test]
fn bindings_shadow_constants_and_the_prefix_reaches_the_builtin() {
struct ShadowingBindings;
impl context::ValueProvider for ShadowingBindings {
fn get_value(&self, name: &str) -> Option<Value> {
match name {
"e" => Some(Value::from_f64(2.5)),
"π" => Some(Value::from_f64(3.)),
_ => None,
}
}
}
let eval = |source: &str| ast::Node::try_parse_from_str(source).unwrap().eval(&EvalContext::new(ShadowingBindings, context::NothingMap));

// A binding shadows the builtin of exactly its spelling, so a bound `π` shadows the constant while `pi` still reaches the builtin
assert_eq!(eval("e").unwrap().as_real(), Some(2.5));
assert_eq!(eval("π + pi").unwrap().as_real(), Some(3. + std::f64::consts::PI));

// The `\` prefix always reaches the builtin, and names no variable when no builtin has that name
assert_eq!(eval("\\e + \\π").unwrap().as_real(), Some(std::f64::consts::E + std::f64::consts::PI));
assert!(matches!(eval("\\foo"), Err(EvalError::MissingValue(name)) if name == "\\foo"));

// Constants are lowercase-only, so another casing is an unbound variable rather than a spelling of the constant
assert!(matches!(eval("E"), Err(EvalError::MissingValue(name)) if name == "E"));
}

#[test]
fn host_functions_shadow_builtins_except_behind_the_prefix() {
struct DoublingSin;
impl context::FunctionProvider for DoublingSin {
fn run_function(&self, name: &str, args: &[Value]) -> Option<Value> {
(name == "sin").then(|| Value::from_f64(2. * args[0].as_real().unwrap()))
}
}
let eval = |source: &str| {
ast::Node::try_parse_from_str(source)
.unwrap()
.eval(&EvalContext::new(context::NothingMap, DoublingSin))
.unwrap()
.as_real()
};

assert_eq!(eval("sin(3)"), Some(6.));
assert_eq!(eval("\\sin(pi / 2)"), Some(1.));
}

#[test]
fn dot_led_function_suffixes_fail_to_parse() {
// A `.`-led base suffix is an error (the supported spelling is `log0.5`)
Expand Down Expand Up @@ -358,6 +404,11 @@ mod tests {
// Truth values are the numbers 1 and 0
constant_truth_values: "true + true - false" => 2.,

// The `\` prefix names the language's own constants and functions, so LaTeX habits like `2\pi` evaluate
builtin_prefix_constant: "\\tau / \\pi" => 2.,
builtin_prefix_function: "\\sqrt(16)" => 4.,
builtin_prefix_implicit_multiplication: "2\\pi" => 2. * std::f64::consts::PI,

// atan2
trig_atan2_axis: "atan2(1, 0)" => std::f64::consts::FRAC_PI_2,

Expand Down
14 changes: 5 additions & 9 deletions libraries/math-parser/src/parser.rs
Original file line number Diff line number Diff line change
Expand Up @@ -44,10 +44,7 @@ where
E::Error: LabelError<'src, I, &'static str>,
{
recursive(|expr| {
let constant = select! {
Token::Float(f) => Node::Lit(Literal::Float(f)),
Token::Const(c) => Node::Lit(c.value())
};
let constant = select! { Token::Float(f) => Node::Lit(Literal::Float(f)) };

let args = expr.clone().separated_by(just(Token::Comma)).collect::<Vec<_>>().delimited_by(just(Token::LParen), just(Token::RParen));

Expand Down Expand Up @@ -152,7 +149,6 @@ where
#[cfg(test)]
mod tests {
use super::*;
use crate::value::Complex;

macro_rules! test_parser {
($($name:ident: $input:expr_2021 => $expected:expr_2021),* $(,)?) => {
Expand Down Expand Up @@ -201,10 +197,10 @@ mod tests {
name: "ii".to_string(),
expr: vec![Node::Lit(Literal::Float(16.))]
},
test_parse_i_mul: "i(16)" => Node::BinOp {
lhs: Box::new(Node::Lit(Literal::Complex(Complex::new(0., 1.)))),
op: BinaryOp::Mul,
rhs: Box::new(Node::Lit(Literal::Float(16.))),
// `i` is a name a binding may shadow, so only the evaluator can read this call as `i` times its argument
test_parse_i_mul: "i(16)" => Node::FnCall {
name: "i".to_string(),
expr: vec![Node::Lit(Literal::Float(16.))],
},
test_parse_complex_expr: "(1 + 2) * 3 - 4 ^ 2" => Node::BinOp {
lhs: Box::new(Node::BinOp {
Expand Down
Loading