mirror of
https://github.com/storytold/gridcraft.git
synced 2026-10-25 11:16:49 -06:00
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>
88 lines
4.9 KiB
Rust
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));
|
|
});
|
|
}
|