From 7a11989935b1cf1b9e38cc3a8e818ff0fc8104f9 Mon Sep 17 00:00:00 2001 From: Keavon Chambers Date: Tue, 1 Sep 2026 15:49:07 -0700 Subject: [PATCH] Shadow the constants behind case-sensitive bindings, with the `\` prefix reaching the builtin Constants resolve at evaluation time, after the environment, so a binding of any spelling, word or typeset symbol, shadows the builtin of exactly that spelling, and a host-supplied function likewise shadows a builtin function. The `\` prefix, in LaTeX's habit, names the language's own constant or function regardless of bindings (`\pi`, `\e`, `\sin`), and is an error when no builtin has that name. --- libraries/math-parser/src/executer.rs | 35 +++++++++++++++--- libraries/math-parser/src/lexer.rs | 13 ++++--- libraries/math-parser/src/lib.rs | 53 ++++++++++++++++++++++++++- libraries/math-parser/src/parser.rs | 14 +++---- 4 files changed, 93 insertions(+), 22 deletions(-) diff --git a/libraries/math-parser/src/executer.rs b/libraries/math-parser/src/executer.rs index 867ebeaefa..033fa8da53 100644 --- a/libraries/math-parser/src/executer.rs +++ b/libraries/math-parser/src/executer.rs @@ -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; @@ -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(context: &EvalContext, name: &str) -> Option { + 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(&self, context: &EvalContext) -> Result { match self { @@ -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]; @@ -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)` diff --git a/libraries/math-parser/src/lexer.rs b/libraries/math-parser/src/lexer.rs index 544ab3cfb9..ea53c1c0b6 100644 --- a/libraries/math-parser/src/lexer.rs +++ b/libraries/math-parser/src/lexer.rs @@ -10,7 +10,6 @@ pub type Span = SimpleSpan; #[derive(Clone, Debug, PartialEq)] pub enum Token<'src> { Float(f64), - Const(Constant), Ident(&'src str), AndAnd, @@ -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("&&"), @@ -120,7 +118,6 @@ impl Constant { ("φ", Phi), ("inf", Inf), ("infinity", Inf), - ("∞", Inf), ("true", True), ("false", False), ]; @@ -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, @@ -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 diff --git a/libraries/math-parser/src/lib.rs b/libraries/math-parser/src/lib.rs index 70365db27b..2c957b9d2d 100644 --- a/libraries/math-parser/src/lib.rs +++ b/libraries/math-parser/src/lib.rs @@ -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"); } } @@ -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 { + 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 { + (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`) @@ -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, diff --git a/libraries/math-parser/src/parser.rs b/libraries/math-parser/src/parser.rs index c70365eb0b..f964246d6f 100644 --- a/libraries/math-parser/src/parser.rs +++ b/libraries/math-parser/src/parser.rs @@ -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::>().delimited_by(just(Token::LParen), just(Token::RParen)); @@ -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),* $(,)?) => { @@ -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 {