From b930d60bc3918477b311c0e0a23b9d3047bac682 Mon Sep 17 00:00:00 2001 From: justinjohn0306 Date: Thu, 15 Oct 2026 18:32:00 +0530 Subject: [PATCH] #1505: keep scalar INDEX MATCH formulas scalar --- crates/xlsx/src/array_formula.rs | 9 +++++- crates/xlsx/src/tests/array_export.rs | 46 +++++++++++++++++++++++++++ docs/file-format-parity.md | 6 +++- 3 files changed, 59 insertions(+), 2 deletions(-) diff --git a/crates/xlsx/src/array_formula.rs b/crates/xlsx/src/array_formula.rs index caa5e8e9f..2001b0492 100644 --- a/crates/xlsx/src/array_formula.rs +++ b/crates/xlsx/src/array_formula.rs @@ -154,7 +154,9 @@ impl Scan<'_> { "INDEX" => { let row = args.get(1); let col = args.get(2); - !row.is_some_and(positive) || col.is_some_and(|e| !positive(e)) || (col.is_none() && !args.first().is_some_and(single_column)) + let row_positive = row.is_some_and(|e| positive_index(e, shapes.get(1).copied())); + let col_positive = col.is_some_and(|e| positive_index(e, shapes.get(2).copied())); + !row_positive || col.is_some_and(|_| !col_positive) || (col.is_none() && !args.first().is_some_and(single_column)) } "ROW" | "COLUMN" => { affected |= first_array && !array_context; @@ -187,6 +189,11 @@ fn positive(e: &Expr) -> bool { matches!(e, Expr::Number(n) if *n >= 1.0) } +fn positive_index(e: &Expr, shape: Option) -> bool { + // MATCH returns a one-based position or an error, never INDEX's zero "whole row" selector. + positive(e) || (matches!(e, Expr::Call(name, _) if matches!(name.as_str(), "MATCH" | "XMATCH")) && shape.is_some_and(|s| !s.array)) +} + fn one(e: &Expr) -> bool { matches!(e, Expr::Number(n) if *n == 1.0) } diff --git a/crates/xlsx/src/tests/array_export.rs b/crates/xlsx/src/tests/array_export.rs index b90b98668..7042e14a3 100644 --- a/crates/xlsx/src/tests/array_export.rs +++ b/crates/xlsx/src/tests/array_export.rs @@ -161,6 +161,47 @@ fn scalar_formulas_constants_and_legacy_cse_do_not_require_dynamic_metadata() { } } +#[test] +fn legacy_index_match_formulas_remain_scalar_after_xlsx_roundtrip() { + let source = super::minimal( + r#"a0.1cxINDEX($B$1:$B$3,MATCH(D1,$A$1:$A$3,0))0.7IF(E1="","",INDEX($B$1:$B$3,MATCH(D1,$A$1:$A$3,0)))0.7VLOOKUP(D1,$A$1:$B$3,2,FALSE)0.7SUM($B$1:$B$3)1.2MATCH(D1,$A$1:$A$3,0)3b0.4IFERROR(INDEX($B$1:$B$3,MATCH(D1,$A$1:$A$3,0)),0)0.7c0.7"#, + &[], + "", + "", + ); + let (opened, _) = read_xlsx(&source).unwrap(); + let saved = write_xlsx(&opened).unwrap(); + let mut pkg = Package::open(&saved).unwrap(); + let sheet = pkg.read_xml("xl/worksheets/sheet1.xml").unwrap().unwrap(); + + for address in ["F1", "F2", "F3", "F5", "F6", "F7"] { + assert_scalar(&sheet, address); + } +} + +#[test] +fn shared_legacy_index_match_formulas_remain_scalar_after_xlsx_roundtrip() { + let mut body = String::from(""); + for row in 16..=39 { + let formula = if row == 16 { + format!(r#"IF(E{row}="","",IFERROR(INDEX($B$1:$B$3,MATCH(D{row},$A$1:$A$3,0)),0))"#) + } else { + r#""#.to_string() + }; + body.push_str(&format!(r#"{formula}0.7"#)); + } + body.push_str(""); + let source = super::minimal(&body, &[], "", ""); + let (opened, _) = read_xlsx(&source).unwrap(); + let saved = write_xlsx(&opened).unwrap(); + let mut pkg = Package::open(&saved).unwrap(); + let sheet = pkg.read_xml("xl/worksheets/sheet1.xml").unwrap().unwrap(); + + for row in 16..=39 { + assert_scalar(&sheet, &format!("F{row}")); + } +} + #[test] fn calculated_table_column_keeps_formula_and_cell_value() { let mut wb = Workbook::new(); @@ -304,6 +345,11 @@ fn implicit_intersection_metadata_respects_function_argument_context() { ("SQRT(INDIRECT(A1))", true), ("INDEX(A1:B3,1,1)", false), ("INDEX(A1:A3,1)", false), + ("INDEX(A1:A3,MATCH(D1,A1:A3,0))", false), + ("IFERROR(INDEX(A1:A3,MATCH(D1,A1:A3,0)),0)", false), + ("IF(E1=\"\",\"\",INDEX(A1:A3,MATCH(D1,A1:A3,0)))", false), + ("INDEX(A1:B3,MATCH(D1,A1:A3,0),MATCH(E1,A1:B1,0))", false), + ("INDEX(A1:B3,MATCH(D1,A1:A3,0))", true), ("SUM(INDEX(A1:B3,0,1))", false), ("SQRT(INDEX(A1:B3,0,1))", true), ("SUM(ROW(A1:A3))", true), diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 988472414..ebb6ad612 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** trivial (XLSX chart sheets are kept on save (#727); previously minor: CSV save keeps the separator it was opened with) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (legacy XLSX `INDEX`/`MATCH` formulas stay scalar on save (#1505)) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 Every format Excel opens or saves, with GridCraft's read and write support, fidelity and tests. Part of [target-app-parity.md](target-app-parity.md). **File formats: ~50% ready, 80–120 h** @@ -11,6 +11,9 @@ documentation (the Office bundle is off limits under this repo's clean-room rule support comes from `crates/engine/src/io.rs` (`FileKind`), `crates/xlsx/src` and the tests in `crates/xlsx/src/tests/` (round-trip, fixtures, malformed input, CSV, ODS, XLSB, XLS, PivotTables). +Legacy scalar `INDEX(range, MATCH(...))` formulas remain ordinary formulas on save, including when +shared formulas are expanded during import (`array_export.rs`, #1505). + ## Workbook formats Ink created with Draw is written as standard, unfilled DrawingML polylines, without visible point @@ -85,6 +88,7 @@ refresh. Counted under features (Get & Transform, ~10% ready), not here. | Date | Change | Summary | |---|---|---| +| 2026-10-15 | minor | XLSX save keeps legacy `INDEX`/`MATCH` formulas scalar instead of marking them as dynamic arrays (#1505) | | 2026-10-12 | trivial | XLSX chart sheets are kept on save (#727) | | 2026-10-12 | trivial | CSV and TSV export is capped at 512 MiB and refuses a larger used range with an error (#528) | | 2026-10-12 | minor | `.xls` import reads the calculation settings (CalcMode, CalcIter, CalcCount, CalcDelta, CalcPrecision), so circular models calculate instead of showing `#CIRC!` (#724) |