From 6db94d154a14697cb8a0d3b85c77f8a5c4c33efb Mon Sep 17 00:00:00 2001 From: Harrison Booth Date: Thu, 15 Oct 2026 05:19:32 -0500 Subject: [PATCH] Calc: INDEX with one index on a 2-D reference is #REF!, as in Excel INDEX(A1:C3,2) returned the whole second row. Excel gives #REF! when a reference has several rows and columns and the column argument is omitted, so the error also carries into ranges built from it (A1:INDEX(A1:C3,2)), position arrays (INDEX(A1:C3,{1;2})) and outer functions. An empty column argument (INDEX(A1:C3,2,)) still gives the row, and arrays are unchanged: INDEX({1,2;3,4},2) is the row. Observed in Excel that a column array with one index, INDEX({10;20;30},2), is a 1x1 array (TYPE 64), which is what calc already returned; the new test pins it. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 4 ++- crates/calc/src/eval.rs | 8 ++++- crates/calc/src/tests.rs | 75 ++++++++++++++++++++++++++++++++++++++++ docs/function-parity.md | 3 +- 4 files changed, 87 insertions(+), 3 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 070552d07..3292b595f 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-15 · **Change:** minor (INDEX with one index on a 2-D reference is #REF! as in Excel) · **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-15 | Calc: `INDEX(A1:C3,2)` (one index on a reference of several rows and columns) is `#REF!` as in Excel, instead of the whole row; `INDEX(A1:C3,2,)` still gives the row | | 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-15 | minor | Progress log: INDEX with one index on a 2-D reference is #REF! | | 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/eval.rs b/crates/calc/src/eval.rs index 0f9cfb03d..1f58c50be 100644 --- a/crates/calc/src/eval.rs +++ b/crates/calc/src/eval.rs @@ -1251,7 +1251,13 @@ impl<'h> Evaluator<'h> { let area = usize::try_from((area_n as u64).saturating_sub(1)).ok(); let Some(a) = area.and_then(|i| areas.get(i)).copied() else { return err(CellError::Ref) }; let r = a.range; - // A single row or column with one index picks along it. + // A single row or column with one index picks along it. An area with several rows + // and columns needs both: `INDEX(A1:C3,2)` is #REF! in Excel (an empty third + // argument, `INDEX(A1:C3,2,)`, still means the whole row). Arrays differ: + // `INDEX({1,2;3,4},2)` is the row. + if two_args && r.height() > 1 && r.width() > 1 { + return err(CellError::Ref); + } let (row, col) = if two_args && r.height() == 1 { (1, row) } else { (row, col) }; if row as u64 > r.height() as u64 || col as u64 > r.width() as u64 { return err(CellError::Ref); diff --git a/crates/calc/src/tests.rs b/crates/calc/src/tests.rs index 196c57de7..5abedec60 100644 --- a/crates/calc/src/tests.rs +++ b/crates/calc/src/tests.rs @@ -172,6 +172,81 @@ fn index_area_number_overflow() { } } +/// INDEX with one index, as observed in Excel: a 2-D reference needs both indexes (#REF!), a +/// column array gives a 1×1 array (TYPE 64), a row array or 1-D reference gives one value. +#[test] +fn index_with_one_index() { + let mut t = T::new(); + for (i, v) in [10, 20, 30].iter().enumerate() { + t.set(&format!("A{}", i + 1), &v.to_string()); + } + let mut k = 1; + for r in 5..=7 { + for col in ["A", "B", "C"] { + t.set(&format!("{col}{r}"), &k.to_string()); + k += 1; + } + } + for (row, (formula, expected)) in [ + // Column arrays keep Excel's 1×1 array; row arrays and explicit columns give a value. + ("=INDEX({10;20;30},2)", 20.0), + ("=TYPE(INDEX({10;20;30},2))", 64.0), + ("=TYPE(INDEX(SEQUENCE(3),2))", 64.0), + ("=TYPE(INDEX({10;20;30},2,1))", 1.0), + ("=TYPE(INDEX({10,20,30},2))", 1.0), + ("=TYPE(INDEX(INDEX({10;20;30},2),1))", 1.0), + ("=SUM(INDEX({10;20;30},2))", 20.0), + // 1-D references with one index are single-cell references. + ("=TYPE(INDEX(A1:A3,2))", 1.0), + ("=SUM(INDEX(A1:A3,1):INDEX(A1:A3,3))", 60.0), + ("=SUM(INDEX((A1:A3,A5:C7),2))", 20.0), + // A 2-D array with one index is the row. + ("=SUM(INDEX({1,2;3,4},2))", 7.0), + ("=SUM(INDEX({1,2;3,4},0))", 10.0), + // A 2-D reference with an empty (not omitted) column argument is the row. + ("=SUM(INDEX(A5:C7,2,))", 15.0), + ("=SUM(INDEX(A5:C7,2,,1))", 15.0), + ("=COLUMNS(INDEX((A1:A3,A5:C7),2,,2))", 3.0), + ("=INDEX(A5:C7,2,2)", 5.0), + // A 2-D reference with one index is #REF!, and the error carries through. + ("=TYPE(INDEX(A5:C7,2))", 16.0), + ("=ISREF(INDEX(A5:C7,2))", 0.0), + ] + .iter() + .enumerate() + { + let at = format!("J{}", row * 4 + 1); + t.set(&at, formula); + let got = match t.get(&at) { + Value::Bool(b) => f64::from(u8::from(b)), + v => v.as_f64().unwrap_or_else(|| panic!("{formula} = {v:?}")), + }; + assert_eq!(got, *expected, "{formula}"); + } + for formula in [ + "=INDEX(A5:C7,2)", + "=INDEX(A5:C7,0)", + "=INDEX(A5:C7,1.9)", + "=INDEX(A5:C7,)", + "=INDEX(A5:B6,1)", + "=INDEX(A5:C7,2)+1", + "=SUM(INDEX(A5:C7,{1;2}))", + "=ROWS(INDEX(INDEX(A5:C7,2),3))", + "=SUM(A5:INDEX(A5:C7,2))", + "=SUM(INDEX(A5:C7,2):C7)", + ] { + t.set("L1", formula); + assert_eq!(t.get("L1"), Value::Error(CellError::Ref), "{formula}"); + } + t.set("L1", "=INDEX(A5:C7,{1;2})"); + assert_eq!([t.get("L1"), t.get("L2")], [Value::Error(CellError::Ref), Value::Error(CellError::Ref)]); + t.set("L1", "=IFERROR(INDEX(A5:C7,2),\"e\")"); + assert_eq!(t.get("L1"), Value::from("e")); + // A bad position is still #VALUE!. + t.set("L1", "=INDEX(A5:C7,\"x\")"); + assert_eq!(t.get("L1"), Value::Error(CellError::Value)); +} + #[test] fn errors_and_cycles() { let mut t = T::new(); diff --git a/docs/function-parity.md b/docs/function-parity.md index 760ffd6ac..12bef8ee9 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-15 · **Change:** minor (INDEX with one index on a reference of several rows and columns is #REF! as in Excel) · **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-15 | minor | INDEX with only a row index on a reference of several rows and columns (`INDEX(A1:C3,2)`, `INDEX(A1:C3,0)`) is `#REF!`, as observed in Excel, so it also fails as a range endpoint and inside other functions; an empty column argument (`INDEX(A1:C3,2,)`) still gives the row. Checked that a column array with one index stays a 1×1 array (`TYPE` 64), which is what Excel returns | | 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 |