From 92adfea27ce388f668f227f473288f8a6597dee2 Mon Sep 17 00:00:00 2001
From: Alexander Alber <90604107+alexanderalber@users.noreply.github.com>
Date: Sun, 11 Oct 2026 10:04:09 +0200
Subject: [PATCH] XLSX: write `@` as `_xlfn.SINGLE` in a dynamic-array formula
(#64)
The file format has no `@` operator, but formulas with an explicit
implicit intersection were written with a literal `@`
(`@A1:A3*2`). Excel writes `@x` as `_xlfn.SINGLE(x)`, which only
dynamic-array formulas carry. The writer now does the same and marks such
formulas cm="1"; the parser reads `SINGLE(x)` back as `@x`, as it already
does for `ANCHORARRAY` and `#`.
Two cases in the #83 metadata test expected a redundant `@` (`SQRT(@A1:A3)`,
`@A1:A3*2`) to be written as an ordinary formula. Dropping the `@` there is
only lossless if the reader puts it back on legacy formulas, which it
doesn't yet (the read side of #64), so they now expect cm.
generated with Claude Code
---
crates/formula/src/parser.rs | 7 +++++++
crates/xlsx/src/array_formula.rs | 5 ++++-
crates/xlsx/src/fmla.rs | 11 +++++++++++
crates/xlsx/src/tests/array_export.rs | 6 ++++--
crates/xlsx/src/tests/roundtrip.rs | 23 +++++++++++++++++++++++
5 files changed, 49 insertions(+), 3 deletions(-)
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);
+}