diff --git a/crates/formula/src/parser.rs b/crates/formula/src/parser.rs index 754e986..ee2354e 100644 --- a/crates/formula/src/parser.rs +++ b/crates/formula/src/parser.rs @@ -236,6 +236,13 @@ impl Parser { { return Ok(Expr::Unary(UnOp::Spill, Box::new(r))); } + // Files write `@x` as `_xlfn.SINGLE(x)`. + if name == "SINGLE" + && args.len() == 1 + && let Some(x) = args.pop() + { + return Ok(Expr::Unary(UnOp::At, Box::new(x))); + } Ok(Expr::Call(name, args)) } Tok::Sheet(a, b) => { diff --git a/crates/xlsx/src/array_formula.rs b/crates/xlsx/src/array_formula.rs index 2512cc0..01c9d92 100644 --- a/crates/xlsx/src/array_formula.rs +++ b/crates/xlsx/src/array_formula.rs @@ -24,7 +24,10 @@ pub(crate) fn needs_array(wb: &Workbook, sheet: usize, at: CellRef, formula: &Fo let Some(expr) = formula.expr_arc() else { return false }; let mut scan = Scan { wb, at, remaining: 4096, locals: Vec::new() }; let shape = scan.expr(&expr, sheet, false, 0); - shape.array || shape.affected + // An explicit `@` is written as `_xlfn.SINGLE`, which only dynamic-array formulas carry. + let mut at = false; + expr.walk(&mut |e| at |= matches!(e, Expr::Unary(UnOp::At, _))); + shape.array || shape.affected || at } struct Scan<'a> { diff --git a/crates/xlsx/src/fmla.rs b/crates/xlsx/src/fmla.rs index 018cbf2..9ac4ccf 100644 --- a/crates/xlsx/src/fmla.rs +++ b/crates/xlsx/src/fmla.rs @@ -78,6 +78,8 @@ fn expr_to_file(e: Expr) -> String { }, // Files have no `#` operator: Excel writes `A1#` as `_xlfn.ANCHORARRAY(A1)`. Expr::Unary(gridcraft_formula::UnOp::Spill, r) => Expr::Call("_xlfn.ANCHORARRAY".into(), vec![*r]), + // Nor an `@` operator: Excel writes `@x` as `_xlfn.SINGLE(x)`. + Expr::Unary(gridcraft_formula::UnOp::At, x) => Expr::Call("_xlfn.SINGLE".into(), vec![*x]), other => other, }); gridcraft_formula::print(&e) @@ -115,6 +117,15 @@ mod tests { assert_eq!(from_file("SUM(_xlfn.ANCHORARRAY(Sheet1!$D$1))+_xlfn.ANCHORARRAY(B2)"), "SUM(Sheet1!$D$1#)+B2#"); } + #[test] + fn implicit_intersection() { + assert_eq!(to_file(&Formula::new("SUM(@A1:A3*B1:B3)")), "SUM(_xlfn.SINGLE(A1:A3)*B1:B3)"); + assert_eq!(to_file(&Formula::new("@A1:A3*2")), "_xlfn.SINGLE(A1:A3)*2"); + assert_eq!(text_to_file("=@Sheet1!$A$1:$A$3"), "_xlfn.SINGLE(Sheet1!$A$1:$A$3)"); + assert_eq!(from_file("_xlfn.SINGLE(A1:A3)*2"), "@A1:A3*2"); + assert_eq!(from_file("SUM(_xlfn.SINGLE(A1:A3)*B1:B3)"), "SUM(@A1:A3*B1:B3)"); + } + #[test] fn prefixes() { assert_eq!(to_file(&Formula::new("XLOOKUP(1,A:A,B:B)+SUM(A1)")), "_xlfn.XLOOKUP(1,A:A,B:B)+SUM(A1)"); diff --git a/crates/xlsx/src/tests/array_export.rs b/crates/xlsx/src/tests/array_export.rs index d92f473..4bed1e5 100644 --- a/crates/xlsx/src/tests/array_export.rs +++ b/crates/xlsx/src/tests/array_export.rs @@ -236,8 +236,10 @@ fn implicit_intersection_metadata_respects_function_argument_context() { ("SUM(A1:A3)", false), ("SUMPRODUCT(A1:A3*B1:B3)", false), ("SUMPRODUCT(SQRT(A1:A3))", false), - ("SQRT(@A1:A3)", false), - ("@A1:A3*2", false), + // An explicit `@` is written as `_xlfn.SINGLE`, a dynamic-array formula. Dropping it + // instead would need the reader to put it back on legacy formulas. + ("SQRT(@A1:A3)", true), + ("@A1:A3*2", true), ("@(A1:A3*2)", true), ("SQRT(INDIRECT(\"A1\"))", false), ("SUM(INDIRECT(\"A1:A3\"))", false), diff --git a/crates/xlsx/src/tests/roundtrip.rs b/crates/xlsx/src/tests/roundtrip.rs index 12bba62..7360820 100644 --- a/crates/xlsx/src/tests/roundtrip.rs +++ b/crates/xlsx/src/tests/roundtrip.rs @@ -600,3 +600,26 @@ fn spill_and_calc_errors_are_cached_as_value_errors() { assert_eq!(cell.child("v").unwrap().text, value); } } + +/// Files have no `@` operator: Excel writes `@x` as `_xlfn.SINGLE(x)` in a dynamic-array +/// formula, and reads it back as `@x`. +#[test] +fn implicit_intersection_is_written_as_single() { + let mut wb = Workbook::new(); + let s = wb.sheet_mut(0).unwrap(); + for (r, n) in [(0, 1.0), (1, 3.0), (2, 5.0)] { + s.set_value(CellRef::new(r, 0), Value::Number(n)); + } + s.cells.set(at("D3"), Cell { value: Value::Number(10.0), ..Cell::formula(Formula::new("@A1:A3*2")) }); + s.cells.set(at("D4"), Cell { value: Value::Number(2.0), ..Cell::formula(Formula::new("A1*2")) }); + let bytes = write_xlsx(&wb).unwrap(); + let (xml, metadata) = sheet1_xml(&bytes); + assert!(xml.contains("_xlfn.SINGLE(A1:A3)*210"), "{xml}"); + assert!(xml.contains("A1*2"), "{xml}"); + assert!(!xml.contains('@'), "{xml}"); + assert!(metadata); + let (back, _) = read_xlsx(&bytes).unwrap(); + let sh = back.sheet(0).unwrap(); + assert_eq!(sh.cell(at("D3")).and_then(|c| c.formula.as_ref()).map(|f| f.text.as_str()), Some("@A1:A3*2")); + assert_eq!(sh.cell(at("D3")).and_then(|c| c.formula.as_ref()).and_then(|f| f.array), None); +}