Files
gridcraft/crates/engine/tests/long-operator-chains.rs
T
Harrison BoothandClaude Opus 5.5 2aeb21d4d3 Formulas: flat operator chains as long as Excel allows (#916)
along: =1+1+... with 4,096 operands (8,192 characters, exactly Excel's
limit, which Excel accepts and calculates) overflowed the stack when
entered, because the derived Clone of Expr recursed once per operand
down the chain's left spine. Saving hit the same in the XLSX writer.

- Expr: Clone, PartialEq, walk and map follow the left spine in a loop
  and recurse only into right operands and real nesting.
- XLSX: _xlpm. prefixing and the implicit-intersection scan follow the
  spine too. The scan's depth limit of 64 wrote any chain of more than
  about 64 operands as a dynamic-array formula; such sums are plain
  formulas again (ranges in the chain still get array evaluation).
- Evaluate Formula borrows sub-expressions instead of cloning each
  operator's subtree (quadratic memory on a long chain).
- Spanish formula entry and display no longer refuse chains of more
  than 256 operands; the left spine does not count as nesting.

Tests run on a 1 MiB stack (the Windows main thread) with Excel's
observed results: formula (clone/eq/walk/map/adjust at 4,096), calc
(401 and 4,096 operands, -, *, /, ^, &, %, comparisons, 1,500 refs),
xlsx (long chains written as scalar formulas), engine (cell.set, fill,
copy/paste, insert rows/columns, undo/redo, Evaluate Formula, XLSX save
and reopen), Spanish entry. Each fails without the fix.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 18:54:30 -05:00

88 lines
4.9 KiB
Rust

//! Flat operator chains as long as Excel allows (#916). `=1+1+…` with 4,096 operands is
//! exactly 8,192 characters, Excel's formula limit, and builds a tree 4,095 levels deep on its
//! left. Entering, editing, saving and reopening such a formula must neither overflow the stack
//! nor fall back to an error: Excel computes it.
use gridcraft_core::{CellRef, Value};
use gridcraft_engine::{DocState, Session, io};
use serde_json::json;
/// Excel's longest flat chain of literals: 4,096 operands, 8,192 characters with the `=`.
fn literal_chain(op: &str) -> String {
format!("={}", vec!["1"; 4096].join(op))
}
/// `=A1+A2+…+A1500`: 7,893 characters, inside Excel's limit.
fn reference_chain() -> String {
format!("={}", (1..=1500).map(|r| format!("A{r}")).collect::<Vec<_>>().join("+"))
}
fn value(session: &Session, cell: &str) -> Value {
let at = CellRef::parse(cell).expect("cell");
session.doc().expect("doc").wb.sheet(0).expect("sheet").value(at)
}
/// Runs `f` on a 1 MiB stack, the size of the Windows main thread the desktop app runs on.
fn on_small_stack(f: impl FnOnce() + Send + 'static) {
std::thread::Builder::new().stack_size(1 << 20).spawn(f).expect("spawn").join().expect("long chains must not overflow the stack");
}
#[test]
fn longest_flat_chains_survive_editing_saving_and_reopening() {
on_small_stack(|| {
let mut session = Session::new();
session.new_workbook();
session.execute("range.setValues", json!({"range": "A1:A1500", "values": vec![[1]; 1500]})).expect("values");
session.execute("cell.set", json!({"cell": "C1", "input": literal_chain("+")})).expect("plus chain");
session.execute("cell.set", json!({"cell": "C2", "input": literal_chain("-")})).expect("minus chain");
session.execute("cell.set", json!({"cell": "C3", "input": literal_chain("&")})).expect("concat chain");
session.execute("cell.set", json!({"cell": "C4", "input": reference_chain()})).expect("reference chain");
assert_eq!(value(&session, "C1"), Value::Number(4096.0));
assert_eq!(value(&session, "C2"), Value::Number(-4094.0));
assert_eq!(value(&session, "C3"), Value::text("1".repeat(4096)));
assert_eq!(value(&session, "C4"), Value::Number(1500.0));
// Precedents of a long reference chain still drive recalculation.
session.execute("cell.set", json!({"cell": "A750", "input": "101"})).expect("edit precedent");
assert_eq!(value(&session, "C4"), Value::Number(1600.0));
// Evaluate Formula walks every operator of the chain.
let steps = session.execute("formulas.evaluateFormula", json!({"cell": "C4"})).expect("evaluate formula");
assert_eq!(steps["steps"].as_array().map(Vec::len), Some(200));
assert_eq!(steps["result"], json!(1600.0));
assert_eq!(session.execute("formulas.evaluate", json!({"formula": literal_chain("+")})).expect("evaluate"), json!(4096.0));
// Copy and paste move every reference (Excel: the pasted C4 sums A2:A1501).
session.execute("edit.copy", json!({"range": "C4"})).expect("copy");
session.execute("edit.paste", json!({"at": "C5"})).expect("paste");
assert_eq!(value(&session, "C5"), Value::Number(1599.0));
session.execute("edit.undo", json!({})).expect("undo paste");
// Fill and structural edits rewrite every reference in the chain (Excel: an inserted
// row above shifts A1:A1500 to A2:A1501; the sum is unchanged).
session.execute("edit.fillRight", json!({"range": "C1:D4"})).expect("fill right");
assert_eq!(value(&session, "D1"), Value::Number(4096.0));
assert_eq!(value(&session, "D4"), Value::Number(0.0));
session.execute("home.insertRows", json!({"rows": "1:1"})).expect("insert row");
assert_eq!(value(&session, "C5"), Value::Number(1600.0));
session.execute("home.insertColumns", json!({"cols": "A:A"})).expect("insert column");
assert_eq!(value(&session, "D5"), Value::Number(1600.0));
session.execute("edit.undo", json!({})).expect("undo");
session.execute("edit.undo", json!({})).expect("undo");
assert_eq!(value(&session, "C4"), Value::Number(1600.0));
session.execute("edit.redo", json!({})).expect("redo");
assert_eq!(value(&session, "C5"), Value::Number(1600.0));
// XLSX round trip keeps the formulas and their values.
let wb = &session.doc().expect("doc").wb;
let bytes = io::save_bytes(wb, "chains.xlsx").expect("save");
let (reopened, _) = io::open_bytes("chains.xlsx", &bytes).expect("open");
let mut session = Session::new();
session.add_document(DocState::new(reopened, None, "chains".into()));
session.execute("formulas.calculateNow", json!({})).expect("recalc");
assert_eq!(value(&session, "C2"), Value::Number(4096.0));
assert_eq!(value(&session, "C3"), Value::Number(-4094.0));
assert_eq!(value(&session, "C5"), Value::Number(1600.0));
});
}