Expressions: accept a decimal comma outside parentheses (#1347) (#1382)

"2,5 mm" is 2.5 mm; inside a function's parentheses the comma still separates the arguments. Noted under design format 22: an expression typed with a decimal comma is saved as typed, so 0.12.0 asks for an update instead of failing to evaluate it.
This commit is contained in:
DuBearte authored and GitHub committed 2026-10-15 11:40:50 -04:00
1 parent e8c340c59d
commit 2bfec256b7
3 files changed
+45 -2

No files matched your search

+38 -1
View File
@@ -237,6 +237,8 @@ fn lex_spans(s: &str) -> Result<Vec<(Tok, usize, usize)>, DocError> {
let at = |i: usize| cs.get(i).map(|x| x.0).unwrap_or(s.len()); let at = |i: usize| cs.get(i).map(|x| x.0).unwrap_or(s.len());
let mut i = 0; let mut i = 0;
let mut out = Vec::new(); let mut out = Vec::new();
// Parentheses open at this point: a comma outside them can only be a decimal comma.
let mut depth = 0usize;
while let Some(&(_, c)) = cs.get(i) { while let Some(&(_, c)) = cs.get(i) {
if c.is_whitespace() { if c.is_whitespace() {
i += 1; i += 1;
@@ -245,6 +247,17 @@ fn lex_spans(s: &str) -> Result<Vec<(Tok, usize, usize)>, DocError> {
while cs.get(i).is_some_and(|(_, c)| c.is_ascii_digit() || *c == '.') { while cs.get(i).is_some_and(|(_, c)| c.is_ascii_digit() || *c == '.') {
i += 1; i += 1;
} }
// A decimal comma (`2,5 mm`, as typed with a numeric keypad in many locales, #1347):
// a comma between digits, in a number with no point, outside any parentheses (inside
// them a comma separates a function's arguments: `max(1,2)`).
let digit = |k: usize| cs.get(k).is_some_and(|(_, c)| c.is_ascii_digit());
let has_point = cs.get(st..i).is_some_and(|ds| ds.iter().any(|(_, c)| *c == '.'));
if depth == 0 && !has_point && digit(i.wrapping_sub(1)) && cs.get(i).is_some_and(|(_, c)| *c == ',') && digit(i + 1) {
i += 1;
while digit(i) {
i += 1;
}
}
// Exponent: 1e-3 (but not `2em`). // Exponent: 1e-3 (but not `2em`).
if cs.get(i).is_some_and(|(_, c)| *c == 'e' || *c == 'E') { if cs.get(i).is_some_and(|(_, c)| *c == 'e' || *c == 'E') {
let sign = cs.get(i + 1).is_some_and(|(_, c)| *c == '-' || *c == '+'); let sign = cs.get(i + 1).is_some_and(|(_, c)| *c == '-' || *c == '+');
@@ -257,7 +270,7 @@ fn lex_spans(s: &str) -> Result<Vec<(Tok, usize, usize)>, DocError> {
} }
} }
let t = s.get(at(st)..at(i)).unwrap_or_default(); let t = s.get(at(st)..at(i)).unwrap_or_default();
let v: f64 = t.parse().map_err(|_| DocError::Expr(format!("bad number `{t}`")))?; let v: f64 = t.replace(',', ".").parse().map_err(|_| DocError::Expr(format!("bad number `{t}`")))?;
out.push((Tok::Num(v), at(st), at(i))); out.push((Tok::Num(v), at(st), at(i)));
} else if c.is_alphabetic() || c == '_' || c == 'µ' { } else if c.is_alphabetic() || c == '_' || c == 'µ' {
let st = i; let st = i;
@@ -282,6 +295,11 @@ fn lex_spans(s: &str) -> Result<Vec<(Tok, usize, usize)>, DocError> {
out.push((Tok::Op(','), at(i), at(i + 1))); out.push((Tok::Op(','), at(i), at(i + 1)));
i += 1; i += 1;
} else if "+-*/^(),%".contains(c) { } else if "+-*/^(),%".contains(c) {
match c {
'(' => depth += 1,
')' => depth = depth.saturating_sub(1),
_ => {}
}
out.push((Tok::Op(c), at(i), at(i + 1))); out.push((Tok::Op(c), at(i), at(i + 1)));
i += 1; i += 1;
} else { } else {
@@ -819,6 +837,25 @@ mod tests {
ParamDef { name: n.into(), expr: e.into(), unit: u.into() } ParamDef { name: n.into(), expr: e.into(), unit: u.into() }
} }
#[test]
fn a_decimal_comma_outside_parentheses() {
// #1347: a comma between digits is a decimal comma where it cannot separate arguments.
assert_eq!(ev("2,5", Kind::Unitless).unwrap(), 2.5);
assert_eq!(ev("2,5 mm", Kind::Length).unwrap(), 2.5);
assert_eq!(ev("12,75mm + 0,25", Kind::Length).unwrap(), 13.0);
assert_eq!(ev("width / 2,5", Kind::Length).unwrap(), 16.0);
assert!((ev("1,5e3 mm", Kind::Length).unwrap() - 1500.0).abs() < 1e-9);
assert!((ev("22,5 deg", Kind::Angle).unwrap() - 22.5f64.to_radians()).abs() < 1e-12);
// Inside parentheses a comma still separates a function's arguments.
assert_eq!(ev("max(1,2)", Kind::Unitless).unwrap(), 2.0);
assert_eq!(ev("max(1 mm, 3, 2)", Kind::Length).unwrap(), 3.0);
assert_eq!(ev("(2,5)", Kind::Unitless).ok(), None, "a comma inside parentheses is not a decimal comma");
// Not between two digits, or a second comma: still an error.
for bad in ["2,", ",5", "2 ,5", "2, 5", "1,5,3", "1.5,3"] {
assert!(ev(bad, Kind::Unitless).is_err(), "{bad}");
}
}
#[test] #[test]
fn arithmetic_and_units() { fn arithmetic_and_units() {
assert_eq!(ev("10", Kind::Length).unwrap(), 10.0); assert_eq!(ev("10", Kind::Length).unwrap(), 10.0);
+4
View File
@@ -208,6 +208,10 @@
//! whose imports sit loose in a component are left as they are. The version stamp makes //! whose imports sit loose in a component are left as they are. The version stamp makes
//! 0.12.0 (which reads up to 21) ask for an update instead of building those instances again //! 0.12.0 (which reads up to 21) ask for an update instead of building those instances again
//! on top of their occurrences. //! on top of their occurrences.
//! Also under 22, a number in an expression can be written with a decimal comma outside
//! parentheses (`2,5 mm` is `2.5 mm`, #1347; inside a function's parentheses the comma still
//! separates its arguments). Nothing new is saved, but an expression typed that way is saved as
//! typed; 0.12.0 asks for an update instead of failing to evaluate it.
use serde_json::Value; use serde_json::Value;
+3 -1
View File
@@ -17,7 +17,9 @@ does not also submit the parameter.
Expressions are unit-aware: `2 * width + 5 mm`, `angle / 2`, `sqrt(area)`, `1 in + 3 mm`, using Expressions are unit-aware: `2 * width + 5 mm`, `angle / 2`, `sqrt(area)`, `1 in + 3 mm`, using
`+ - * / ^`, parentheses, `sin cos tan asin acos atan atan2 sqrt abs min max floor ceil round mod `+ - * / ^`, parentheses, `sin cos tan asin acos atan atan2 sqrt abs min max floor ceil round mod
trunc` and unit conversion. A length where an angle is expected (or the other way round) is an trunc` and unit conversion. A decimal comma works too, outside parentheses: `2,5 mm` is
`2.5 mm` (inside a function's parentheses the comma separates its arguments, so write
`max(2.5, 3)` there). A length where an angle is expected (or the other way round) is an
error, not a silent conversion. Cycles are reported with their path (`a → b → a`), renaming a error, not a silent conversion. Cycles are reported with their path (`a → b → a`), renaming a
parameter updates every expression, dimension and feature input that uses it, and a parameter parameter updates every expression, dimension and feature input that uses it, and a parameter
in use can't be deleted (the error lists its users). in use can't be deleted (the error lists its users).