From 2bfec256b707d10e2c90055313d04b51cd02e6fe Mon Sep 17 00:00:00 2001 From: DuBearte <130190089+DuBearte@users.noreply.github.com> Date: Thu, 15 Oct 2026 11:40:50 -0400 Subject: [PATCH] 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. --- crates/doc/src/expr.rs | 39 ++++++++++++++++++++++++++++++- crates/doc/src/format.rs | 4 ++++ docs/book/src/guide/parameters.md | 4 +++- 3 files changed, 45 insertions(+), 2 deletions(-) diff --git a/crates/doc/src/expr.rs b/crates/doc/src/expr.rs index 0d54eb10..8ea891a9 100644 --- a/crates/doc/src/expr.rs +++ b/crates/doc/src/expr.rs @@ -237,6 +237,8 @@ fn lex_spans(s: &str) -> Result, DocError> { let at = |i: usize| cs.get(i).map(|x| x.0).unwrap_or(s.len()); let mut i = 0; 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) { if c.is_whitespace() { i += 1; @@ -245,6 +247,17 @@ fn lex_spans(s: &str) -> Result, DocError> { while cs.get(i).is_some_and(|(_, c)| c.is_ascii_digit() || *c == '.') { 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`). if cs.get(i).is_some_and(|(_, c)| *c == 'e' || *c == 'E') { let sign = cs.get(i + 1).is_some_and(|(_, c)| *c == '-' || *c == '+'); @@ -257,7 +270,7 @@ fn lex_spans(s: &str) -> Result, DocError> { } } 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))); } else if c.is_alphabetic() || c == '_' || c == 'µ' { let st = i; @@ -282,6 +295,11 @@ fn lex_spans(s: &str) -> Result, DocError> { out.push((Tok::Op(','), at(i), at(i + 1))); i += 1; } else if "+-*/^(),%".contains(c) { + match c { + '(' => depth += 1, + ')' => depth = depth.saturating_sub(1), + _ => {} + } out.push((Tok::Op(c), at(i), at(i + 1))); i += 1; } else { @@ -819,6 +837,25 @@ mod tests { 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] fn arithmetic_and_units() { assert_eq!(ev("10", Kind::Length).unwrap(), 10.0); diff --git a/crates/doc/src/format.rs b/crates/doc/src/format.rs index 0f947449..9a4f9168 100644 --- a/crates/doc/src/format.rs +++ b/crates/doc/src/format.rs @@ -208,6 +208,10 @@ //! 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 //! 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; diff --git a/docs/book/src/guide/parameters.md b/docs/book/src/guide/parameters.md index 65a4043e..f9b43dc4 100644 --- a/docs/book/src/guide/parameters.md +++ b/docs/book/src/guide/parameters.md @@ -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 `+ - * / ^`, 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 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).