feat(core): rewrite formula references across a row/column insert or delete - #974
Merged
Merged
Conversation
…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
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
Contributor
Test Coverage by Category
✓ = 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. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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_formulaapplies a uniform offset andEngine::rename_sheet_refsswaps 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
atdo not move; those at or after it shift bycount#REF!$anchors do not exempt an axis —$governs how a reference is copied, not which cell it points at — and are preserved in the outputedited_sheetare touched; a bare reference resolves toformula_sheet, so a formula on another sheet keeps its bare refs and still moves its explicitly qualified onesLET/LAMBDAbindings are untouched, the contractrename_sheet_refsdocuments for its own caseSplice, 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:
#REF!Nothing has to reshape the expression around the reference. A spreadsheet does not delete the argument from
SUM(A1:A3, B1); it leavesSUM(#REF!, B1). So this lands as a third consumer of the existing machinery (collect_shiftable_refs+ right-to-leftString::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!:A3do not parse, so a qualified or per-corner#REF!would not survive a round trip, while=SUM(#REF!)does. That is a deliberate divergence fromtranslate_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:
$anchors do not exempt an axis from a structural shift.#REF!.#REF!.A5:A1) clamps by coordinate order, not written order.#REF!. Sheets refuses such an insert rather than damaging formulas; this is the engine's convention, matchingtranslate_formula's grid rule, not observed product behaviour.count: 0and anatbeyond the axis maximum are no-ops;at: 0is 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/LAMBDAshadowing;u32::MAXcounts; the Excel-flavor guard. Plus a doctest that exercises theformula_sheet/edited_sheetargument order.cargo clippy --workspace -- -D warningsclean;cargo test --workspacegreen.Not in this PR
workbookis not changed to call this. This is the primitive.Follow-ups worth opening
translate_formulaproduces output that does not re-parse when a reference goes out of bounds:translate_formula("=Sheet1!A1", -5, 0)returns"=Sheet1!#REF!", whichparse_formularejects. 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
Need help on this PR? Tag
@codesmith-botwith what you need. Autofix is disabled.