Preserve array-sensitive XLSX formulas (#64)

This commit is contained in:
Harrison O'Connor-Hoover
2026-10-10 12:19:52 -04:00
parent 1ded850ee6
commit 42854f2fb9
7 changed files with 584 additions and 6 deletions
+217
View File
@@ -0,0 +1,217 @@
//! Export-time implicit-intersection analysis, without evaluating the workbook.
//!
//! A range is harmless in SUM, but not in SQRT or arithmetic. Keep the result's shape separate
//! from affected operations inside it: SUM(range * range) returns a scalar yet needs array
//! evaluation. Legacy array-aware contexts such as SUMPRODUCT already preserve those operations.
use gridcraft_core::CellRef;
use gridcraft_formula::{BinOp, Expr, SheetSel, StructItem, UnOp};
use gridcraft_model::{Formula, Workbook};
#[derive(Clone, Copy, Default)]
struct Shape {
array: bool,
affected: bool,
}
impl Shape {
fn array() -> Self {
Self { array: true, affected: false }
}
}
pub(crate) fn needs_array(wb: &Workbook, sheet: usize, at: CellRef, formula: &Formula) -> bool {
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
}
struct Scan<'a> {
wb: &'a Workbook,
at: CellRef,
remaining: usize,
locals: Vec<(String, Shape)>,
}
impl Scan<'_> {
fn expr(&mut self, e: &Expr, sheet: usize, array_context: bool, depth: usize) -> Shape {
// Names can be cyclic, and a short chain of repeated names can expand exponentially.
// Preserve array evaluation when analysis cannot safely finish.
if depth >= 64 || self.remaining == 0 {
return Shape { array: true, affected: true };
}
self.remaining -= 1;
match e {
Expr::Ref(r) => Shape { array: !r.range().is_single() || matches!(r.sheet, SheetSel::Span(_, _)), affected: false },
Expr::Array(rows) => Shape { array: rows.len() > 1 || rows.first().is_some_and(|r| r.len() > 1), affected: false },
Expr::Name(n) => {
if let Some((_, shape)) = self.locals.iter().rev().find(|(key, _)| key.eq_ignore_ascii_case(n)) {
return *shape;
}
let (scope, bare) = match n.rsplit_once('!') {
Some((s, name)) => (self.wb.sheet_index(&s.trim_matches('\'').replace("''", "'")).unwrap_or(sheet), name),
None => (sheet, n.as_str()),
};
if let Some(def) = self.wb.name(bare, scope) {
return match gridcraft_formula::parse(def.formula.trim_start_matches('=')) {
Ok(expr) => self.expr(&expr, def.scope.unwrap_or(scope), array_context, depth + 1),
Err(_) => Shape::array(),
};
}
Shape { array: self.wb.table(bare).is_some(), affected: false }
}
Expr::Struct(r) => {
let table = if r.table.is_empty() {
self.wb.sheet(sheet).and_then(|s| s.table_at(self.at))
} else {
self.wb.table(&r.table).and_then(|(si, ti)| self.wb.sheet(si).and_then(|s| s.tables.get(ti)))
};
let one_row = r.specifiers.iter().any(|s| matches!(s, StructItem::ThisRow | StructItem::Headers | StructItem::Totals));
let one_col = match (&r.col_start, &r.col_end) {
(Some(a), Some(b)) => a.eq_ignore_ascii_case(b),
(Some(_), None) => true,
_ => table.is_some_and(|t| t.columns.len() == 1),
};
Shape { array: !(one_row && one_col), affected: false }
}
Expr::Paren(x) => self.expr(x, sheet, array_context, depth + 1),
Expr::Unary(op, x) => {
let child = self.expr(x, sheet, array_context, depth + 1);
match op {
UnOp::At => Shape { array: false, affected: child.affected },
UnOp::Spill => Shape { array: true, affected: child.affected },
_ => Shape { array: child.array, affected: child.affected || (child.array && !array_context) },
}
}
Expr::Binary(op, a, b) => {
let a = self.expr(a, sheet, array_context, depth + 1);
let b = self.expr(b, sheet, array_context, depth + 1);
let reference = matches!(op, BinOp::Range | BinOp::Union | BinOp::Intersect);
let array = reference || a.array || b.array;
Shape { array, affected: a.affected || b.affected || (!reference && array && !array_context) }
}
Expr::Call(name, args) => self.call(name, args, sheet, array_context, depth + 1),
Expr::Invoke(_, _) => Shape { array: true, affected: false },
_ => Shape::default(),
}
}
fn call(&mut self, name: &str, args: &[Expr], sheet: usize, array_context: bool, depth: usize) -> Shape {
if name == "LET" && args.len() >= 3 {
let saved = self.locals.len();
let mut affected = false;
for pair in args[..args.len() - 1].chunks_exact(2) {
if let [Expr::Name(n), value] = pair {
let shape = self.expr(value, sheet, array_context, depth);
affected |= shape.affected;
self.locals.push((n.clone(), shape));
}
}
let mut result = args.last().map(|e| self.expr(e, sheet, array_context, depth)).unwrap_or_default();
result.affected |= affected;
self.locals.truncate(saved);
return result;
}
let legacy_array =
matches!(name, "SUMPRODUCT" | "MMULT" | "MINVERSE" | "MDETERM" | "TRANSPOSE" | "FREQUENCY" | "LINEST" | "LOGEST" | "TREND" | "GROWTH");
let mut affected = false;
let mut lifted = false;
let mut shapes = Vec::with_capacity(args.len().min(256));
if args.len() > 255 {
return Shape { array: true, affected: true };
}
for (i, arg) in args.iter().enumerate() {
let context = array_context || legacy_array || (name == "INDEX" && i == 0);
let shape = self.expr(arg, sheet, context, depth);
affected |= shape.affected;
if shape.array && scalar_arg(name, i) {
lifted = true;
affected |= !context;
}
shapes.push(shape);
}
let first_array = shapes.first().is_some_and(|s| s.array);
let array = match name {
"INDIRECT" => match args.first() {
Some(Expr::Text(text)) if !matches!(args.get(1), Some(Expr::Bool(false))) => {
gridcraft_formula::parse(text).map(|e| self.expr(&e, sheet, array_context, depth).array).unwrap_or(true)
}
_ => true,
},
"OFFSET" => {
// Omitted dimensions retain the source reference's shape; unknown dimensions
// might become multiple cells on a later calculation.
if args.len() <= 3 { first_array } else { !args.get(3).is_some_and(one) || args.get(4).map_or(first_array, |e| !one(e)) }
}
"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))
}
"ROW" | "COLUMN" => {
affected |= first_array && !array_context;
first_array
}
"IF" | "IFERROR" | "IFNA" | "IFS" | "SWITCH" | "CHOOSE" => shapes.iter().any(|s| s.array),
"XLOOKUP" => {
lifted
|| match (args.get(1), args.get(2)) {
(Some(Expr::Ref(lookup)), Some(Expr::Ref(result))) => {
if lookup.range().width() == 1 {
result.range().width() > 1
} else {
result.range().height() > 1
}
}
_ => true,
}
}
"FILTER" | "SORT" | "SORTBY" | "UNIQUE" | "SEQUENCE" | "RANDARRAY" | "VSTACK" | "HSTACK" | "TOROW" | "TOCOL" | "WRAPROWS"
| "WRAPCOLS" | "TAKE" | "DROP" | "CHOOSEROWS" | "CHOOSECOLS" | "EXPAND" | "GROUPBY" | "PIVOTBY" | "TRANSPOSE" | "MMULT" | "MINVERSE"
| "FREQUENCY" | "LINEST" | "LOGEST" | "TREND" | "GROWTH" | "MAP" | "SCAN" | "BYROW" | "BYCOL" | "MAKEARRAY" | "ANCHORARRAY" => true,
_ => lifted || self.wb.name(name, sheet).is_some(),
};
Shape { array, affected }
}
}
fn positive(e: &Expr) -> bool {
matches!(e, Expr::Number(n) if *n >= 1.0)
}
fn one(e: &Expr) -> bool {
matches!(e, Expr::Number(n) if *n == 1.0)
}
fn single_column(e: &Expr) -> bool {
matches!(e, Expr::Ref(r) if r.range().width() == 1)
}
/// Legacy reference/array-taking parameters. Other parameters consume a single value.
/// This is file-format policy, not a second evaluator: unknown results are never calculated.
fn scalar_arg(name: &str, i: usize) -> bool {
match name {
"SUM" | "AVERAGE" | "AVERAGEA" | "MIN" | "MINA" | "MAX" | "MAXA" | "COUNT" | "COUNTA" | "COUNTBLANK" | "PRODUCT" | "SUMSQ" | "MEDIAN"
| "MODE" | "MODE.SNGL" | "STDEV" | "STDEV.S" | "STDEV.P" | "STDEVP" | "VAR" | "VAR.S" | "VAR.P" | "VARP" | "AVEDEV" | "DEVSQ" | "GEOMEAN"
| "HARMEAN" | "KURT" | "SKEW" | "SKEW.P" | "AND" | "OR" | "XOR" | "SUMPRODUCT" | "SUMX2MY2" | "SUMX2PY2" | "SUMXMY2" | "CORREL" | "COVAR"
| "COVARIANCE.P" | "COVARIANCE.S" | "PEARSON" | "RSQ" | "SLOPE" | "INTERCEPT" | "STEYX" | "ROWS" | "COLUMNS" | "AREAS" | "ROW" | "COLUMN"
| "TRANSPOSE" | "ISREF" | "MMULT" | "MINVERSE" | "MDETERM" | "FREQUENCY" | "CONCAT" | "VSTACK" | "HSTACK" | "ANCHORARRAY" => false,
"SUMIF" | "AVERAGEIF" => i == 1,
"COUNTIF" => i != 0,
"SUMIFS" | "AVERAGEIFS" | "MINIFS" | "MAXIFS" => i > 0 && i.is_multiple_of(2),
"COUNTIFS" => i % 2 == 1,
"INDEX" | "LARGE" | "SMALL" | "PERCENTILE" | "PERCENTILE.INC" | "PERCENTILE.EXC" | "QUARTILE" | "QUARTILE.INC" | "QUARTILE.EXC" | "IRR"
| "MIRR" | "RANK" | "RANK.EQ" | "RANK.AVG" => i != if name.starts_with("RANK") { 1 } else { 0 },
"VLOOKUP" | "HLOOKUP" | "MATCH" | "XMATCH" => i != 1,
"LOOKUP" | "NPV" | "XNPV" | "SUBTOTAL" => i == 0,
"XLOOKUP" => i != 1 && i != 2,
"XIRR" => i > 1,
"OFFSET" => i != 0,
"AGGREGATE" | "TEXTJOIN" => i < 2,
"FILTER" | "SORTBY" => i > 1,
"SORT" | "UNIQUE" | "TAKE" | "DROP" | "CHOOSECOLS" | "CHOOSEROWS" | "TOROW" | "TOCOL" | "WRAPROWS" | "WRAPCOLS" | "EXPAND" => i != 0,
"LINEST" | "LOGEST" | "TREND" | "GROWTH" => i > 1,
_ => true,
}
}
+1
View File
@@ -10,6 +10,7 @@
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#![forbid(unsafe_code)]
mod array_formula;
mod chart;
mod csv;
mod drawing;
+7 -2
View File
@@ -291,7 +291,11 @@ pub fn write_sheet(wb: &Workbook, si: usize, selected: bool, out: &mut Out) -> (
let cell = sheet.cells.get(*c);
let ov = overrides.get(c);
let ex = extra.get(c);
write_cell(&mut s, *c, cell, ov, ex, dynamic.get(c), &sid, out);
let array = dynamic.get(c).copied().or_else(|| {
let f = cell?.formula.as_ref()?;
(f.array.is_none() && crate::array_formula::needs_array(wb, si, *c, f)).then_some(RangeRef::cell(*c))
});
write_cell(&mut s, *c, cell, ov, ex, array.as_ref(), &sid, out);
}
s.push_str("</row>");
}
@@ -564,7 +568,8 @@ fn write_cell(
Value::Error(CellError::Spill | CellError::Calc) => Value::Error(CellError::Value),
v => v,
};
if formula.is_some() && dynamic.is_some() {
// A non-spilling formula may still need array evaluation to avoid implicit intersection.
if dynamic.is_some() && formula.is_some_and(|f| f.array.is_none()) {
s.push_str(" cm=\"1\"");
out.dynamic_arrays = true;
}
+336
View File
@@ -0,0 +1,336 @@
//! Array evaluation must survive export even when a formula returns one value.
use std::sync::Arc;
use gridcraft_core::{CellRef, RangeRef, Value};
use gridcraft_model::{Cell, DefinedName, Formula, Sheet, Table, TableColumn, TotalsFn, Workbook};
use crate::package::Package;
use crate::xml::El;
use crate::{read_xlsx, write_xlsx};
fn at(s: &str) -> CellRef {
CellRef::parse(s).unwrap()
}
fn range(s: &str) -> RangeRef {
RangeRef::parse(s).unwrap()
}
fn formula(text: &str, value: f64) -> Cell {
let mut cell = Cell::formula(Formula::new(text));
cell.value = Value::Number(value);
cell
}
fn cell<'a>(sheet: &'a El, address: &str) -> &'a El {
sheet.child("sheetData").unwrap().kids("row").flat_map(|r| r.kids("c")).find(|c| c.attr("r") == Some(address)).unwrap()
}
fn assert_scalar(sheet: &El, address: &str) {
let c = cell(sheet, address);
assert_eq!(c.attr("cm"), None, "scalar formula must not have dynamic metadata at {address}");
let f = c.child("f").unwrap();
assert_eq!(f.attr("t"), None);
assert_eq!(f.attr("ref"), None);
}
fn assert_dynamic(sheet: &El, address: &str, reference: &str) {
let c = cell(sheet, address);
assert_eq!(c.attr("cm"), Some("1"), "array evaluation metadata at {address}");
let f = c.child("f").unwrap();
assert_eq!(f.attr("t"), Some("array"));
assert_eq!(f.attr("ref"), Some(reference));
}
#[test]
fn nonspilling_formulas_preserve_array_evaluation_and_metadata() {
let mut wb = Workbook::new();
let data = wb.sheet_mut(0).unwrap();
data.name = "Data".into();
for (address, value) in [("A1", 1.0), ("A2", 4.0), ("A3", 9.0), ("B1", 2.0), ("B2", 3.0), ("B3", 4.0)] {
data.cells.set(at(address), Cell::value(Value::Number(value)));
}
let cases = [("B107", "SUM(Data!A1:A3*Data!B1:B3)", 50.0), ("B108", "SUM(SQRT(Data!A1:A3))", 6.0), ("B109", "SUM(ABS(Data!A1:A3))", 14.0)];
let mut results = Sheet::new("Results");
for (address, text, value) in cases {
results.cells.set(at(address), formula(text, value));
}
results.cells.set(at("C107"), Cell::value(Value::text("neighbor")));
assert!(results.spill_ranges.is_empty());
wb.sheets.push(Arc::new(results));
let bytes = write_xlsx(&wb).unwrap();
let mut pkg = Package::open(&bytes).unwrap();
let sheet = pkg.read_xml("xl/worksheets/sheet2.xml").unwrap().unwrap();
for (address, text, _) in cases {
assert_dynamic(&sheet, address, address);
assert_eq!(cell(&sheet, address).child("f").unwrap().text, text);
}
assert_eq!(cell(&sheet, "C107").attr("cm"), None);
let metadata = pkg.read_xml("xl/metadata.xml").unwrap().unwrap();
let kind = metadata.path(&["metadataTypes", "metadataType"]).unwrap();
assert_eq!(kind.attr("name"), Some("XLDAPR"));
assert_eq!(kind.attr("cellMeta"), Some("1"));
let dynamic = metadata.path(&["futureMetadata", "bk", "extLst", "ext", "dynamicArrayProperties"]).unwrap();
assert_eq!(dynamic.attr("fDynamic"), Some("1"));
assert_eq!(dynamic.attr("fCollapsed"), Some("0"));
let record = metadata.path(&["cellMetadata", "bk", "rc"]).unwrap();
assert_eq!((record.attr("t"), record.attr("v")), (Some("1"), Some("0")));
let types = pkg.read_xml("[Content_Types].xml").unwrap().unwrap();
assert!(types.kids("Override").any(|e| e.attr("PartName") == Some("/xl/metadata.xml")
&& e.attr("ContentType") == Some("application/vnd.openxmlformats-officedocument.spreadsheetml.sheetMetadata+xml")));
assert!(pkg.rels("xl/workbook.xml").unwrap().iter().any(|r| r.kind == "sheetMetadata" && r.target == "xl/metadata.xml"));
let (back, report) = read_xlsx(&bytes).unwrap();
assert!(report.warnings.is_empty(), "{:?}", report.warnings);
let results = back.sheet(1).unwrap();
for (address, text, value) in cases {
let c = results.cell(at(address)).unwrap();
let f = c.formula.as_ref().unwrap();
assert_eq!(f.text, text);
assert_eq!(f.array, None, "dynamic formula must not become legacy CSE");
assert_eq!(c.value, Value::Number(value));
}
assert_eq!(results.value(at("C107")), Value::text("neighbor"));
}
#[test]
fn dynamic_spills_and_legacy_cse_keep_distinct_ranges() {
let mut wb = Workbook::new();
let sh = wb.sheet_mut(0).unwrap();
let mut legacy = Formula::new("ROW(A1:A2)");
legacy.array = Some(range("A1:A2"));
sh.cells.set(at("A1"), Cell::formula(legacy));
sh.cells.set(at("A2"), Cell::value(Value::Number(2.0)));
sh.cells.set(at("B1"), formula("SEQUENCE(3)", 1.0));
sh.spill_ranges.insert(at("B1"), range("B1:B3"));
sh.spill.insert(at("B2"), Value::Number(2.0));
sh.spill.insert(at("B3"), Value::Number(3.0));
sh.cells.set(at("C1"), formula("SUM(B1:B3)", 6.0));
let bytes = write_xlsx(&wb).unwrap();
let mut pkg = Package::open(&bytes).unwrap();
let sheet = pkg.read_xml("xl/worksheets/sheet1.xml").unwrap().unwrap();
assert_eq!(cell(&sheet, "A1").attr("cm"), None);
let legacy = cell(&sheet, "A1").child("f").unwrap();
assert_eq!((legacy.attr("t"), legacy.attr("ref")), (Some("array"), Some("A1:A2")));
assert_dynamic(&sheet, "B1", "B1:B3");
assert_scalar(&sheet, "C1");
assert_eq!(cell(&sheet, "B2").child("v").unwrap().text, "2");
assert_eq!(cell(&sheet, "B2").attr("cm"), None);
let (back, _) = read_xlsx(&bytes).unwrap();
let sh = back.sheet(0).unwrap();
assert_eq!(sh.cell(at("A1")).unwrap().formula.as_ref().unwrap().array, Some(range("A1:A2")));
assert_eq!(sh.value(at("A2")), Value::Number(2.0));
assert_eq!(sh.cell(at("B1")).unwrap().formula.as_ref().unwrap().array, None);
assert!(sh.cell(at("B2")).is_none());
assert!(sh.cell(at("B3")).is_none());
assert_eq!(sh.value(at("C1")), Value::Number(6.0));
}
#[test]
fn scalar_formulas_constants_and_legacy_cse_do_not_require_dynamic_metadata() {
let mut wb = Workbook::new();
let sh = wb.sheet_mut(0).unwrap();
sh.name = "Data".into();
sh.cells.set(at("A1"), Cell::value(Value::Number(3.0)));
for (address, text, value) in [("C1", "1+2", 3.0), ("C2", "Data!A1", 3.0), ("C3", "SUM(A1:A3)", 3.0), ("C4", "A1*2", 6.0)] {
sh.cells.set(at(address), formula(text, value));
}
for legacy in [false, true] {
if legacy {
let mut f = Formula::new("SUM(A1:A2)");
f.array = Some(range("B1"));
wb.sheet_mut(0).unwrap().cells.set(at("B1"), Cell::formula(f));
}
let bytes = write_xlsx(&wb).unwrap();
let mut pkg = Package::open(&bytes).unwrap();
assert!(!pkg.has("xl/metadata.xml"));
assert!(!pkg.rels("xl/workbook.xml").unwrap().iter().any(|r| r.kind == "sheetMetadata"));
let types = pkg.read_xml("[Content_Types].xml").unwrap().unwrap();
assert!(!types.kids("Override").any(|e| e.attr("PartName") == Some("/xl/metadata.xml")));
let sheet = pkg.read_xml("xl/worksheets/sheet1.xml").unwrap().unwrap();
assert_eq!(cell(&sheet, "A1").attr("cm"), None);
for address in ["C1", "C2", "C3", "C4"] {
assert_scalar(&sheet, address);
}
}
}
#[test]
fn calculated_table_column_keeps_formula_and_cell_value() {
let mut wb = Workbook::new();
let sh = wb.sheet_mut(0).unwrap();
sh.cells.set(at("A2"), Cell::value(Value::Number(3.0)));
sh.cells.set(at("B2"), formula("A2*2", 6.0));
sh.tables.push(Table {
id: 1,
name: "Data".into(),
range: range("A1:B2"),
header_row: true,
totals_row: false,
columns: vec![
TableColumn { name: "Input".into(), totals: TotalsFn::None, totals_label: None, formula: None },
TableColumn { name: "Output".into(), totals: TotalsFn::None, totals_label: None, formula: Some("A2*2".into()) },
],
style: "TableStyleLight1".into(),
banded_rows: true,
banded_cols: false,
first_col: false,
last_col: false,
filter_button: true,
});
let bytes = write_xlsx(&wb).unwrap();
let mut pkg = Package::open(&bytes).unwrap();
let sheet = pkg.read_xml("xl/worksheets/sheet1.xml").unwrap().unwrap();
assert_scalar(&sheet, "B2");
assert_eq!(cell(&sheet, "B1").attr("cm"), None, "table headers are text");
let table = pkg.read_xml("xl/tables/table1.xml").unwrap().unwrap();
let column = table.child("tableColumns").unwrap().kids("tableColumn").nth(1).unwrap();
assert_eq!(column.child("calculatedColumnFormula").unwrap().text, "A2*2");
let (back, _) = read_xlsx(&bytes).unwrap();
let sh = back.sheet(0).unwrap();
assert_eq!(sh.tables[0].columns[1].formula.as_deref(), Some("A2*2"));
assert_eq!(sh.cell(at("B2")).unwrap().formula.as_ref().unwrap().array, None);
assert_eq!(sh.value(at("B2")), Value::Number(6.0));
}
/// Compare both cell and formula metadata, collecting all boundaries in the group on failure.
fn check_formula_metadata(mut wb: Workbook, sheet_index: usize, cases: &[(&str, bool)]) {
for (i, (text, _)) in cases.iter().enumerate() {
let f = Formula::new(text);
assert!(f.expr().is_some(), "test formula must parse: {text}");
wb.sheet_mut(sheet_index).unwrap().cells.set(CellRef::new(i as u32 + 100, 5), Cell::formula(f));
}
let bytes = write_xlsx(&wb).unwrap();
let mut pkg = Package::open(&bytes).unwrap();
let sheet = pkg.read_xml(&format!("xl/worksheets/sheet{}.xml", sheet_index + 1)).unwrap().unwrap();
let mut mismatches = vec![];
for (i, (text, dynamic)) in cases.iter().enumerate() {
let address = CellRef::new(i as u32 + 100, 5).to_string();
let c = cell(&sheet, &address);
let f = c.child("f").unwrap();
let expected = if *dynamic { (Some("1"), Some("array"), Some(address.as_str())) } else { (None, None, None) };
let actual = (c.attr("cm"), f.attr("t"), f.attr("ref"));
if actual != expected {
mismatches.push(format!("{text}: expected {expected:?}, got {actual:?}"));
}
}
assert!(mismatches.is_empty(), "{}", mismatches.join("\n"));
}
#[test]
fn implicit_intersection_metadata_respects_function_argument_context() {
check_formula_metadata(
Workbook::new(),
0,
&[
("SQRT(A1:A3)", true),
("ABS(A1:A3)", true),
("SUM(A1:A3*B1:B3)", true),
("SUM(SQRT(A1:A3))", true),
("SUM(A1:A3)", false),
("SUMPRODUCT(A1:A3*B1:B3)", false),
("SUMPRODUCT(SQRT(A1:A3))", false),
("SQRT(@A1:A3)", false),
("@A1:A3*2", false),
("@(A1:A3*2)", true),
("SQRT(INDIRECT(\"A1\"))", false),
("SUM(INDIRECT(\"A1:A3\"))", false),
("SQRT(INDIRECT(\"A1:A3\"))", true),
("SUM(INDIRECT(A1))", false),
("SQRT(INDIRECT(A1))", true),
("INDEX(A1:B3,1,1)", false),
("INDEX(A1:A3,1)", false),
("SUM(INDEX(A1:B3,0,1))", false),
("SQRT(INDEX(A1:B3,0,1))", true),
("SUM(ROW(A1:A3))", true),
("SUMPRODUCT(ROW(A1:A3))", false),
("OFFSET(A1,0,0,1,1)*2", false),
("SUM(OFFSET(A1,0,0,3,1)*2)", true),
("SUM(OFFSET(A1,0,0,3,1))", false),
("LET(x,A1:A3,SUM(x))", false),
("LET(x,A1:A3,SUM(x*2))", true),
("XLOOKUP(1,A1:A3,B1:B3)", false),
("XLOOKUP(1,A1:A3,B1:C3)", true),
],
);
}
#[test]
fn implicit_intersection_metadata_resolves_names_in_formula_sheet_scope() {
let mut wb = Workbook::new();
wb.sheets.push(Arc::new(Sheet::new("Results")));
for (name, scope, text) in [
("Rate", None, "2"),
("Inputs", None, "Sheet1!$A$1:$A$3"),
("Single", None, "Sheet1!$A$1"),
("Alias", None, "Inputs"),
("Scoped", None, "Sheet1!$A$1:$A$3"),
("Scoped", Some(0), "2"),
("LocalInputs", Some(1), "Results!$A$1:$A$3"),
] {
wb.names.push(DefinedName { name: name.into(), scope, formula: text.into(), comment: String::new(), hidden: false });
}
check_formula_metadata(
wb.clone(),
0,
&[
("Rate", false),
("SQRT(Rate)", false),
("Single*2", false),
("Inputs", true),
("SUM(Inputs)", false),
("SUM(Inputs*2)", true),
("SUM(SQRT(Alias))", true),
("SQRT(Scoped)", false),
],
);
check_formula_metadata(wb, 1, &[("SQRT(Scoped)", true), ("SUM(LocalInputs)", false), ("LocalInputs*2", true)]);
}
#[test]
fn cyclic_names_preserve_array_evaluation_without_unbounded_export_recursion() {
let mut wb = Workbook::new();
for (name, formula) in [("First", "Second"), ("Second", "First")] {
wb.names.push(DefinedName { name: name.into(), scope: None, formula: formula.into(), comment: String::new(), hidden: false });
}
check_formula_metadata(wb, 0, &[("First", true), ("SUM(First)", true)]);
}
#[test]
fn implicit_intersection_metadata_distinguishes_table_rows_and_columns() {
let mut wb = Workbook::new();
let sh = wb.sheet_mut(0).unwrap();
sh.tables.push(Table {
id: 1,
name: "Data".into(),
range: range("A1:B3"),
header_row: true,
totals_row: false,
columns: vec![
TableColumn { name: "Input".into(), totals: TotalsFn::None, totals_label: None, formula: None },
TableColumn { name: "Output".into(), totals: TotalsFn::None, totals_label: None, formula: Some("[@Input]*2".into()) },
],
style: "TableStyleLight1".into(),
banded_rows: true,
banded_cols: false,
first_col: false,
last_col: false,
filter_button: true,
});
sh.cells.set(at("A2"), Cell::value(Value::Number(3.0)));
sh.cells.set(at("B2"), formula("[@Input]*2", 6.0));
let bytes = write_xlsx(&wb).unwrap();
let mut pkg = Package::open(&bytes).unwrap();
let sheet = pkg.read_xml("xl/worksheets/sheet1.xml").unwrap().unwrap();
assert_scalar(&sheet, "B2");
check_formula_metadata(
wb,
0,
&[("Data[@Input]*2", false), ("SUM(Data[Input])", false), ("SUM(Data[Input]*2)", true), ("SQRT(Data[Input])", true)],
);
}
+1
View File
@@ -1,5 +1,6 @@
//! Integration-style tests: round trips, hand-written fixtures, hostile input and CSV.
mod array_export;
mod csv_tests;
mod fixtures;
mod malformed;
+11 -4
View File
@@ -7,6 +7,7 @@ use std::sync::Arc;
use gridcraft_core::{CellError, CellRef, DateSystem, RangeRef, Value};
use gridcraft_model::*;
use crate::package::Package;
use crate::{read_xlsx, write_xlsx};
fn at(a: &str) -> CellRef {
@@ -581,9 +582,15 @@ fn spill_and_calc_errors_are_cached_as_value_errors() {
s.cells.set(at("C1"), Cell { value: Value::Error(CellError::Spill), ..Cell::formula(Formula::new("A1:A3*2")) });
s.cells.set(at("C2"), Cell { value: Value::Error(CellError::Calc), ..Cell::formula(Formula::new("FILTER(A1:A3,A1:A3>5)")) });
s.cells.set(at("C3"), Cell { value: Value::Error(CellError::NA), ..Cell::formula(Formula::new("NA()")) });
let (xml, _) = sheet1_xml(&write_xlsx(&wb).unwrap());
let bytes = write_xlsx(&wb).unwrap();
let (xml, _) = sheet1_xml(&bytes);
assert!(!xml.contains("#SPILL!") && !xml.contains("#CALC!"), "{xml}");
assert!(xml.contains(r#"<f>A1:A3*2</f><v>#VALUE!</v></c>"#), "{xml}");
assert!(xml.contains(r#"<f>_xlfn._xlws.FILTER(A1:A3,A1:A3&gt;5)</f><v>#VALUE!</v></c>"#), "{xml}");
assert!(xml.contains(r#"<c r="C3" t="e"><f>NA()</f><v>#N/A</v></c>"#), "{xml}");
let mut pkg = Package::open(&bytes).unwrap();
let sheet = pkg.read_xml("xl/worksheets/sheet1.xml").unwrap().unwrap();
for (address, text, value) in [("C1", "A1:A3*2", "#VALUE!"), ("C2", "_xlfn._xlws.FILTER(A1:A3,A1:A3>5)", "#VALUE!"), ("C3", "NA()", "#N/A")] {
let cell = sheet.child("sheetData").unwrap().kids("row").flat_map(|row| row.kids("c")).find(|cell| cell.attr("r") == Some(address)).unwrap();
assert_eq!(cell.attr("t"), Some("e"));
assert_eq!(cell.child("f").unwrap().text, text);
assert_eq!(cell.child("v").unwrap().text, value);
}
}