Skip to content

feat(core): rewrite formula references across a row/column insert or delete - #974

Merged
hhimanshu merged 4 commits into
mainfrom
feat/972-shift-refs-for-insert-delete
Sep 1, 2026
Merged

feat(core): rewrite formula references across a row/column insert or delete#974
hhimanshu merged 4 commits into
mainfrom
feat/972-shift-refs-for-insert-delete

Conversation

@hhimanshu

@hhimanshu hhimanshu commented Sep 1, 2026

Copy link
Copy Markdown
Member

Adds the missing primitive from #972: a public transform that rewrites the cell/range references inside a formula when rows or columns are inserted or deleted.

Engine::translate_formula applies a uniform offset and Engine::rename_sheet_refs swaps a sheet qualifier. Neither can express a structural edit, which moves references conditionally by their position relative to the edit and can make one cease to exist.

API

pub enum GridEdit {
    InsertRows    { at: u32, count: u32 },
    DeleteRows    { at: u32, count: u32 },
    InsertColumns { at: u32, count: u32 },
    DeleteColumns { at: u32, count: u32 },
}

impl Engine {
    pub fn shift_refs_for_grid_edit(
        &self,
        formula: &str,
        formula_sheet: &str,   // what a bare `A1` in this formula means
        edited_sheet: &str,    // the sheet the rows/columns were added to or removed from
        edit: GridEdit,
    ) -> Result<String, ParseError>;
}
  • references before at do not move; those at or after it shift by count
  • a range straddling the edit grows (insert) or shrinks (delete)
  • a reference whose every row/column was deleted becomes #REF!
  • $ anchors do not exempt an axis — $ governs how a reference is copied, not which cell it points at — and are preserved in the output
  • only references resolving to edited_sheet are touched; a bare reference resolves to formula_sheet, so a formula on another sheet keeps its bare refs and still moves its explicitly qualified ones
  • string literals, function names, defined names and LET/LAMBDA bindings are untouched, the contract rename_sheet_refs documents for its own case

Splice, not printer

The issue asks whether this needs a formula printer (AST → text), which does not exist. It does not. Every outcome of an insert or a delete is still a substitution over one reference span:

  • shifted → new A1 text
  • shrunk → new A1 text
  • wholly removed → the literal #REF!

