From 0a27d4a5d4e9f472561c81e667a93da9826b246c Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Mon, 12 Oct 2026 19:04:17 +0200 Subject: [PATCH] Functions: MIRR, COUNTIF, INDIRECT and USDOLLAR match Excel 365 (fixes #738) --- crates/calc/src/eval.rs | 8 +++++++- crates/calc/src/tests.rs | 16 ++++++++++++++++ crates/functions/src/criteria.rs | 8 ++++++-- crates/functions/src/financial.rs | 5 ++++- crates/functions/src/text.rs | 3 +++ docs/function-parity.md | 5 ++++- 6 files changed, 40 insertions(+), 5 deletions(-) diff --git a/crates/calc/src/eval.rs b/crates/calc/src/eval.rs index 92a0a39..753e03d 100644 --- a/crates/calc/src/eval.rs +++ b/crates/calc/src/eval.rs @@ -1081,8 +1081,14 @@ impl<'h> Evaluator<'h> { v.scalar().to_bool().unwrap_or(true) } }; - let text = if a1 { text } else { r1c1_to_a1(&text, self.at).unwrap_or(text) }; + // With a1 = FALSE, text that isn't R1C1 can still be a name, but never an A1 reference. + let (text, a1_ok) = match (a1, r1c1_to_a1(&text, self.at)) { + (true, _) => (text, true), + (false, Some(t)) => (t, true), + (false, None) => (text, false), + }; match gridcraft_formula::parse(&text) { + Ok(Expr::Ref(_)) if !a1_ok => err(CellError::Ref), Ok(e @ (Expr::Ref(_) | Expr::Name(_) | Expr::Struct(_))) => match self.eval(&e) { r @ Ev::R(_) => r, _ => err(CellError::Ref), diff --git a/crates/calc/src/tests.rs b/crates/calc/src/tests.rs index 5133409..ada11fb 100644 --- a/crates/calc/src/tests.rs +++ b/crates/calc/src/tests.rs @@ -403,6 +403,22 @@ fn references_functions() { assert_eq!(t.num("B3"), 50.0); t.set("B4", "=INDIRECT(\"A\"&4)"); assert_eq!(t.num("B4"), 40.0); + // With a1 = FALSE the text must be R1C1: A1-style text is #REF!, not read as A1. + t.set("C1", "=INDIRECT(\"R3C1\",FALSE)"); + assert_eq!(t.num("C1"), 30.0); + t.set("C2", "=INDIRECT(\"A3\",FALSE)"); + assert_eq!(t.get("C2"), Value::Error(CellError::Ref)); + // Defined names don't depend on the reference style. + t.wb.names.push(gridcraft_model::DefinedName { + name: "Third".into(), + scope: None, + formula: "Sheet1!$A$3".into(), + comment: String::new(), + hidden: false, + }); + t.calc.rebuild(&t.wb); + t.set("C3", "=INDIRECT(\"Third\",FALSE)"); + assert_eq!(t.num("C3"), 30.0); t.set("B5", "=SUM(A1:INDEX(A1:A4,3))"); assert_eq!(t.num("B5"), 60.0); t.set("B6", "=SUM(A:A)"); diff --git a/crates/functions/src/criteria.rs b/crates/functions/src/criteria.rs index 20410c8..85039f4 100644 --- a/crates/functions/src/criteria.rs +++ b/crates/functions/src/criteria.rs @@ -98,9 +98,10 @@ impl Criterion { match self.op { Op::Eq => blank, Op::Ne => !blank, - // "<" / ">" with nothing: Excel compares against "" for text cells. + // "<" / ">" with nothing: Excel compares against "" for text cells, but an + // empty string never matches ("<=" and ">=" count it in neither). _ => match &v { - Value::Text(t) => cmp_op(self.op, compare_text(t, "")), + Value::Text(t) if !t.is_empty() => cmp_op(self.op, compare_text(t, "")), _ => false, }, } @@ -290,6 +291,9 @@ mod tests { assert!(m("<>", Value::from("a"))); assert!(!m("<>", Value::Empty)); assert!(m("TRUE", Value::Bool(true))); + // A bare "<=" or ">=" never matches an empty string (Excel 365: COUNTIF(="", "<=") is 0). + assert!(!m("<=", Value::from(""))); + assert!(!m(">=", Value::from(""))); assert!(m(" R { neg += x / (1.0 + fr).powi(i as i32); } } - if pos == 0.0 || neg == 0.0 { + // No positive flow is not an error in Excel: the formula below gives -1. + if neg == 0.0 { return Err(CellError::Div0); } let r = (-pos * (1.0 + rr).powf(n - 1.0) / neg).powf(1.0 / (n - 1.0)) - 1.0; @@ -1813,6 +1814,8 @@ mod tests { close_tol(ev("MIRR", vec![rf(m), n(0.1), n(0.12)]), 0.1260943, 1e-6); close_tol(ev("MIRR", vec![rf(col(&[-120000.0, 39000.0, 30000.0, 21000.0])), n(0.1), n(0.12)]), -0.048044655, 1e-7); is_err(ev("MIRR", vec![rf(col(&[1.0, 2.0])), n(0.1), n(0.1)]), CellError::Div0); + // Excel 365 gives -1 when every flow is negative: the positive flows are worth nothing. + close_tol(ev("MIRR", vec![rf(col(&[-10000.0, -3400.0, -6500.0, -1000.0])), n(0.07), n(0.01)]), -1.0, 1e-12); close(ev("FVSCHEDULE", vec![n(1.0), av(row(&[0.09, 0.11, 0.1]))]), 1.33089); close(ev("EFFECT", vec![n(0.0525), n(4.0)]), 0.0535426673707584); is_err(ev("EFFECT", vec![n(-0.1), n(4.0)]), CellError::Num); diff --git a/crates/functions/src/text.rs b/crates/functions/src/text.rs index 723cd4a..985ca08 100644 --- a/crates/functions/src/text.rs +++ b/crates/functions/src/text.rs @@ -841,6 +841,7 @@ pub(crate) fn specs() -> Vec { fixed ), f!("DOLLAR", 1, 2, Text, S, "DOLLAR(number, [decimals])", "Formats a number as currency text.", dollar), + f!("USDOLLAR", 1, 2, Compatibility, S, "USDOLLAR(number, [decimals])", "Formats a number as currency text, like DOLLAR.", dollar), f!("BAHTTEXT", 1, 1, Text, S, "BAHTTEXT(number)", "Spells out a number in Thai words with Baht.", bahttext), f!("T", 1, 1, Text, S, "T(value)", "Returns the value if it is text, otherwise empty text.", t_fn), f!( @@ -1091,6 +1092,8 @@ mod tests { is_text(ev("DOLLAR", vec![n(1234.567)]), "$1,234.57"); is_text(ev("DOLLAR", vec![n(-1234.567), n(1.0)]), "($1,234.6)"); is_text(ev("DOLLAR", vec![n(0.5), n(0.0)]), "$1"); + is_text(ev("USDOLLAR", vec![n(1.0)]), "$1.00"); + is_text(ev("USDOLLAR", vec![n(-1234.567), n(1.0)]), "($1,234.6)"); is_text(ev("T", vec![t("x")]), "x"); is_text(ev("T", vec![n(1.0)]), ""); } diff --git a/docs/function-parity.md b/docs/function-parity.md index 6dbced2..760ffd6 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-11 · **Change:** minor (TEXT and number formats: fraction literals, `?` thousands padding, booleans through the text section) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **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 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). @@ -20,6 +20,8 @@ Excel's worksheet functions, category by category, against GridCraft's function evaluator's special forms in `crates/calc/src/eval.rs` (`SPECIAL_FUNCTIONS`: IF, IFS, CHOOSE, OFFSET, INDIRECT, INDEX, SUBTOTAL, AGGREGATE, TEXT, LET, LAMBDA, MAP, REDUCE, SCAN, BYROW, BYCOL, MAKEARRAY…), excluding the test-only `ADD2`. Every one is an Excel name. +- **Not on Microsoft's list:** USDOLLAR, a legacy function Excel still evaluates (like DOLLAR), + is in our library as a Compatibility function for files that use it. It is not counted above. - **Working** excludes stubs that return an error by design: the 7 CUBE functions (`#N/A`, no cube connections) and WEBSERVICE and FILTERXML (`#VALUE!`). Excel for Mac also lacks WEBSERVICE and FILTERXML; Excel for Windows has them. @@ -106,6 +108,7 @@ workbooks; results checked in as data, not Excel files) and fixing what it finds | Date | Change | Summary | |---|---|---| +| 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 | | 2026-10-11 | minor | Trim-reference operators (`:.`, `.:`, `.:.`) and unbracketed `[[#This Row],Col]` done; dropped the stale "Scope of what's missing" rows for TRIMRANGE, PERCENTOF, BAHTTEXT and the trim references |