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
21 changes: 18 additions & 3 deletions libraries/math-parser/src/lexer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -186,8 +186,22 @@ impl<'a> Lexer<'a> {
(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 {
// Two number literals never juxtapose, so digit grouping like `10 000` can't silently multiply
fn follows_number_literal(&self, literal_start: usize) -> bool {
let preceding = self.input[..literal_start].trim_end();

// The preceding token begins within its run of name and number characters (`2pi`, `1e5`), whose start is a token boundary to lex from
let run_start = preceding
.char_indices()
.rev()
.take_while(|&(_, c)| unicode_ident::is_xid_continue(c) || c == '.')
.last()
.map_or(preceding.len(), |(index, _)| index);
Lexer::new(&preceding[run_start..]).last().is_some_and(|token| matches!(token, Token::Float(_)))
}

// A `.`-led literal can't follow an operand (`sqrt(4).5`), which must write its leading zero instead
fn follows_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
Expand Down Expand Up @@ -226,7 +240,8 @@ 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.juxtaposes_with_preceding_operand(start_pos) {
let leading_dot = self.input[start_pos..].starts_with('.');
if !got_digit || self.peek().is_some_and(|c| c == '.' || c.is_ascii_digit()) || self.follows_number_literal(start_pos) || (leading_dot && self.follows_operand(start_pos)) {
self.pos = start_pos;
return None;
}
Expand Down
17 changes: 12 additions & 5 deletions libraries/math-parser/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -50,15 +50,15 @@ mod tests {

#[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"] {
// Adjacent number literals like digit-grouped `10 000` must not silently multiply, and a `.`-led literal after any operand needs its leading zero
for input in ["2 3", "10 000", "1 .5", "2 3 + 1", "1e5 3", "2. 3", "sqrt(4).5", "sqrt(4) .5", "pi.5", "5!.5"] {
assert!(evaluate(input).is_err(), "expected `{input}` to be a parse error");
}
}

#[test]
fn dot_led_function_suffixes_fail_to_parse() {
// A `.`-led base suffix must stay an error, keeping dot-after-identifier free for possible future accessor syntax (the supported spelling is `log0.5`)
// A `.`-led base suffix is an error (the supported spelling is `log0.5`)
for input in ["log.5(8)", "log.5", "root.5(9)", "log_.5(8)"] {
assert!(evaluate(input).is_err(), "expected `{input}` to be a parse error");
}
Expand Down Expand Up @@ -194,6 +194,13 @@ mod tests {
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.,
implicit_multiplication_trailing_number: "pi 2" => 2. * std::f64::consts::PI,
implicit_multiplication_trailing_number_after_call: "sqrt(4) 3" => 6.,
implicit_multiplication_trailing_number_unspaced: "sqrt(4)3" => 6.,
implicit_multiplication_trailing_leading_zero: "sqrt(4) 0.5" => 1.,
implicit_multiplication_trailing_number_after_name_run: "2pi 3" => 6. * std::f64::consts::PI,
implicit_multiplication_trailing_number_after_factorial: "3! 2" => 12.,
implicit_multiplication_trailing_number_after_infinity: "∞ 2" => f64::INFINITY,

// Factorial (postfix !)
factorial_simple: "5!" => 120.,
Expand Down Expand Up @@ -448,9 +455,9 @@ mod tests {
assert!(ast::Node::try_parse_from_str(&input).is_err(), "expected `{input}` to be a parse error");
}

// A name ending in a combining mark is still an operand, so a spaced number after it doesn't silently multiply
// A name ending in a combining mark is an operand like any other, so a spaced number after it multiplies
for input in ["x 2".to_string(), format!("{decomposed_e_acute} 2")] {
assert!(ast::Node::try_parse_from_str(&input).is_err(), "expected `{input}` to be a parse error");
assert!(ast::Node::try_parse_from_str(&input).is_ok(), "expected `{input}` to parse");
}
}

Expand Down
2 changes: 1 addition & 1 deletion libraries/math-parser/src/parser.rs
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ where
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 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`).
// The implicit operand is a `pow`, not a full unary, so `2 -3` stays a subtraction; the lexer rejects a number right after another number (`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),
Expand Down
Loading