Nothing has to reshape the expression around the reference. A spreadsheet does not delete the argument from SUM(A1:A3, B1); it leaves SUM(#REF!, B1). So this lands as a third consumer of the existing machinery (collect_shiftable_refs + right-to-left String::replace_range), which is also why the "leave literals and names alone" contract comes for free. A printer would only be needed for a transform that had to restructure the tree.

One shape decision came out of the parser rather than convention: a removed reference is replaced whole, sheet qualifier included. Sheet1!#REF! and #REF!:A3 do not parse, so a qualified or per-corner #REF! would not survive a round trip, while =SUM(#REF!) does. That is a deliberate divergence from translate_formula's per-corner rule — see "Follow-ups" below.

Semantics: what is established, and what is not

No conformance fixture in this repo covers a structural edit — the fixture pipeline evaluates formulas, it does not mutate a grid — so several rules here are asserted rather than established. They are listed explicitly in the module header so a reader can see which boundary behaviour is provisional:

  1. $ anchors do not exempt an axis from a structural shift.
  2. An insert at exactly a range's first row moves the range rather than expanding it; at its last row expands it; one past its last row leaves it alone.
  3. A cell inside the deleted band becomes #REF!.
  4. A partially deleted range shrinks rather than erroring.
  5. A range whose whole span was deleted becomes #REF!.
  6. A backwards-written range (A5:A1) clamps by coordinate order, not written order.
  7. A reference pushed past the grid bound by an insert becomes #REF!. Sheets refuses such an insert rather than damaging formulas; this is the engine's convention, matching translate_formula's grid rule, not observed product behaviour.
  8. count: 0 and an at beyond the axis maximum are no-ops; at: 0 is an error.

These follow the precedent translate_formula's design set for its own #REF! rule — treated as product-agnostic spreadsheet convention rather than something needing live-Sheets verification. They should still be pinned by the fixtures pipeline before anything depends on the exact boundary behaviour, which is the follow-up below.

Tests

65 tests in crates/core/src/engine/grid_edit/tests.rs, written before the implementation (34 failing, then green). Insert above/below/at a reference; delete wholly containing / partially overlapping / not touching; single cells and ranges on both axes; $-absolute and relative; backwards-written ranges; qualified cross-sheet references that must not move; grid-bound overflow; LET/LAMBDA shadowing; u32::MAX counts; the Excel-flavor guard. Plus a doctest that exercises the formula_sheet / edited_sheet argument order.

cargo clippy --workspace -- -D warnings clean; cargo test --workspace green.

Not in this PR

  • Wiring. workbook is not changed to call this. This is the primitive.
  • WASM/Python bindings. Both sibling transforms have them; this one does not yet.

Follow-ups worth opening

  • Fixture-verify the eight rules above through the conformance pipeline.
  • translate_formula produces output that does not re-parse when a reference goes out of bounds: translate_formula("=Sheet1!A1", -5, 0) returns "=Sheet1!#REF!", which parse_formula rejects. Pre-existing and untouched here, but the two transforms now disagree on #REF! shape and this one is the round-trip-safe side.

closes #972


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.

hhimanshu and others added 4 commits September 1, 2026 15:35
…delete

`Engine::translate_formula` applies a uniform offset and
`Engine::rename_sheet_refs` swaps a sheet qualifier; neither can express a
structural edit, which moves references conditionally by their position
relative to the edit and can remove one entirely.

Adds `Engine::shift_refs_for_grid_edit(formula, formula_sheet, edited_sheet,
edit)` and the `GridEdit` enum (`InsertRows` / `DeleteRows` / `InsertColumns`
/ `DeleteColumns`, each `{ at, count }`):

- references before `at` do not move; those at or after it shift by `count`
- a range straddling the edit grows (insert) or shrinks (delete)
- a reference whose every row/column was deleted becomes `#REF!`
- `$` anchors do not exempt an axis — `$` governs how a reference is copied,
  not which cell it points at — and are preserved in the output
- only references resolving to `edited_sheet` are touched; a bare reference
  resolves to `formula_sheet`

Implemented as a third consumer of the existing span-splice machinery
(`collect_shiftable_refs` + `String::replace_range` right-to-left), so string
literals, function names, defined names and `LET`/`LAMBDA` bindings are left
untouched for free. No AST printer is required: every outcome is still a
substitution over one reference span.

A removed reference is replaced whole, sheet qualifier included, because
`Sheet1!#REF!` does not re-parse.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HAt4Keq1m4fEyHPmPjHSJ7
`A5:A1` is a legal way to write rows 1..5. The whole-range-removed check
compared the mapped start against the mapped end assuming ascending order,
so any backwards range read as removed and became `#REF!` even when the
edit did not touch it. Pick the clamping roles by which endpoint is lower on
the edited axis, and compare the survivors in the same orientation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HAt4Keq1m4fEyHPmPjHSJ7
- Document, in the module header, the eight behavioural rules this module
  asserts that no conformance fixture in the repo establishes, so a reader
  can see which boundary behaviour is provisional and which is grounded.
- Make `map_coord`'s "last deleted index" saturating rather than relying on
  a caller-side `at >= 1` check a hundred lines away.
- Note at `map_addr` that the range-end role can yield a sentinel address
  with a `0` coordinate, and why it is never rendered.
- Drop the speculative `serde` derive on `GridEdit`: nothing asks for it, and
  it would ship an unpinned public wire format.
- Drop the unused `PartialEq`/`Eq` derives on the private `Axis` and `Role`.
- Exercise the `formula_sheet` / `edited_sheet` argument order in the public
  doctest, where transposing them is a silent, partial wrong answer.
- Close the test gaps: the column axis to the same depth as rows (whole-range
  delete, both clamps, `$` anchors, off-grid insert), a surviving range
  keeping its sheet qualifier through a shrink, `LAMBDA` shadowing, 2-D
  ranges, one-endpoint-off-grid, an index past the axis maximum, `u32::MAX`
  counts, and the Excel-flavor guard.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HAt4Keq1m4fEyHPmPjHSJ7
@hhimanshu hhimanshu self-assigned this Sep 1, 2026
@github-actions

github-actions Bot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Test Coverage by Category

Category Unit Tests Google Sheets Conformance Property Cases Total
Array 42 552/552 ✓ 1,000 (2×500) 1,594
Database 35 182/182 ✓ 3,500 (7×500) 3,717
Date 373 418/418 ✓ 2,500 (5×500) 3,291
Engineering 245 886/888 ⚠ 5,500 (11×500) 6,633
Filter 11 81/81 ✓ 4,500 (9×500) 4,592
Financial 149 1,208/1,208 ✓ 2,000 (4×500) 3,357
Info 0 256/256 ✓ 4,500 (9×500) 4,756
Logical 121 267/267 ✓ 3,500 (7×500) 3,888
Lookup 69 393/393 ✓ 1,000 (2×500) 1,462
Math 545 2,006/2,006 ✓ 8,000 (16×500) 10,551
Operator 87 251/251 ✓ 7,500 (15×500) 7,838
Parser 83 93/93 ✓ 4,000 (8×500) 4,176
Query 37 37
Statistical 529 3,191/3,191 ✓ 5,000 (10×500) 8,720
Text 327 803/804 ⚠ 4,000 (8×500) 5,131
Timezone 47 47
Volatile 0 3,500 (7×500) 3,500
Web 29 59/59 ✓ 6,000 (12×500) 6,088
Total 3,062 10,646/10,649 66,000 (132×500) ~79,711

✓ = 100% passing · ⚠ = known deviation · The ~79,711 total counts formula evaluations (each conformance row and each property case = 1). GitHub Checks reports 4,119 Rust test functions: 3,062 unit + 159 property functions (shown as cases above) + 898 conformance/integration.

@hhimanshu
hhimanshu merged commit e3352d0 into main Sep 1, 2026
9 checks passed
@hhimanshu
hhimanshu deleted the feat/972-shift-refs-for-insert-delete branch September 1, 2026 04:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

No public primitive rewrites references across a row/column insert or delete

1 participant