From 5c20f164342f0b443fb7e6897f52f6e0b3934163 Mon Sep 17 00:00:00 2001 From: Harrison Booth Date: Wed, 14 Oct 2026 18:30:16 -0500 Subject: [PATCH] core: read accounting negatives like $(1,234) in number text Excel 365 reads a dollar sign before accounting parentheses ($(1,234), $ (5)) and a percent sign after them ((5)%) as negative numbers when the text is typed, coerced by arithmetic or passed to VALUE. GridCraft gave #VALUE! for these. The parentheses branch no longer recurses: Excel reads only an unsigned plain number or fraction inside, so (-5), ((5)), ($-5), and dates or times in parentheses are now #VALUE! as in Excel (they used to give 5 or a negative serial). $5% is #VALUE! too: Excel never combines the currency and percent signs. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 4 +- crates/calc/src/tests.rs | 15 +++++ crates/core/src/parse.rs | 115 ++++++++++++++++++++++++++++++++++- crates/functions/src/text.rs | 7 +++ docs/function-parity.md | 3 +- 5 files changed, 139 insertions(+), 5 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 070552d07..610011d87 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~25 points of "ready for real work" and ~160–240 h away -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** minor (rows auto-fit oversized fonts on open and whole-row/column formatting) · **Target:** Microsoft Excel (Microsoft 365) +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-14 · **Change:** minor (accounting negatives such as `$(1,234)` read as numbers) · **Target:** Microsoft Excel (Microsoft 365) GridCraft aims at full Microsoft Excel parity, and at being better in places: faster, open (XLSX is the native format), fully scriptable by agents (MCP, control channel, CLI), and on the web. @@ -132,6 +132,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | What landed | |---|---| +| 2026-10-14 | Accounting negatives in text: `$(1,234)`, `$ (5)` and `(5)%` read as negative numbers when typed, coerced by arithmetic or passed to VALUE, like Excel 365; `(-5)`, `((5))`, `$5%` and dates or times in parentheses stay text | | 2026-10-12 | Rows auto-fit oversized fonts: opening a file and whole-row/column formatting grow non-custom rows to their tallest font (#833) | | 2026-10-12 | Number formats: parsing a format code is linear in its length (long runs of `[`, `,` or `/` no longer take quadratic time), and an unclosed `[` is a literal when splitting sections as well as when tokenizing (#810) | | 2026-10-12 | Number formats: a fraction denominator with 65,536 zeros no longer crashes; format codes are capped at 255 characters like Excel (longer codes display as General, `TEXT` gives `#VALUE!`) (#807) | @@ -193,6 +194,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | Change | Summary | |---|---|---| +| 2026-10-14 | minor | Progress log: accounting negatives in number text | | 2026-10-12 | minor | Rows auto-fit oversized fonts on open and whole-row/column formatting (#833); progress log row | | 2026-10-12 | minor | Progress log: multi-area row/column delete (#758) | | 2026-10-12 | trivial | Progress log: XLSX chart sheets kept on save (#727) | diff --git a/crates/calc/src/tests.rs b/crates/calc/src/tests.rs index 196c57de7..1d5a1a464 100644 --- a/crates/calc/src/tests.rs +++ b/crates/calc/src/tests.rs @@ -310,6 +310,21 @@ fn date_text_coerces_in_the_workbook_date_system() { } } +#[test] +fn accounting_negative_text_coerces() { + let mut t = T::new(); + t.set("A1", "'$(1,234)"); + t.set("B1", "=A1+0"); + t.set("C1", "=VALUE(A1)"); + t.set("D1", "=--\"$ (5)\""); + t.set("E1", "=\"(5)%\"*100"); + t.set("F1", "=\"(-5)\"+0"); + t.set("G1", "$(1,234)"); + assert_eq!((t.num("B1"), t.num("C1"), t.num("D1"), t.num("E1")), (-1234.0, -1234.0, -5.0, -5.0)); + assert_eq!(t.get("F1"), Value::Error(CellError::Value)); + assert_eq!(t.num("G1"), -1234.0); +} + #[test] fn operators() { let mut t = T::new(); diff --git a/crates/core/src/parse.rs b/crates/core/src/parse.rs index b465beaf9..bf8487622 100644 --- a/crates/core/src/parse.rs +++ b/crates/core/src/parse.rs @@ -54,8 +54,13 @@ fn parse_numeric(t: &str, sys: DateSystem) -> Option<(f64, Option<&'static str>) return None; } // Parenthesised negatives: (100) = -100. - if let Some(inner) = t.strip_prefix('(').and_then(|x| x.strip_suffix(')')) { - return parse_numeric(inner.trim(), sys).map(|(n, f)| (-n, f)); + if let Some(inner) = parenthesized(t) { + // Excel reads no sign and no nested parentheses inside: `(-5)`, `($-5)`, `((5))`. + if inner.strip_prefix('$').map_or(inner.as_str(), str::trim_start).starts_with(['-', '+', '(']) { + return None; + } + let r = parse_plain(&inner).or_else(|| parse_fraction(&inner).map(|n| (n, Some("# ?/?")))); + return r.map(|(n, f)| (-n, f)); } if let Some(r) = parse_plain(t) { return Some(r); @@ -69,6 +74,22 @@ fn parse_numeric(t: &str, sys: DateSystem) -> Option<(f64, Option<&'static str>) None } +/// The number inside accounting parentheses, as plain text: `(5)` gives `5`. A dollar sign may +/// stand before them (`$(5)` reads as `($5)`) and a percent sign after them (`(5)%` reads as +/// `(5%)`), with spaces around either. The caller reads only a plain number or a fraction from it. +fn parenthesized(t: &str) -> Option { + let (currency, rest) = match t.strip_prefix('$') { + Some(r) => ("$", r.trim_start()), + None => ("", t), + }; + let rest = rest.strip_prefix('(')?; + let (body, percent) = match rest.strip_suffix(')') { + Some(b) => (b, ""), + None => (rest.strip_suffix('%')?.trim_end().strip_suffix(')')?, "%"), + }; + Some(format!("{currency}{}{percent}", body.trim())) +} + /// Signs, currency, thousands separators, percent, exponent. fn parse_plain(t: &str) -> Option<(f64, Option<&'static str>)> { let mut s = t; @@ -93,7 +114,8 @@ fn parse_plain(t: &str) -> Option<(f64, Option<&'static str>)> { percent = true; s = r.trim_end(); } - if s.is_empty() { + // Excel reads `$5` and `5%` but not `$5%`. + if s.is_empty() || currency && percent { return None; } let has_sep = s.contains(','); @@ -352,6 +374,93 @@ mod tests { assert_eq!(p(".5").value, Value::Number(0.5)); } + /// Observed in Excel 365 (en-US): typed entry, VALUE and `--"text"` agree. + #[test] + fn accounting_parentheses() { + let accepted: [(&str, f64, Option<&str>); 22] = [ + ("$(5)", -5.0, Some("\"$\"#,##0")), + ("$ (5)", -5.0, Some("\"$\"#,##0")), + ("$( 5 )", -5.0, Some("\"$\"#,##0")), + ("$ ( 1,234 )", -1234.0, Some("\"$\"#,##0")), + ("$(1,234)", -1234.0, Some("\"$\"#,##0")), + ("$(1,000.50)", -1000.5, Some("\"$\"#,##0.00")), + ("$(1,234.)", -1234.0, Some("\"$\"#,##0.00")), + ("$(.5)", -0.5, Some("\"$\"#,##0.00")), + ("$(0)", 0.0, Some("\"$\"#,##0")), + (" $(5) ", -5.0, Some("\"$\"#,##0")), + ("($5)", -5.0, Some("\"$\"#,##0")), + ("( $ 1,234 )", -1234.0, Some("\"$\"#,##0")), + ("(5)", -5.0, None), + ("( 5 )", -5.0, None), + ("(1,234.5)", -1234.5, Some("#,##0.00")), + ("(1e-3)", -0.001, Some("0.00E+00")), + ("(5)%", -0.05, Some("0%")), + ("( 5 ) %", -0.05, Some("0%")), + ("(5%)", -0.05, Some("0%")), + ("(5.)%", -0.05, Some("0.00%")), + ("(1,234)%", -12.34, Some("0%")), + ("(1 1/2)", -1.5, Some("# ?/?")), + ]; + for (s, n, f) in accepted { + assert_eq!(p(s), Parsed { value: Value::Number(n), format: f }, "{s}"); + assert_eq!(parse_number_text(s), Some(n), "{s}"); + } + for s in [ + "$5%", + "$(5%)", + "$(5)%", + "($5%)", + "($5)%", + "$ ( 5 ) %", + "(5)%%", + "(5%)%", + "((5))", + "(-5)", + "(+5)", + "($-5)", + "($ -5)", + "($+5)", + "(-$5)", + "$(-5)", + "$ (-5)", + "$(+5)", + "-(5)", + "-$(5)", + "+$(5)", + "$+(5)", + "$$(5)", + "$ $(5)", + "$((5))", + "($(5))", + "(-1 1/2)", + "()", + "$()", + "(5)$", + "$(5)$", + "(5)(5)", + "$(5)0", + "(5)e3", + "(5)-", + "(5-)", + "$(5-)", + "$(5", + "$5)", + "(5", + "$(1,23)", + "$(,5)", + "(5e)", + "(1/2)", + "$(1/2)", + "(3/4/2026)", + "$(3/4/2026)", + "(10:30)", + "$(10:30)", + ] { + assert_eq!(p(s).value, Value::text(s), "{s}"); + assert_eq!(parse_number_text(s), None, "{s}"); + } + } + #[test] fn texts_and_specials() { assert_eq!(p("hello").value, Value::text("hello")); diff --git a/crates/functions/src/text.rs b/crates/functions/src/text.rs index 91962d6e7..84fad9898 100644 --- a/crates/functions/src/text.rs +++ b/crates/functions/src/text.rs @@ -1092,6 +1092,13 @@ mod tests { close(ev("VALUE", vec![t("12:00")]), 0.5); close(ev("VALUE", vec![t("2020-01-01")]), 43831.0); is_err(ev("VALUE", vec![t("abc")]), CellError::Value); + // Accounting negatives, as Excel 365 reads them. + close(ev("VALUE", vec![t("$(5)")]), -5.0); + close(ev("VALUE", vec![t("$ (1,000.50)")]), -1000.5); + close(ev("VALUE", vec![t("(5)%")]), -0.05); + for s in ["$(5)%", "$(5%)", "$5%", "(-5)", "((5))", "$(-5)", "(1/2)"] { + is_err(ev("VALUE", vec![t(s)]), CellError::Value); + } close(ev("NUMBERVALUE", vec![t("2.500,27"), t(","), t(".")]), 2500.27); close(ev("NUMBERVALUE", vec![t("3.5%")]), 0.035); close(ev("NUMBERVALUE", vec![t(" 1 000 ")]), 1000.0); diff --git a/docs/function-parity.md b/docs/function-parity.md index 760ffd6ac..3b25b144f 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-11 · **Last updated:** 2026-10-12 · **Change:** minor (USDOLLAR added as a compatibility function; MIRR, COUNTIF and INDIRECT edge cases match Excel 365) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-14 · **Change:** minor (number text with accounting parentheses coerces like Excel 365) · **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). @@ -108,6 +108,7 @@ workbooks; results checked in as data, not Excel files) and fixing what it finds | Date | Change | Summary | |---|---|---| +| 2026-10-14 | minor | Number text coercion (VALUE, arithmetic, typed entry): a dollar sign before accounting parentheses (`$(1,234)`, `$ (5)`) and a percent sign after them (`(5)%`) read as negatives; `(-5)`, `((5))`, `($-5)`, `$5%`, `$(5)%` and dates or times in parentheses are `#VALUE!`, as observed in Excel 365 | | 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) | | 2026-10-11 | minor | TEXT / number formats fixed against Excel-cached results: fraction literals and zero fractions, `0` denominators zero padded, `?` pads the thousands separator, booleans use the text section (226 mismatches → 0 in 352 TEXT cells) | | 2026-10-11 | minor | Iterative calculation: circular references iterate up to Maximum Iterations / Maximum Change when enabled |