fix(table): clamp formula range walks to the table

Bug:
evaluate_table_formula walked for row in r1.min(r2)..=r1.max(r2) across
columns with no bound, and table_cell_reference parses any usize -
=SUM(A1:A18446744073709551615) names about 1.8e19 rows. table.cell()
returns None outside the table, so results were correct; the loop just
never finished. The walk runs during tessellation, i.e. on the render
path - the app freezes permanently and cannot even be closed.

Reproduce:
1. Insert a table, double-click a cell.
2. Type =SUM(A1:A18446744073709551615) (or
   =SUM(A1:A1000000000) - a minutes-long freeze) and press Enter.
3. Before: the next repaint hangs forever.
4. After: the value appears immediately.

Fix:
The walk is clamped to the table's own rows/columns: row_end =
r1.max(r2).min(rows - 1) (same for columns). Every skipped cell was a
None, so the collected values - and every function's result
(SUM/AVERAGE/COUNT/MIN/MAX) - are identical; the loop now runs at most
rows x columns. start > end after clamping is an empty range, so
out-of-table references simply collect nothing (as before). The
regression test also asserts total evaluation time under 5 seconds,
turning any future un-clamped loop into a fast failure.
This commit is contained in:
Karim Jerbi committed 2026-09-30 19:15:49 +01:00
1 parent 2041d5f1ab
commit 9b3842c0f2
1 file changed
+45 -2
+45 -2
View File
@@ -520,8 +520,15 @@ fn evaluate_table_formula(table: &Table, expression: &str) -> Option<String> {
let (r1, c1) = table_cell_reference(first)?;
let (r2, c2) = table_cell_reference(last)?;
let mut values = Vec::new();
for row in r1.min(r2)..=r1.max(r2) {
for column in c1.min(c2)..=c1.max(c2) {
// A range can name any `usize` (`A1:A18446744073709551615`): `cell()`
// returns None outside the table, so clamping the walk to the table's
// own rows/columns collects exactly the same values — every skipped
// cell would have been None — while keeping an unbounded walk off the
// render path (1.8e19 iterations of `..=`).
let row_end = r1.max(r2).min(table.rows.len().saturating_sub(1));
let col_end = c1.max(c2).min(table.columns.len().saturating_sub(1));
for row in r1.min(r2)..=row_end {
for column in c1.min(c2)..=col_end {
if let Some(value) = table
.cell(row, column)
.and_then(|cell| cell.text_value().trim().parse::<f64>().ok())
@@ -3432,6 +3439,42 @@ mod tests {
assert_eq!(evaluate_table_formula(&table, "not a formula"), None);
}
/// A cell range can name any `usize` — `A1:A18446744073709551615`
/// parses to row ≈ 1.8e19. `cell()` returns None for out-of-bounds
/// indices, but the range loop still *walked* every index on the
/// render path. Clamping the walk to the table's own rows/columns
/// must collect exactly the same values (each skipped cell would
/// have been None) and stay instant; the elapsed guard keeps a
/// future un-clamped loop from creeping back in.
#[test]
fn formula_range_evaluation_is_bounded_by_the_table() {
let table = sample_table();
let start = std::time::Instant::now();
assert_eq!(
evaluate_table_formula(&table, "=SUM(A1:A18446744073709551615)"),
Some("40".to_string()),
"A1:A… is column A only: 10 + 30"
);
assert_eq!(
evaluate_table_formula(&table, "=COUNT(A1:A1000000000)"),
Some("2".to_string()),
"column A holds two numeric cells"
);
assert_eq!(
evaluate_table_formula(&table, "=SUM(A1:Z999999999)"),
Some("100".to_string())
);
assert_eq!(
evaluate_table_formula(&table, "=AVERAGE(A1:ZZ18446744073709551615)"),
Some("25".to_string())
);
let elapsed = start.elapsed();
assert!(
elapsed < std::time::Duration::from_secs(5),
"formula evaluation took {elapsed:?} — the range walk is unbounded again"
);
}
/// A column count driven to `MAX_TYPED_MAGNITUDE` (1e15) grows one
/// column per loop iteration until the allocator fails: the magnitude
/// check bounds geometry, not iteration counts. Bound the cell grid