From ac371be50de68e0544b9d667b74ba30ed32144c9 Mon Sep 17 00:00:00 2001 From: Maciej Malinowski <231036771+webglobecontact-ui@users.noreply.github.com> Date: Mon, 19 Oct 2026 21:15:00 +0200 Subject: [PATCH 1/3] calc: LET and LAMBDA names ignore case in any script MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Excel matches LET and LAMBDA names case-insensitively in every script, so =LET(dług,100,DŁUG*2) is 200. The evaluator compared them ASCII-only and returned #NAME?. The three lookups (name evaluation, ISOMITTED, a LET variable shadowing a function name) now fold Unicode case. --- crates/calc/src/eval.rs | 11 ++++++++--- crates/calc/src/tests.rs | 13 +++++++++++++ docs/function-parity.md | 3 ++- 3 files changed, 23 insertions(+), 4 deletions(-) diff --git a/crates/calc/src/eval.rs b/crates/calc/src/eval.rs index 0f9cfb03d..da15977fd 100644 --- a/crates/calc/src/eval.rs +++ b/crates/calc/src/eval.rs @@ -256,7 +256,7 @@ impl<'h> Evaluator<'h> { } fn name(&mut self, n: &str) -> Ev { - if let Some((_, v)) = self.env.iter().rev().find(|(k, _)| k.eq_ignore_ascii_case(n)) { + if let Some((_, v)) = self.env.iter().rev().find(|(k, _)| same_name(k, n)) { return v.clone(); } let wb = self.host.workbook(); @@ -772,7 +772,7 @@ impl<'h> Evaluator<'h> { Ev::L(Arc::new(Lambda { params, body: body.clone(), env: self.env.clone() })) } "ISOMITTED" => { - let r = matches!(args.first(), Some(Expr::Name(n)) if self.env.iter().rev().find(|(k, _)| k.eq_ignore_ascii_case(n)).is_some_and(|(_, v)| matches!(v, Ev::V(Value::Empty)))); + let r = matches!(args.first(), Some(Expr::Name(n)) if self.env.iter().rev().find(|(k, _)| same_name(k, n)).is_some_and(|(_, v)| matches!(v, Ev::V(Value::Empty)))); Ev::V(Value::Bool(r)) } "MAP" | "REDUCE" | "SCAN" | "BYROW" | "BYCOL" | "MAKEARRAY" => self.higher_order(name, args), @@ -975,7 +975,7 @@ impl<'h> Evaluator<'h> { /// that name. fn function_by_name(&self, e: &Expr) -> Option { let Expr::Name(n) = e else { return None }; - if self.env.iter().any(|(k, _)| k.eq_ignore_ascii_case(n)) || self.host.workbook().name(n, self.sheet).is_some() { + if self.env.iter().any(|(k, _)| same_name(k, n)) || self.host.workbook().name(n, self.sheet).is_some() { return None; } let u = n.to_ascii_uppercase(); @@ -1868,5 +1868,10 @@ pub fn is_known_function(n: &str) -> bool { is_special(&n.to_ascii_uppercase()) || gridcraft_functions::lookup(n).is_some() } +/// LET and LAMBDA names compare case-insensitively in any script, as in Excel (`dług` = `DŁUG`). +fn same_name(a: &str, b: &str) -> bool { + a.chars().flat_map(char::to_uppercase).eq(b.chars().flat_map(char::to_uppercase)) +} + #[allow(dead_code)] fn unused(_: RefKind) {} diff --git a/crates/calc/src/tests.rs b/crates/calc/src/tests.rs index 97f7a8350..2910e96a1 100644 --- a/crates/calc/src/tests.rs +++ b/crates/calc/src/tests.rs @@ -590,6 +590,19 @@ fn let_lambda() { assert_eq!(t.num("A4"), 14.0); } +#[test] +fn let_lambda_names_ignore_case_in_any_script() { + let mut t = T::new(); + t.set("A1", "=LET(dług,100,DŁUG*2)"); + assert_eq!(t.num("A1"), 200.0); + t.set("A3", "=LAMBDA(ćma,ĆMA+1)(1)"); + assert_eq!(t.num("A3"), 2.0); + t.set("A4", "=LET(ŻAR,LAMBDA(x,x*3),żar(2))"); + assert_eq!(t.num("A4"), 6.0); + t.set("A5", "=LET(λ,LAMBDA(x,x+1),SUM(MAP({1,2},Λ)))"); + assert_eq!(t.num("A5"), 5.0); +} + #[test] fn functions_given_by_name() { let mut t = T::new(); diff --git a/docs/function-parity.md b/docs/function-parity.md index 2343e0de9..10588e94d 100644 --- a/docs/function-parity.md +++ b/docs/function-parity.md @@ -1,6 +1,6 @@ # Function parity: worksheet functions and formulas -> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (MEDIAN and AGGREGATE median avoid intermediate overflow) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-19 · **Change:** minor (LET and LAMBDA names ignore case in any script) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 Excel's worksheet functions, category by category, against GridCraft's function library. Part of [target-app-parity.md](target-app-parity.md); gaps feed [gaps.md](gaps.md). @@ -114,6 +114,7 @@ workbooks; results checked in as data, not Excel files) and fixing what it finds | Date | Change | Summary | |---|---|---| +| 2026-10-19 | minor | LET and LAMBDA names compare case-insensitively in any script, as in Excel: `=LET(dług,100,DŁUG*2)` is 200 (was #NAME?); also LET-bound lambdas called or passed by name (`żar(2)`, `MAP(…,Λ)`) | | 2026-10-15 | minor | MEDIAN and AGGREGATE median use an overflow-safe midpoint (#1579); regressions cover finite extremes, tiny values and dependent recalculation | | 2026-10-13 | minor | Clear reports changed stored cells sparsely, including those beyond the first million coordinates; dependent formulas and removed spills update, while clearing empty merges still unblocks spills (#1032) | | 2026-10-12 | minor | USDOLLAR added as a Compatibility function (not on Microsoft's list, so the counts don't change); MIRR with no positive flow is -1, COUNTIF's bare `"<="` / `">="` skip empty strings, INDIRECT with `a1` = FALSE gives `#REF!` for A1-style text (#738) | From e9bcb8dfbba14fc1494a6911fe9dac7e6b9a5ed4 Mon Sep 17 00:00:00 2001 From: Maciej Malinowski <231036771+webglobecontact-ui@users.noreply.github.com> Date: Mon, 19 Oct 2026 21:46:10 +0200 Subject: [PATCH 2/3] calc: one shared same_name for LET/LAMBDA names, with case folding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - gridcraft_formula::same_name lower-cases then upper-cases each character, so ß matches ẞ (to_uppercase alone maps ß to SS). - Formula-bar input uses it too, so a binding such as ſi is not translated to IF while the evaluator binds it. - Tests: ISOMITTED with a case variant, ß/ẞ, and the Spanish input case. --- crates/calc/src/eval.rs | 11 +++-------- crates/calc/src/tests.rs | 5 +++++ crates/formula/src/input.rs | 2 +- crates/formula/src/lib.rs | 8 ++++++++ crates/formula/tests/spanish_input.rs | 9 +++++++++ docs/function-parity.md | 2 +- 6 files changed, 27 insertions(+), 10 deletions(-) diff --git a/crates/calc/src/eval.rs b/crates/calc/src/eval.rs index da15977fd..c0868ae0b 100644 --- a/crates/calc/src/eval.rs +++ b/crates/calc/src/eval.rs @@ -256,7 +256,7 @@ impl<'h> Evaluator<'h> { } fn name(&mut self, n: &str) -> Ev { - if let Some((_, v)) = self.env.iter().rev().find(|(k, _)| same_name(k, n)) { + if let Some((_, v)) = self.env.iter().rev().find(|(k, _)| gridcraft_formula::same_name(k, n)) { return v.clone(); } let wb = self.host.workbook(); @@ -772,7 +772,7 @@ impl<'h> Evaluator<'h> { Ev::L(Arc::new(Lambda { params, body: body.clone(), env: self.env.clone() })) } "ISOMITTED" => { - let r = matches!(args.first(), Some(Expr::Name(n)) if self.env.iter().rev().find(|(k, _)| same_name(k, n)).is_some_and(|(_, v)| matches!(v, Ev::V(Value::Empty)))); + let r = matches!(args.first(), Some(Expr::Name(n)) if self.env.iter().rev().find(|(k, _)| gridcraft_formula::same_name(k, n)).is_some_and(|(_, v)| matches!(v, Ev::V(Value::Empty)))); Ev::V(Value::Bool(r)) } "MAP" | "REDUCE" | "SCAN" | "BYROW" | "BYCOL" | "MAKEARRAY" => self.higher_order(name, args), @@ -975,7 +975,7 @@ impl<'h> Evaluator<'h> { /// that name. fn function_by_name(&self, e: &Expr) -> Option { let Expr::Name(n) = e else { return None }; - if self.env.iter().any(|(k, _)| same_name(k, n)) || self.host.workbook().name(n, self.sheet).is_some() { + if self.env.iter().any(|(k, _)| gridcraft_formula::same_name(k, n)) || self.host.workbook().name(n, self.sheet).is_some() { return None; } let u = n.to_ascii_uppercase(); @@ -1868,10 +1868,5 @@ pub fn is_known_function(n: &str) -> bool { is_special(&n.to_ascii_uppercase()) || gridcraft_functions::lookup(n).is_some() } -/// LET and LAMBDA names compare case-insensitively in any script, as in Excel (`dług` = `DŁUG`). -fn same_name(a: &str, b: &str) -> bool { - a.chars().flat_map(char::to_uppercase).eq(b.chars().flat_map(char::to_uppercase)) -} - #[allow(dead_code)] fn unused(_: RefKind) {} diff --git a/crates/calc/src/tests.rs b/crates/calc/src/tests.rs index 2910e96a1..19b50ca3d 100644 --- a/crates/calc/src/tests.rs +++ b/crates/calc/src/tests.rs @@ -595,12 +595,17 @@ fn let_lambda_names_ignore_case_in_any_script() { let mut t = T::new(); t.set("A1", "=LET(dług,100,DŁUG*2)"); assert_eq!(t.num("A1"), 200.0); + t.set("A2", "=LAMBDA(λ,ISOMITTED(Λ))()"); + assert_eq!(t.get("A2"), Value::Bool(true)); t.set("A3", "=LAMBDA(ćma,ĆMA+1)(1)"); assert_eq!(t.num("A3"), 2.0); t.set("A4", "=LET(ŻAR,LAMBDA(x,x*3),żar(2))"); assert_eq!(t.num("A4"), 6.0); t.set("A5", "=LET(λ,LAMBDA(x,x+1),SUM(MAP({1,2},Λ)))"); assert_eq!(t.num("A5"), 5.0); + // `ß` upper-cases to `SS`, yet `ẞ` is its capital. + t.set("A6", "=LET(ß,100,ẞ*2)"); + assert_eq!(t.num("A6"), 200.0); } #[test] diff --git a/crates/formula/src/input.rs b/crates/formula/src/input.rs index 8568023ed..5c54bb7de 100644 --- a/crates/formula/src/input.rs +++ b/crates/formula/src/input.rs @@ -128,7 +128,7 @@ fn normalize(expr: &mut Expr, known_name: &impl Fn(&str) -> bool, bindings: &mut if depth > parser::MAX_DEPTH { return Err(ParseError { msg: "formula is nested too deeply".into(), pos: 0 }); } - let bound = |name: &str, bindings: &[String]| bindings.iter().rev().any(|n| n.eq_ignore_ascii_case(name)) || known_name(name); + let bound = |name: &str, bindings: &[String]| bindings.iter().rev().any(|n| crate::same_name(n, name)) || known_name(name); match expr { Expr::Bool(value) if display => { let alias = if *value { "VERDADERO" } else { "FALSO" }; diff --git a/crates/formula/src/lib.rs b/crates/formula/src/lib.rs index 88d09a8af..5303c73c0 100644 --- a/crates/formula/src/lib.rs +++ b/crates/formula/src/lib.rs @@ -15,5 +15,13 @@ pub use input::{FormulaLocale, parse_input, print_input}; pub use parser::{MAX_FORMULA_LEN, ParseError, normalize_function_name, parse}; pub use printer::{print, print_r1c1, quote_sheet}; +/// Whether two LET/LAMBDA names are the same: case-insensitive in any script, as in Excel +/// (`dług` = `DŁUG`, `ß` = `ẞ`). Each character is lower-cased, then upper-cased, so letters whose +/// upper case is not one character (`ß` -> `SS`) still match their capital. +pub fn same_name(a: &str, b: &str) -> bool { + let fold = |s: &str| s.chars().flat_map(char::to_lowercase).flat_map(char::to_uppercase).collect::(); + a == b || fold(a) == fold(b) +} + #[cfg(test)] mod tests; diff --git a/crates/formula/tests/spanish_input.rs b/crates/formula/tests/spanish_input.rs index fee688a86..5e5677665 100644 --- a/crates/formula/tests/spanish_input.rs +++ b/crates/formula/tests/spanish_input.rs @@ -98,3 +98,12 @@ fn spanish_display_round_trips_text_containing_separators() { assert_eq!(shown, r#"SI(A1="a,b;c";CONCAT("1.5";",";";");SUMA(0,25;A1:A3 B2))"#); assert_eq!(print(&parse_input(&shown, es, |_| false).unwrap()), print(&expr)); } + +#[test] +fn local_names_match_aliases_in_any_case_like_the_evaluator() { + // `ſi` (long s) is `SI` case-insensitively, so the call is the LAMBDA, not IF. + assert_eq!(input("LET(ſi;LAMBDA(x;x*2);SI(3))"), "LET(ſi,LAMBDA(x,x*2),SI(3))"); + assert!(gridcraft_formula::same_name("ß", "ẞ")); + assert!(gridcraft_formula::same_name("dług", "DŁUG")); + assert!(!gridcraft_formula::same_name("a", "b")); +} diff --git a/docs/function-parity.md b/docs/function-parity.md index 10588e94d..a8e223356 100644 --- a/docs/function-parity.md +++ b/docs/function-parity.md @@ -114,7 +114,7 @@ workbooks; results checked in as data, not Excel files) and fixing what it finds | Date | Change | Summary | |---|---|---| -| 2026-10-19 | minor | LET and LAMBDA names compare case-insensitively in any script, as in Excel: `=LET(dług,100,DŁUG*2)` is 200 (was #NAME?); also LET-bound lambdas called or passed by name (`żar(2)`, `MAP(…,Λ)`) | +| 2026-10-19 | minor | LET and LAMBDA names compare case-insensitively in any script, as in Excel: `=LET(dług,100,DŁUG*2)` is 200 (was #NAME?); also LET-bound lambdas called or passed by name (`żar(2)`, `MAP(…,Λ)`), `ISOMITTED`, and formula-bar alias translation (one shared `gridcraft_formula::same_name`, which also matches `ß` with `ẞ`) | | 2026-10-15 | minor | MEDIAN and AGGREGATE median use an overflow-safe midpoint (#1579); regressions cover finite extremes, tiny values and dependent recalculation | | 2026-10-13 | minor | Clear reports changed stored cells sparsely, including those beyond the first million coordinates; dependent formulas and removed spills update, while clearing empty merges still unblocks spills (#1032) | | 2026-10-12 | minor | USDOLLAR added as a Compatibility function (not on Microsoft's list, so the counts don't change); MIRR with no positive flow is -1, COUNTIF's bare `"<="` / `">="` skip empty strings, INDIRECT with `a1` = FALSE gives `#REF!` for A1-style text (#738) | From a74e82ac939b181982b4f81639b25bc05608daf2 Mon Sep 17 00:00:00 2001 From: Maciej Malinowski <231036771+webglobecontact-ui@users.noreply.github.com> Date: Mon, 19 Oct 2026 22:27:21 +0200 Subject: [PATCH 3/3] =?UTF-8?q?formula:=20same=5Fname=20folds=20case=20wit?= =?UTF-8?q?hout=20merging=20=C4=B1=20and=20i?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lower-casing then upper-casing made dotless ı equal to i (both become I), which Unicode's default case folding keeps apart; LET(i,1,ı,2,i) could return the ı binding. same_name now lower-cases and adds the folds lower case misses (ß/ẞ -> ss, ſ -> s, ς -> σ). Tests cover ı/i and the Greek final sigma. --- crates/calc/src/tests.rs | 3 +++ crates/formula/src/lib.rs | 17 ++++++++++++++--- crates/formula/tests/spanish_input.rs | 5 +++++ docs/function-parity.md | 2 +- 4 files changed, 23 insertions(+), 4 deletions(-) diff --git a/crates/calc/src/tests.rs b/crates/calc/src/tests.rs index 19b50ca3d..0f853e2c7 100644 --- a/crates/calc/src/tests.rs +++ b/crates/calc/src/tests.rs @@ -606,6 +606,9 @@ fn let_lambda_names_ignore_case_in_any_script() { // `ß` upper-cases to `SS`, yet `ẞ` is its capital. t.set("A6", "=LET(ß,100,ẞ*2)"); assert_eq!(t.num("A6"), 200.0); + // Dotless ı is a different name from i. + t.set("A7", "=LET(i,1,ı,2,i)"); + assert_eq!(t.num("A7"), 1.0); } #[test] diff --git a/crates/formula/src/lib.rs b/crates/formula/src/lib.rs index 5303c73c0..77f4e5899 100644 --- a/crates/formula/src/lib.rs +++ b/crates/formula/src/lib.rs @@ -16,10 +16,21 @@ pub use parser::{MAX_FORMULA_LEN, ParseError, normalize_function_name, parse}; pub use printer::{print, print_r1c1, quote_sheet}; /// Whether two LET/LAMBDA names are the same: case-insensitive in any script, as in Excel -/// (`dług` = `DŁUG`, `ß` = `ẞ`). Each character is lower-cased, then upper-cased, so letters whose -/// upper case is not one character (`ß` -> `SS`) still match their capital. +/// (`dług` = `DŁUG`, `ß` = `ẞ`). Close to Unicode's default case folding: lower case, plus the +/// folds lower-casing misses for letters a name may hold (`ß`/`ẞ` -> `ss`, `ſ` -> `s`, `ς` -> `σ`). +/// Dotless `ı` stays distinct from `i`, as outside Turkic languages. pub fn same_name(a: &str, b: &str) -> bool { - let fold = |s: &str| s.chars().flat_map(char::to_lowercase).flat_map(char::to_uppercase).collect::(); + let fold = |s: &str| -> String { + s.chars() + .flat_map(char::to_lowercase) + .flat_map(|c| match c { + 'ß' => ['s', 's'].into_iter().take(2), + 'ſ' => ['s', 's'].into_iter().take(1), + 'ς' => ['σ', 'σ'].into_iter().take(1), + c => [c, c].into_iter().take(1), + }) + .collect() + }; a == b || fold(a) == fold(b) } diff --git a/crates/formula/tests/spanish_input.rs b/crates/formula/tests/spanish_input.rs index 5e5677665..d04f7da20 100644 --- a/crates/formula/tests/spanish_input.rs +++ b/crates/formula/tests/spanish_input.rs @@ -105,5 +105,10 @@ fn local_names_match_aliases_in_any_case_like_the_evaluator() { assert_eq!(input("LET(ſi;LAMBDA(x;x*2);SI(3))"), "LET(ſi,LAMBDA(x,x*2),SI(3))"); assert!(gridcraft_formula::same_name("ß", "ẞ")); assert!(gridcraft_formula::same_name("dług", "DŁUG")); + assert!(gridcraft_formula::same_name("ſi", "SI")); + assert!(gridcraft_formula::same_name("σοφός", "ΣΟΦΌΣ")); + // Dotless ı is its own letter outside Turkic languages. + assert!(!gridcraft_formula::same_name("ı", "i")); + assert!(!gridcraft_formula::same_name("ı", "I")); assert!(!gridcraft_formula::same_name("a", "b")); } diff --git a/docs/function-parity.md b/docs/function-parity.md index a8e223356..6c841a2be 100644 --- a/docs/function-parity.md +++ b/docs/function-parity.md @@ -114,7 +114,7 @@ workbooks; results checked in as data, not Excel files) and fixing what it finds | Date | Change | Summary | |---|---|---| -| 2026-10-19 | minor | LET and LAMBDA names compare case-insensitively in any script, as in Excel: `=LET(dług,100,DŁUG*2)` is 200 (was #NAME?); also LET-bound lambdas called or passed by name (`żar(2)`, `MAP(…,Λ)`), `ISOMITTED`, and formula-bar alias translation (one shared `gridcraft_formula::same_name`, which also matches `ß` with `ẞ`) | +| 2026-10-19 | minor | LET and LAMBDA names compare case-insensitively in any script, as in Excel: `=LET(dług,100,DŁUG*2)` is 200 (was #NAME?); also LET-bound lambdas called or passed by name (`żar(2)`, `MAP(…,Λ)`), `ISOMITTED`, and formula-bar alias translation (one shared `gridcraft_formula::same_name`, which matches `ß` with `ẞ` and keeps dotless `ı` apart from `i`) | | 2026-10-15 | minor | MEDIAN and AGGREGATE median use an overflow-safe midpoint (#1579); regressions cover finite extremes, tiny values and dependent recalculation | | 2026-10-13 | minor | Clear reports changed stored cells sparsely, including those beyond the first million coordinates; dependent formulas and removed spills update, while clearing empty merges still unblocks spills (#1032) | | 2026-10-12 | minor | USDOLLAR added as a Compatibility function (not on Microsoft's list, so the counts don't change); MIRR with no positive flow is -1, COUNTIF's bare `"<="` / `">="` skip empty strings, INDIRECT with `a1` = FALSE gives `#REF!` for A1-style text (#738) |