diff --git a/ROADMAP.md b/ROADMAP.md index 1faf680ca..4261b0676 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-22 · **Change:** trivial (progress log: formula hardening, #2163/#2162; previously minor: Page Setup paper sizes: JIS B4/B5/B6, the Japanese postcards and envelopes, and A6; long flat operator chains up to Excel's formula limit) · **Target:** Microsoft Excel (Microsoft 365) +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-23 · **Change:** minor (INDEX with one index on a 2-D reference is #REF! as in Excel; earlier: Page Setup paper sizes: JIS B4/B5/B6, the Japanese postcards and envelopes, and A6; long flat operator chains up to Excel's formula limit) · **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. @@ -135,6 +135,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | What landed | |---|---| +| 2026-10-23 | 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-22 | Formula hardening: digits too many for a finite number are text, so `"1 <400 digits>/<400 digits>"` no longer coerces to NaN; a NaN column index in VLOOKUP, HLOOKUP and the D-functions is `#VALUE!` instead of an index underflow; REGEXREPLACE stops building a result as soon as it can only be too long (a worst case went from 1 GB to 7 MB) (#2163, #2162) | | 2026-10-21 | Page Setup paper sizes match Japanese Excel: `B4`/`B5`/`B6` are the JIS sizes (ISO B4/B5/B6 stay reachable by name), so a Japanese B5 worksheet prints at 182×257 mm; Japanese Postcard (100×148 mm), Double Postcard (148×200 mm), Envelope #3/#4 (120×235/90×205 mm), Kaku #2/#3 (240×332/216×277 mm) and A6 are added, both sizes reach the PDF page (`file.exportPdf`/`file.print` MediaBox) and the size survives an XLSX round-trip; the Page Layout Size menu lists them. `B4`/`B5` used to be the ISO sizes, which is changed here | | 2026-10-20 | Japanese interface catalog covers every key the Brazilian Portuguese catalog translates: the 398-label shared core plus 545 dialog, message, Backstage and menu strings (943 total, ~43% of ~2,200). A test parses `pt.rs`'s source, so the two catalogs stay in step | @@ -221,6 +222,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | Change | Summary | |---|---|---| +| 2026-10-23 | minor | Progress log: INDEX with one index on a 2-D reference is #REF! | | 2026-10-22 | trivial | Progress log: formula hardening (#2163, #2162) | | 2026-10-21 | minor | Page Setup paper sizes: JIS B4/B5/B6 (ISO by name), Japanese postcards and envelopes, A6; PDF page size follows, XLSX round-trips | | 2026-10-20 | minor | Dutch (nl) listed under other shipped languages; progress log row | diff --git a/crates/calc/src/eval.rs b/crates/calc/src/eval.rs index 06586879b..7b7d6dad1 100644 --- a/crates/calc/src/eval.rs +++ b/crates/calc/src/eval.rs @@ -1301,7 +1301,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 622ed6cc9..fcea2c257 100644 --- a/crates/calc/src/tests.rs +++ b/crates/calc/src/tests.rs @@ -191,6 +191,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 042e0287d..a97c39eb7 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-15 · **Last updated:** 2026-10-21 · **Change:** minor (SUBTOTAL and AGGREGATE cap the cells they visit, #2175) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-23 · **Change:** minor (INDEX with one index on a 2-D reference is #REF! as in Excel; earlier: SUBTOTAL and AGGREGATE cap the cells they visit, #2175) · **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). @@ -122,6 +122,7 @@ workbooks; results checked in as data, not Excel files) and fixing what it finds | Date | Change | Summary | |---|---|---| +| 2026-10-23 | 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-21 | minor | SUBTOTAL and AGGREGATE charge every argument against the evaluator's 16-million-cell limit before scanning; a value in XFD1048576 no longer makes `SUBTOTAL(9,A:XFD)` read the whole sheet (#2175) | | 2026-10-19 | minor | LET and LAMBDA names compare case-insensitively in any script, as in Excel: `=LET(dług,100,DŁUG*2)` is 200 (was #NAME?); also LET-bound lambdas called or passed by name (`żar(2)`, `MAP(…,Λ)`), `ISOMITTED`, and formula-bar alias translation (one shared `gridcraft_formula::same_name`, which matches `ß` with `ẞ` and keeps dotless `ı` apart from `i`) | | 2026-10-19 | minor | Long flat operator chains (#916): 4,096 operands (Excel's 8,192-character limit) no longer overflow the stack when entered, filled, adjusted or saved; chains past 64 operands are no longer saved as dynamic-array formulas; Spanish entry no longer refuses chains past 256 operands |