From ea190adad6a1edb792521a7818ef426570a8229c Mon Sep 17 00:00:00 2001 From: Harrison Booth Date: Wed, 14 Oct 2026 19:55:46 -0500 Subject: [PATCH] functions: Analysis ToolPak value arguments reject multi-cell references EOMONTH, EDATE, WORKDAY(.INTL), NETWORKDAYS(.INTL), YEARFRAC, WEEKNUM, the coupon, bond and T-bill functions, DISC, BIN2DEC, IM* and the other Analysis ToolPak functions give #VALUE! in Excel 365 for a reference to more than one cell in a value argument, in dynamic-array and legacy formulas alike, where GridCraft lifted the range and spilled. Array constants and computed arrays still lift, and range arguments (holidays, cash flows, schedules) and the .INTL weekend still take ranges. FnSpec gains a value_args mask, filled at registration from one table in lift.rs; call_lifted turns a multi-cell reference at a value position into a scalar #VALUE! before lifting, so EOMONTH(A1:A3,{1;2}) is a 2x1 array of #VALUE! as in Excel. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 4 +- crates/calc/src/tests.rs | 85 +++++++++++++++++++++++++++++++++++ crates/functions/src/lib.rs | 18 ++++++++ crates/functions/src/lift.rs | 87 ++++++++++++++++++++++++++++++++++++ docs/function-parity.md | 4 +- 5 files changed, 196 insertions(+), 2 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 070552d07..2abac7262 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 (Analysis ToolPak functions reject multi-cell references in value arguments) · **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 | Functions: EOMONTH, EDATE, WORKDAY, NETWORKDAYS, YEARFRAC, WEEKNUM, the bond and coupon functions, DISC, BIN2DEC, IM* and the other Analysis ToolPak functions give `#VALUE!` for a reference to more than one cell in a value argument, as Excel 365 does, instead of spilling; array constants and computed arrays still lift | | 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: Analysis ToolPak value arguments reject multi-cell references | | 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..a9317528b 100644 --- a/crates/calc/src/tests.rs +++ b/crates/calc/src/tests.rs @@ -1266,3 +1266,88 @@ fn offset_extreme_arguments_return_ref() { assert_eq!(crate::evaluate(&wb, 0, c("D1"), formula), Value::Number(expected), "{formula}"); } } + +#[test] +fn analysis_toolpak_value_params_reject_multi_cell_references() { + // Excel 365 (observed): EOMONTH, WORKDAY, COUPDAYS, BIN2DEC... give #VALUE! for a reference to more + // than one cell, however the reference is formed, and lift array constants and computed arrays. + let mut t = T::new(); + for (a, v) in [ + ("A1", "45000"), + ("A2", "45031"), + ("A3", "45061"), + ("B1", "1"), + ("B2", "2"), + ("B3", "3"), + ("C2", "7"), + ("F1", "1"), + ("F2", "2"), + ("F3", "11"), + ] { + t.set(a, v); + } + t.wb.names.push(gridcraft_model::DefinedName { + name: "Dates".into(), + scope: None, + formula: "Sheet1!$A$1:$A$3".into(), + comment: String::new(), + hidden: false, + }); + let value = Value::Error(CellError::Value); + for f in [ + "=EOMONTH(A1:A3,1)", + "=EOMONTH(A1,B1:B3)", + "=EDATE(A1:A3,1)", + "=SUMPRODUCT(EOMONTH(A1:A3,0))", + "=EOMONTH(INDEX(A1:A3,0,1),0)", + "=EOMONTH(OFFSET(A1,0,0,3,1),0)", + "=EOMONTH(IF(TRUE,A1:A3),0)", + "=EOMONTH(CHOOSE(1,A1:A3),0)", + "=LET(x,A1:A3,EOMONTH(x,0))", + "=EOMONTH(Dates,0)", + "=EOMONTH((A1,A2),0)", + "=EOMONTH(Z:Z,0)", + "=EOMONTH(A1:INDEX(A1:A3,3),0)", + "=WORKDAY(A1:A3,1)", + "=NETWORKDAYS(A1,A3:A4)", + "=YEARFRAC(A1,A3,B1:B2)", + "=WEEKNUM(A1,B1:B3)", + "=COUPDAYS(A1:A3,46000,2,0)", + "=DISC(A1,46000,98,100,B1:B3)", + "=BIN2DEC(B1:B3)", + "=ISEVEN(B1:B3)", + ] { + t.set("H5", f); + assert_eq!((t.get("H5"), t.get("H6")), (value.clone(), Value::Empty), "{f}"); + t.set("H5", ""); + } + // One-cell references, intersections, @ and the range parameters are fine. + for (f, want) in [ + ("=EOMONTH(A1:A1,0)", 45016.0), + ("=EOMONTH(INDEX(A1:A3,2),0)", 45046.0), + ("=EOMONTH(OFFSET(A1,0,0,1,1),0)", 45016.0), + ("=EOMONTH(A1:A3 A2:C2,0)", 45046.0), + ("=EOMONTH(@A1:A3,0)", 45046.0), + ("=WORKDAY(A1,1,A1:A3)", 45001.0), + ("=XNPV(0,B1:B3,A1:A3)", 6.0), + ] { + t.set("H2", f); + assert_eq!(t.num("H2"), want, "{f}"); + t.set("H2", ""); + } + // Arrays that aren't references lift; so does the WORKDAY.INTL/NETWORKDAYS.INTL weekend. + for (f, below, right) in [ + ("=EOMONTH(A1:A3+0,0)", Value::Number(45046.0), Value::Empty), + ("=EOMONTH(A1,{1,2})", Value::Empty, Value::Number(45077.0)), + ("=EOMONTH(TRANSPOSE(A1:A3),0)", Value::Empty, Value::Number(45046.0)), + ("=MAP(A1:A3,LAMBDA(x,EOMONTH(x,0)))", Value::Number(45046.0), Value::Empty), + ("=NETWORKDAYS.INTL(A1,A3+30,F1:F3)", Value::Number(66.0), Value::Empty), + ("=ISOWEEKNUM(A1:A3)", Value::Number(15.0), Value::Empty), + // The rejected reference adds no shape: a 2x1 array of #VALUE!. + ("=EOMONTH(A1:A3,{1;2})", value.clone(), Value::Empty), + ] { + t.set("H5", f); + assert_eq!((t.get("H6"), t.get("I5")), (below, right), "{f}"); + t.set("H5", ""); + } +} diff --git a/crates/functions/src/lib.rs b/crates/functions/src/lib.rs index 148d68221..5f9dcb67d 100644 --- a/crates/functions/src/lib.rs +++ b/crates/functions/src/lib.rs @@ -30,6 +30,7 @@ macro_rules! f { category: $crate::Category::$cat, volatile: true, scalar_args: $scal, + value_args: &[], signature: $sig, description: $desc, imp: { @@ -48,6 +49,7 @@ macro_rules! f { category: $crate::Category::$cat, volatile: false, scalar_args: $scal, + value_args: &[], signature: $sig, description: $desc, imp: { @@ -147,6 +149,11 @@ pub struct FnSpec { /// (broadcast like Excel's dynamic arrays). The last entry repeats for variadic tails; an /// empty slice means no lifting. pub scalar_args: &'static [bool], + /// Parameter positions that take a single value and reject a reference to more than one + /// cell with `#VALUE!` (the Analysis ToolPak functions: EOMONTH, WORKDAY, COUPDAYS, BIN2DEC…), + /// where other scalar parameters lift the range. Arrays that aren't references still lift. + /// Same layout as `scalar_args`; filled at registration from [`lift::value_args`]. + pub value_args: &'static [bool], pub signature: &'static str, pub description: &'static str, pub imp: FnImpl, @@ -167,6 +174,14 @@ impl FnSpec { } } + /// Whether parameter `i` takes a single value, so a multi-cell reference there is `#VALUE!`. + pub fn is_value_arg(&self, i: usize) -> bool { + match self.value_args.get(i) { + Some(b) => *b, + None => self.value_args.last().copied().unwrap_or(false), + } + } + /// Whether a call with `n` arguments is valid (Excel allows at most 255). pub fn accepts(&self, n: usize) -> bool { (self.min_args..=self.max_args).contains(&n) && n <= 255 @@ -195,6 +210,9 @@ fn registry() -> &'static (Vec, HashMap<&'static str, usize>) { ] { v.extend(list); } + for s in &mut v { + s.value_args = lift::value_args(s.name); + } let mut map = HashMap::with_capacity(v.len()); for (i, s) in v.iter().enumerate() { map.entry(s.name).or_insert(i); diff --git a/crates/functions/src/lift.rs b/crates/functions/src/lift.rs index cea0067ee..056cb6d31 100644 --- a/crates/functions/src/lift.rs +++ b/crates/functions/src/lift.rs @@ -6,7 +6,61 @@ use gridcraft_core::{Array, CellError, Value}; use crate::util::MAX_CELLS; use crate::{Arg, Ctx, FnSpec}; +/// Value-parameter masks (see [`FnSpec::value_args`]), observed in Excel 365: a reference to more +/// than one cell at these positions gives `#VALUE!` in dynamic-array and legacy formulas alike (no +/// implicit intersection), while array constants and computed arrays lift. Range parameters +/// (holidays, schedules, cash flows, coefficients) and the WORKDAY.INTL/NETWORKDAYS.INTL weekend +/// (which lifts a range) are `false`. +pub(crate) const VALUE_ARGS: &[(&str, &[bool])] = &[ + // Date and time. + ("EOMONTH EDATE YEARFRAC WEEKNUM", &[true]), + ("WORKDAY NETWORKDAYS", &[true, true, false]), + ("WORKDAY.INTL NETWORKDAYS.INTL", &[true, true, false, false]), + // Financial. + ( + "ACCRINT ACCRINTM AMORDEGRC AMORLINC COUPDAYBS COUPDAYS COUPDAYSNC COUPNCD COUPNUM COUPPCD CUMIPMT CUMPRINC DISC \ + DOLLARDE DOLLARFR DURATION EFFECT INTRATE MDURATION NOMINAL ODDFPRICE ODDFYIELD ODDLPRICE ODDLYIELD PRICE PRICEDISC \ + PRICEMAT RECEIVED TBILLEQ TBILLPRICE TBILLYIELD YIELD YIELDDISC YIELDMAT", + &[true], + ), + ("FVSCHEDULE XNPV", &[true, false]), + ("XIRR", &[false, false, true]), + // Math and information. + ("ISEVEN ISODD QUOTIENT MROUND FACTDOUBLE SQRTPI RANDBETWEEN", &[true]), + ("SERIESSUM", &[true, true, true, false]), + // Engineering (IMSUM and IMPRODUCT aggregate, so they aren't here). + ( + "BIN2DEC BIN2HEX BIN2OCT DEC2BIN DEC2HEX DEC2OCT HEX2BIN HEX2DEC HEX2OCT OCT2BIN OCT2DEC OCT2HEX CONVERT DELTA GESTEP \ + ERF ERF.PRECISE ERFC ERFC.PRECISE BESSELI BESSELJ BESSELK BESSELY COMPLEX IMABS IMAGINARY IMARGUMENT IMCONJUGATE \ + IMCOS IMCOSH IMCOT IMCSC IMCSCH IMDIV IMEXP IMLN IMLOG10 IMLOG2 IMPOWER IMREAL IMSEC IMSECH IMSIN IMSINH IMSQRT IMSUB \ + IMTAN", + &[true], + ), +]; + +/// The value-parameter mask of `name` (empty for functions without value parameters). +pub(crate) fn value_args(name: &str) -> &'static [bool] { + VALUE_ARGS.iter().find(|(names, _)| names.split_ascii_whitespace().any(|n| n == name)).map_or(&[], |(_, mask)| mask) +} + +/// A reference to more than one cell at a value parameter becomes a scalar `#VALUE!`, so it adds +/// no shape: `EOMONTH(A1:A3, {1;2})` is a 2x1 array of `#VALUE!`, as in Excel. +fn reject_multi_cell_refs(spec: &FnSpec, args: &[Arg]) -> Option> { + let multi = |i: usize, a: &Arg| a.from_ref && spec.is_value_arg(i) && matches!(&a.value, Value::Array(arr) if arr.data.len() > 1); + if !args.iter().enumerate().any(|(i, a)| multi(i, a)) { + return None; + } + Some( + args.iter() + .enumerate() + .map(|(i, a)| if multi(i, a) { Arg { value: Value::Error(CellError::Value), from_ref: false } } else { a.clone() }) + .collect(), + ) +} + pub(crate) fn call_lifted(spec: &FnSpec, args: &[Arg], ctx: &mut dyn Ctx) -> Value { + let rejected = if spec.value_args.is_empty() { None } else { reject_multi_cell_refs(spec, args) }; + let args = rejected.as_deref().unwrap_or(args); let mut rows = 0usize; let mut cols = 0usize; let mut any = false; @@ -94,4 +148,37 @@ mod tests { ); is_err(huge, CellError::Num); } + + #[test] + fn value_params_reject_multi_cell_references() { + let s = FnSpec { value_args: &[true, false], ..spec() }; + let mut ctx = TestCtx::default(); + // A multi-cell reference is #VALUE!; a one-cell reference and a computed array are fine. + is_err(crate::call(&s, &[rf(col(&[1.0, 2.0, 3.0])), n(1.0)], &mut ctx), CellError::Value); + assert_eq!(crate::call(&s, &[rf(col(&[4.0])), n(1.0)], &mut ctx), nv(5.0)); + assert_eq!(rows_of(&crate::call(&s, &[av(col(&[1.0, 2.0])), n(1.0)], &mut ctx)), vec![vec![nv(2.0)], vec![nv(3.0)]]); + // The rejected reference adds no shape: the other argument still lifts. + let v = Value::Error(CellError::Value); + assert_eq!(rows_of(&crate::call(&s, &[rf(col(&[1.0, 2.0, 3.0])), av(col(&[1.0, 2.0]))], &mut ctx)), vec![vec![v.clone()], vec![v]]); + // A position that isn't a value parameter still lifts the reference. + assert_eq!(rows_of(&crate::call(&s, &[n(1.0), rf(row(&[1.0, 2.0]))], &mut ctx)), vec![vec![nv(2.0), nv(3.0)]]); + } + + #[test] + fn value_arg_table_names_registered_scalar_positions() { + for (names, mask) in super::VALUE_ARGS { + assert!(!mask.is_empty()); + for name in names.split_ascii_whitespace() { + let Some(s) = crate::lookup(name) else { panic!("{name} isn't registered") }; + assert_eq!(s.value_args, *mask, "{name}"); + for i in 0..s.max_args.min(12) { + assert!(!s.is_value_arg(i) || s.is_scalar_arg(i), "{name} argument {i} is a value parameter but doesn't lift"); + } + } + } + assert!(crate::lookup("WORKDAY").is_some_and(|s| s.is_value_arg(1) && !s.is_value_arg(2))); + assert!(crate::lookup("WORKDAY.INTL").is_some_and(|s| !s.is_value_arg(2) && s.is_scalar_arg(2))); + assert!(crate::lookup("SUM").is_some_and(|s| s.value_args.is_empty())); + assert!(crate::lookup("ISOWEEKNUM").is_some_and(|s| s.value_args.is_empty())); + } } diff --git a/docs/function-parity.md b/docs/function-parity.md index 760ffd6ac..91283721d 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 (Analysis ToolPak functions give #VALUE! for a multi-cell reference in a value argument, 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). @@ -77,6 +77,7 @@ PIVOTBY, REGEXTEST…) are all present. | Defined names (workbook and sheet scope), Create from Selection | done | | Dynamic arrays, spill, `#SPILL!`, spill operator `A1#` | done; stale-spill bugs (#133, #139; PRs #148, #157) | | Implicit intersection `@`, legacy CSE array formulas | done; legacy array range fill (PR #156) | +| Array lifting: an array in a scalar argument evaluates the function per element | done. The Analysis ToolPak functions (EOMONTH, EDATE, WORKDAY(.INTL), NETWORKDAYS(.INTL), YEARFRAC, WEEKNUM, the COUP*, PRICE*, YIELD*, ODD*, TBILL*, ACCRINT*, AMOR*, DISC, DURATION, CUMIPMT/CUMPRINC, EFFECT, NOMINAL, DOLLARDE/FR, XNPV/XIRR rate and guess, FVSCHEDULE principal, ISEVEN/ISODD, QUOTIENT, MROUND, FACTDOUBLE, SQRTPI, RANDBETWEEN, SERIESSUM, the base conversions, CONVERT, DELTA, GESTEP, ERF/ERFC, BESSEL*, COMPLEX and IM* except IMSUM/IMPRODUCT) give `#VALUE!` for a reference to more than one cell in a value argument, as Excel 365 does in both dynamic-array and legacy formulas; array constants and computed arrays (`A1:A3+0`) still lift, and range arguments (holidays, schedule, cash flows) and the .INTL weekend accept ranges. Difference: BYROW/BYCOL pass their row or column to the LAMBDA as values, so `BYROW(A1:B3,LAMBDA(r,EOMONTH(r,0)))` lifts where Excel gives `#VALUE!` | | Trim references `A1:.E10` (trailing), `A1.:E10` (leading), `A1.:.E10` (both), also on whole rows/columns (`A:.A`) | done; evaluated as `TRIMRANGE(range, code, code)`, adjusted on insert/delete; files store `_xlfn.TRIMRANGE(range,code,code)` (Excel's own file spelling unconfirmed in public docs), `_xlfn._TRO_ALL/_TRO_LEADING/_TRO_TRAILING(...)` read too | | LET, LAMBDA, named LAMBDAs, ISOMITTED, MAP/REDUCE/SCAN/BYROW/BYCOL/MAKEARRAY | done | | Iterative calculation for circular references | done: with Enable Iterative Calculation on (`formulas.calculationOptions` `{iterative, maxIterations, maxChange}`, saved as ``), loops are evaluated again from their previous values (blank = 0) until no cell on a loop changes by Maximum Change or Maximum Iterations is reached; off, cycles give `#CIRC!`. Differences: no Options dialog yet (command only); GridCraft recalculates on open, so a loop that never settles (`=A1+1`) advances again each time the file is opened | @@ -108,6 +109,7 @@ workbooks; results checked in as data, not Excel files) and fixing what it finds | Date | Change | Summary | |---|---|---| +| 2026-10-14 | minor | Analysis ToolPak value arguments (EOMONTH, WORKDAY, YEARFRAC, COUPDAYS, DISC, BIN2DEC, IM* and the rest, observed in Excel 365) give `#VALUE!` for a multi-cell reference instead of lifting it | | 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 |