# Conflicts:
#	ROADMAP.md
#	docs/function-parity.md
This commit is contained in:
Brandon Thomas
2026-10-24 02:10:42 -04:00
5 changed files with 147 additions and 18 deletions
+15
View File
@@ -427,6 +427,21 @@ fn lambda_helpers_check_shapes_and_parameters() {
assert_eq!(t.num("B7"), 2.0);
}
#[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();
+120 -16
View File
@@ -61,16 +61,16 @@ pub fn number_stored_as_text(s: &str) -> Option<f64> {
}
fn parse_numeric(t: &str, sys: DateSystem) -> Option<(f64, Option<&'static str>)> {
// Parenthesised negatives: (100) = -100. Each level negates, so ((100)) = 100. Stripped in a
// loop, not by recursion: runtime text (e.g. from REPT) can nest thousands deep (#2180).
let mut t = t;
let mut neg = false;
while let Some(inner) = t.strip_prefix('(').and_then(|x| x.strip_suffix(')')) {
t = inner.trim();
neg = !neg;
// Parenthesised negatives: (100) = -100, and the accounting forms `$(100)` and `(5)%`.
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));
}
let (n, f) = parse_unparenthesised(t, sys)?;
Some((if neg { -n } else { n }, f))
parse_unparenthesised(t, sys)
}
fn parse_unparenthesised(t: &str, sys: DateSystem) -> Option<(f64, Option<&'static str>)> {
@@ -89,6 +89,22 @@ fn parse_unparenthesised(t: &str, sys: DateSystem) -> Option<(f64, Option<&'stat
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<String> {
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;
@@ -113,7 +129,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(',');
@@ -422,20 +439,107 @@ 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 deeply_nested_parentheses_do_not_overflow() {
// #2180: coercion used to recurse once per parenthesis and overflow the stack.
// #2180: coercion used to recurse once per parenthesis and overflow the stack. Excel reads
// one pair only, so nesting is text now, and it is rejected without recursing.
let depth = 100_000;
let s = format!("{}1{}", "(".repeat(depth), ")".repeat(depth));
assert_eq!(parse_number_text(&s), Some(1.0));
assert_eq!(parse_number_text(&s), None);
assert_eq!(p(&s).value, Value::text(s.as_str()));
let odd = format!("{}1{}", "(".repeat(depth + 1), ")".repeat(depth + 1));
assert_eq!(parse_number_text(&odd), Some(-1.0));
assert_eq!(parse_number_text(&odd), None);
let unbalanced = format!("{}1{}", "(".repeat(depth), ")".repeat(depth - 1));
assert_eq!(parse_number_text(&unbalanced), None);
assert_eq!(p(&s).value, Value::Number(1.0));
// Existing results are unchanged.
// A single pair still reads.
assert_eq!(parse_number_text("(100)"), Some(-100.0));
assert_eq!(parse_number_text("((100))"), Some(100.0));
assert_eq!(parse_number_text("( $1,200.50 )"), Some(-1200.5));
assert_eq!(parse_number_text("()"), None);
}
+7
View File
@@ -1211,6 +1211,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);