Skip to main content

truecalc_core/engine/
grid_edit.rs

1//! `shift_refs_for_grid_edit`: rewrite the cell/range references in a formula
2//! for a row/column **insert or delete** on one sheet — the structural-edit
3//! reference-rewrite transform.
4//!
5//! This is not [`super::translate`]'s uniform offset. A structural edit moves
6//! references *conditionally*, by their position relative to the edit, and can
7//! remove one entirely:
8//!
9//! - references before the edit index do not move,
10//! - references at or after it shift by `count` (an insert pushes them away,
11//!   a delete pulls them back),
12//! - a range straddling the edit grows (insert) or shrinks (delete),
13//! - a reference whose every row/column was deleted becomes `#REF!`.
14//!
15//! Unlike fill/paste, `$` anchors do **not** exempt an axis: `$` controls how
16//! a reference is *copied*, not which cell it points at, so `$A$5` tracks the
17//! same cell through an insert exactly as `A5` does. The anchors themselves
18//! are preserved.
19//!
20//! Only references that resolve to the edited sheet are touched — a bare
21//! reference resolves to the sheet the formula lives on. String literals,
22//! function names, defined names and `LET`/`LAMBDA` bindings are left alone,
23//! the same contract [`super::rename`] documents for its own case; that falls
24//! out of sharing `collect_shiftable_refs`. Mechanically this mirrors both
25//! existing transforms: parse, collect the reference spans, splice
26//! replacement text back into the original string right-to-left.
27//!
28//! # Semantics not yet verified against the conformance fixtures
29//!
30//! No conformance fixture in this repo covers a structural edit — the fixture
31//! pipeline evaluates formulas, it does not perform grid mutations — so the
32//! rules below are the ones this module *asserts* rather than ones the repo
33//! establishes. They follow the precedent the fill/paste transform set for its
34//! own `#REF!` rule (treated as well-established, product-agnostic spreadsheet
35//! convention rather than something needing live-Sheets verification), but
36//! they should be pinned by the pipeline before anything depends on the exact
37//! boundary behaviour:
38//!
39//! 1. `$` anchors do not exempt an axis from a structural shift (they do
40//!    exempt one from a fill/paste translation).
41//! 2. An insert at exactly a range's first row moves the whole range rather
42//!    than expanding it; an insert at exactly its last row expands it; an
43//!    insert one past its last row leaves it alone.
44//! 3. A cell inside the deleted band becomes `#REF!`.
45//! 4. A partially deleted range shrinks rather than erroring — its start
46//!    clamps forward onto the cut, its end clamps back off it.
47//! 5. A range whose whole span was deleted becomes `#REF!`.
48//! 6. A backwards-written range (`A5:A1`) clamps by coordinate order, not by
49//!    written order, and keeps its written orientation.
50//! 7. A reference pushed past the grid bound by an insert becomes `#REF!`, and
51//!    a range with even one endpoint pushed off becomes `#REF!` entirely.
52//!    Sheets itself refuses such an insert rather than damaging formulas; this
53//!    is the engine's convention (matching the fill/paste transform's grid
54//!    rule), not observed product behaviour.
55//! 8. `count: 0` and an `at` beyond the axis maximum are silent no-ops;
56//!    `at: 0` is an error.
57//!
58//! # `shift_refs_for_move`: the MOVE sibling
59//!
60//! [`AxisMove`] and [`shift_refs_for_move`] cover a third structural edit —
61//! relocating a contiguous band of rows/columns elsewhere on the *same*
62//! sheet, without inserting or deleting anything. This needs a genuinely
63//! different algorithm from insert/delete's [`map_coord`], not another
64//! variant of it: nothing is ever created or destroyed by a move, so every
65//! coordinate on the moved axis maps to exactly one output coordinate (see
66//! [`move_coord`]) — there is no `#REF!` case here, and no [`Role`] to pick
67//! a clamp direction with.
68//!
69//! ## The three regions
70//!
71//! An [`AxisMove`] relocates the band `start..=end` so its first row/column
72//! lands at `at`. Every coordinate on the axis falls into exactly one of:
73//!
74//! - inside the moved band (`start..=end`) — translates by `at - start`
75//!   onto the band's new position,
76//! - between the band's old and new position (the "gap-fill" zone) — slides
77//!   by the band's width in the *opposite* direction of the move, closing
78//!   the gap the band left or making room for where it landed,
79//! - everywhere else — unchanged.
80//!
81//! `at` landing inside the band itself (`start..=end`, other than `at ==
82//! start`, which is the literal identity) has no well-defined destination —
83//! there is no way to "move a band to the middle of itself", and no real
84//! spreadsheet UI can even express such a drag target — so the whole closed
85//! interval `start..=end` is a no-op, the same way [`GridEdit`] treats its
86//! own `count: 0` as a silent no-op rather than inventing a result. `at ==
87//! end + 1` is deliberately *not* part of that no-op range: it is the
88//! smallest genuine forward move, swapping the band with the immediately
89//! following equal-width block.
90//!
91//! ## Corner swap on inversion
92//!
93//! [`move_coord`] is not monotonic — the band and its gap-fill zone move in
94//! opposite directions relative to each other — so independently mapping a
95//! range's two endpoints can flip their relative numeric order even when
96//! they were written ascending to begin with. The issue's own canonical
97//! example: moving rows 5:7 to before row 2 sends row 3's content to row 6
98//! and row 6's content to row 3, so `A4:A6` (ascending: 4 <= 6) maps its
99//! corners to A7 and A3 — inverted.
100//!
101//! [`moved_ref_text`] tells this apart from a range that was *already*
102//! written backwards (e.g. `A7:A5`, left untouched by some unrelated move)
103//! by recording whether the original range was ascending **before** either
104//! corner is mapped, then comparing that to whether the *mapped* corners
105//! come out ascending. A mismatch between the two means the move flipped
106//! the written order — always an artifact of independent per-corner
107//! mapping, never the user's intent — so it is corrected back, in whichever
108//! direction the mismatch runs:
109//!
110//! - originally ascending, mapped corners come out descending: swap the two
111//!   already-mapped corners back into ascending order.
112//! - originally descending, mapped corners come out ascending (or equal):
113//!   the mirror case — independent mapping "uncrossed" a range the user
114//!   deliberately wrote backwards — swap back into descending order so it
115//!   *stays* backwards, mirroring how [`edited_ref_text`] preserves a
116//!   backwards-written range through insert/delete.
117//! - either orientation, mapped corners keep the same relative order as the
118//!   originals: render them exactly as computed, no swap.
119//!
120//! The `ascending` flag has to come from the *original* addresses: once both
121//! corners are mapped, numeric order alone cannot distinguish an order flip
122//! the move caused (needs correcting) from a range whose order was simply
123//! never going to change (leave it alone).
124//!
125//! ## `$` anchors and out-of-bounds
126//!
127//! As with insert/delete, `$` anchors do not exempt an axis from a move —
128//! `$` governs how a reference copies, not what it points at. [`move_addr`]
129//! carries them through unconditionally, never inspecting them.
130//!
131//! Unlike insert/delete, a move never grows the sheet, so an out-of-bounds
132//! *result* cannot happen from a well-formed [`AxisMove`] — but an
133//! out-of-bounds *request* can (an `at` that leaves no room for the band
134//! before the axis maximum). That is a property of the `AxisMove` itself,
135//! not of any individual reference, so [`shift_refs_for_move`] rejects it
136//! once at the entry point rather than threading an error path through
137//! [`move_coord`] per reference.
138
139use crate::eval::functions::lookup::indirect::{MAX_COL, MAX_ROW};
140use crate::parser::{CellAddr, Ref};
141use crate::types::{ErrorKind, ParseError};
142
143use super::rename::same_sheet;
144use super::translate::collect_shiftable_refs;
145
146/// A row or column insert/delete on a single sheet, described by the 1-based
147/// index of the first row/column affected and how many are inserted/deleted.
148///
149/// `InsertRows { at: 3, count: 2 }` inserts two blank rows so that they occupy
150/// rows 3 and 4 and the old row 3 becomes row 5. `DeleteRows { at: 3, count: 2 }`
151/// removes rows 3 and 4 so that the old row 5 becomes row 3.
152///
153/// `count: 0` is a no-op; `at: 0` is rejected (rows and columns are 1-based).
154#[derive(Debug, Clone, Copy, PartialEq, Eq)]
155pub enum GridEdit {
156    /// Insert `count` rows above the current row `at`.
157    InsertRows { at: u32, count: u32 },
158    /// Delete the `count` rows starting at row `at`.
159    DeleteRows { at: u32, count: u32 },
160    /// Insert `count` columns to the left of the current column `at`.
161    InsertColumns { at: u32, count: u32 },
162    /// Delete the `count` columns starting at column `at`.
163    DeleteColumns { at: u32, count: u32 },
164}
165
166/// Which axis of a [`CellAddr`] an edit moves.
167#[derive(Debug, Clone, Copy, PartialEq, Eq)]
168pub enum Axis {
169    Row,
170    Column,
171}
172
173impl GridEdit {
174    fn axis(self) -> Axis {
175        match self {
176            GridEdit::InsertRows { .. } | GridEdit::DeleteRows { .. } => Axis::Row,
177            GridEdit::InsertColumns { .. } | GridEdit::DeleteColumns { .. } => Axis::Column,
178        }
179    }
180
181    fn is_insert(self) -> bool {
182        matches!(
183            self,
184            GridEdit::InsertRows { .. } | GridEdit::InsertColumns { .. }
185        )
186    }
187
188    fn at(self) -> u32 {
189        match self {
190            GridEdit::InsertRows { at, .. }
191            | GridEdit::DeleteRows { at, .. }
192            | GridEdit::InsertColumns { at, .. }
193            | GridEdit::DeleteColumns { at, .. } => at,
194        }
195    }
196
197    fn count(self) -> u32 {
198        match self {
199            GridEdit::InsertRows { count, .. }
200            | GridEdit::DeleteRows { count, .. }
201            | GridEdit::InsertColumns { count, .. }
202            | GridEdit::DeleteColumns { count, .. } => count,
203        }
204    }
205
206    /// Upper bound of the edited axis in the Sheets grid.
207    fn axis_max(self) -> u32 {
208        match self.axis() {
209            Axis::Row => MAX_ROW as u32,
210            Axis::Column => MAX_COL as u32,
211        }
212    }
213}
214
215/// What a coordinate is part of, which decides what happens when the edit
216/// deletes it: a lone cell simply ceases to exist, while a range endpoint
217/// collapses onto the cut — the start forward, the end back — so that a
218/// partially deleted range shrinks instead of erroring.
219#[derive(Clone, Copy)]
220enum Role {
221    /// A single cell's coordinate.
222    Cell,
223    /// A range's start.
224    RangeStart,
225    /// A range's end.
226    RangeEnd,
227}
228
229/// Map one coordinate on the edited axis. `None` means the coordinate no
230/// longer exists — either deleted with nothing to clamp onto, or pushed off
231/// the grid by an insert.
232fn map_coord(v: u32, edit: GridEdit, role: Role) -> Option<u32> {
233    let (at, count) = (edit.at(), edit.count());
234    if edit.is_insert() {
235        if v < at {
236            return Some(v);
237        }
238        let shifted = v as u64 + count as u64;
239        (shifted <= edit.axis_max() as u64).then_some(shifted as u32)
240    } else {
241        // The last deleted index. Saturating so `map_coord` is safe on its
242        // own terms rather than relying on the `at >= 1` check in
243        // `shift_refs_text`; `count == 0` degenerates to the identity map.
244        let last = (at as u64 + count as u64).saturating_sub(1);
245        if (v as u64) < at as u64 {
246            Some(v)
247        } else if v as u64 > last {
248            Some(v - count)
249        } else {
250            match role {
251                // The cell itself was deleted; nothing to clamp onto.
252                Role::Cell => None,
253                // A deleted start clamps forward to `at`, the position the
254                // first surviving row/column below the cut now occupies.
255                Role::RangeStart => Some(at),
256                // A deleted end clamps back to `at - 1`, the last position
257                // above the cut. `at == 1` gives 0, which is below every
258                // possible start, so the range reads as wholly removed.
259                Role::RangeEnd => at.checked_sub(1),
260            }
261        }
262    }
263}
264
265/// The coordinate of `addr` on the axis `edit` moves.
266fn edited_coord(addr: CellAddr, edit: GridEdit) -> u32 {
267    match edit.axis() {
268        Axis::Row => addr.row,
269        Axis::Column => addr.col,
270    }
271}
272
273/// Apply `edit` to `addr`, leaving the untouched axis (and both `$` anchors)
274/// as they are. `None` if the address no longer exists.
275///
276/// For [`Role::RangeEnd`] this can return a *sentinel* address with a `0`
277/// coordinate — the "clamped back past the top of the grid" case, which
278/// [`CellAddr::parse`] would reject. It is only ever compared against the
279/// other endpoint, never rendered: the comparison in [`edited_ref_text`]
280/// always finds the range wholly removed when it appears.
281fn map_addr(addr: CellAddr, edit: GridEdit, role: Role) -> Option<CellAddr> {
282    let (col, row) = match edit.axis() {
283        Axis::Row => (addr.col, map_coord(addr.row, edit, role)?),
284        Axis::Column => (map_coord(addr.col, edit, role)?, addr.row),
285    };
286    Some(CellAddr {
287        col,
288        row,
289        col_abs: addr.col_abs,
290        row_abs: addr.row_abs,
291    })
292}
293
294/// Render `r` after `edit`. A reference that no longer exists — a cell in a
295/// deleted band, a range whose every row/column went, or anything pushed off
296/// the grid — becomes a bare `#REF!`, sheet qualifier and all: `Sheet1!#REF!`
297/// is not re-parseable, so it would not survive a round trip.
298fn edited_ref_text(r: &Ref, edit: GridEdit) -> String {
299    let rewritten = match r {
300        Ref::Cell { sheet, addr } => map_addr(*addr, edit, Role::Cell).map(|addr| Ref::Cell {
301            sheet: sheet.clone(),
302            addr,
303        }),
304        Ref::Range { sheet, start, end } => {
305            // A range may be written backwards (`A5:A1`), so the clamping
306            // roles follow which endpoint is actually lower on the edited
307            // axis, not which one is written first.
308            let inverted = edited_coord(*start, edit) > edited_coord(*end, edit);
309            let (start_role, end_role) = if inverted {
310                (Role::RangeEnd, Role::RangeStart)
311            } else {
312                (Role::RangeStart, Role::RangeEnd)
313            };
314            match (
315                map_addr(*start, edit, start_role),
316                map_addr(*end, edit, end_role),
317            ) {
318                (Some(new_start), Some(new_end)) => {
319                    // Every row/column the range covered was deleted: the two
320                    // clamped endpoints have crossed over each other.
321                    let (a, b) = (edited_coord(new_start, edit), edited_coord(new_end, edit));
322                    let survives = if inverted { a >= b } else { a <= b };
323                    survives.then_some(Ref::Range {
324                        sheet: sheet.clone(),
325                        start: new_start,
326                        end: new_end,
327                    })
328                }
329                _ => None,
330            }
331        }
332        Ref::Name(_) => unreachable!("collect_shiftable_refs never returns Ref::Name"),
333        Ref::Table { .. } => unreachable!("collect_shiftable_refs never returns Ref::Table"),
334    };
335    match rewritten {
336        Some(r) => r.to_string(),
337        None => ErrorKind::Ref.to_string(),
338    }
339}
340
341/// True if `r` resolves to `edited_sheet`. A bare reference resolves to
342/// `formula_sheet`, the sheet the formula itself lives on.
343fn targets_edited_sheet(r: &Ref, formula_sheet: &str, edited_sheet: &str) -> bool {
344    let target = match r {
345        Ref::Cell { sheet, .. } | Ref::Range { sheet, .. } => sheet.as_deref(),
346        _ => return false,
347    };
348    same_sheet(target.unwrap_or(formula_sheet), edited_sheet)
349}
350
351/// Parse `formula`, rewrite every reference that resolves to `edited_sheet`
352/// for `edit`, and splice the result back into the original text.
353///
354/// `formula_sheet` is the sheet the formula lives on — what a bare `A1`
355/// resolves to. Sheet matching is case-insensitive, as in
356/// [`super::rename`]. No-op if no reference resolves to `edited_sheet`.
357pub(crate) fn shift_refs_text(
358    formula: &str,
359    formula_sheet: &str,
360    edited_sheet: &str,
361    edit: GridEdit,
362) -> Result<String, ParseError> {
363    if edit.at() == 0 {
364        return Err(ParseError {
365            message: "shift_refs_for_grid_edit: rows and columns are 1-based; `at` must be >= 1"
366                .into(),
367            position: 0,
368        });
369    }
370    let expr = crate::parser::parse_formula(formula)?;
371    let mut spans: Vec<_> = collect_shiftable_refs(&expr)
372        .into_iter()
373        .filter(|(_, r)| targets_edited_sheet(r, formula_sheet, edited_sheet))
374        .collect();
375    spans.sort_by_key(|s| std::cmp::Reverse(s.0.offset)); // right to left
376    let mut out = formula.to_string();
377    for (span, r) in spans {
378        let replacement = edited_ref_text(&r, edit);
379        let start = span.offset;
380        let end = span.offset + span.length;
381        out.replace_range(start..end, &replacement);
382    }
383    Ok(out)
384}
385
386/// A row or column relocation on a single sheet: moves the contiguous band
387/// `start..=end` (1-based, inclusive) so its first row/column lands at `at`
388/// (1-based) after the move — Sheets' own convention: "move rows 5:7 to row
389/// 2" means the band's new start is row 2, in either direction.
390///
391/// Unlike [`GridEdit`], nothing is created or destroyed: every coordinate on
392/// `axis` maps to exactly one output coordinate (see [`move_coord`]).
393///
394/// A move is backward when `at < start` and forward when `at > end`. `at`
395/// landing inside the band itself (`start..=end`) is a no-op — see the
396/// module doc. `start == 0` or `at == 0` is rejected (rows/columns are
397/// 1-based); `start > end` is rejected as a malformed band.
398///
399/// A well-formed `AxisMove` also has `end` itself within the sheet's grid
400/// bounds — that precondition is on the caller, the same way it is on
401/// `start <= end`. [`shift_refs_for_move`] validates the *destination*
402/// footprint (`at ..= at + width - 1`) against the axis maximum, since an
403/// out-of-bounds destination is the one thing a move can newly request that
404/// insert/delete cannot, but it does not separately re-validate `end`
405/// against that maximum: an `end` already past the grid could only reach
406/// this function from a caller that mis-described the sheet's own state.
407#[derive(Debug, Clone, Copy, PartialEq, Eq)]
408pub struct AxisMove {
409    pub axis: Axis,
410    pub start: u32,
411    pub end: u32,
412    pub at: u32,
413}
414
415impl AxisMove {
416    /// Upper bound of the moved axis in the Sheets grid.
417    fn axis_max(self) -> u32 {
418        match self.axis {
419            Axis::Row => MAX_ROW as u32,
420            Axis::Column => MAX_COL as u32,
421        }
422    }
423
424    /// How many rows/columns the band spans.
425    fn width(self) -> u32 {
426        self.end - self.start + 1
427    }
428}
429
430/// The coordinate of `addr` on `axis`.
431fn axis_coord(addr: CellAddr, axis: Axis) -> u32 {
432    match axis {
433        Axis::Row => addr.row,
434        Axis::Column => addr.col,
435    }
436}
437
438/// Total remap of one coordinate on `mv`'s axis: every `v` maps to exactly
439/// one output coordinate, since a move never creates or destroys a
440/// row/column — contrast [`map_coord`], which can return `None`. Callers
441/// must exclude the no-op range (`mv.at` inside `mv.start..=mv.end`) first;
442/// see the module doc for why calling this inside that range would produce
443/// a nonsensical cyclic permutation rather than a real answer.
444fn move_coord(v: u32, mv: AxisMove) -> u32 {
445    let width = mv.width();
446    if (mv.start..=mv.end).contains(&v) {
447        // Inside the moved band: translate the in-band offset onto the
448        // band's new start.
449        return v - mv.start + mv.at;
450    }
451    if mv.at < mv.start {
452        // Backward move: the band vacates start..=end and slides down to
453        // at..=(at+width-1). Rows/columns strictly between the new and old
454        // start slide forward by the band's width to close the gap.
455        if (mv.at..mv.start).contains(&v) {
456            return v + width;
457        }
458    } else {
459        // Forward move (mv.at > mv.end, guaranteed once the no-op range
460        // above is excluded): rows/columns between the old end and the
461        // band's new, wider footprint slide back by the band's width.
462        let gap_end = mv.at + width - 1;
463        if (mv.end + 1..=gap_end).contains(&v) {
464            return v - width;
465        }
466    }
467    v
468}
469
470/// Apply `mv` to `addr`, leaving the untouched axis (and both `$` anchors)
471/// exactly as they are. Total: unlike [`map_addr`], this never returns
472/// `None` — a move never drops a reference.
473fn move_addr(addr: CellAddr, mv: AxisMove) -> CellAddr {
474    let (col, row) = match mv.axis {
475        Axis::Row => (addr.col, move_coord(addr.row, mv)),
476        Axis::Column => (move_coord(addr.col, mv), addr.row),
477    };
478    CellAddr {
479        col,
480        row,
481        col_abs: addr.col_abs,
482        row_abs: addr.row_abs,
483    }
484}
485
486/// Render `r` after the move `mv`. Unlike [`edited_ref_text`], nothing is
487/// ever dropped, so there is no `#REF!` case and no [`Role`] to pick a clamp
488/// direction with — a single-cell reference and each range endpoint go
489/// through the same [`move_coord`] call. See the module doc for the
490/// corner-swap reasoning below.
491fn moved_ref_text(r: &Ref, mv: AxisMove) -> String {
492    let rewritten = match r {
493        Ref::Cell { sheet, addr } => Ref::Cell {
494            sheet: sheet.clone(),
495            addr: move_addr(*addr, mv),
496        },
497        Ref::Range { sheet, start, end } => {
498            // Record orientation from the ORIGINAL addresses, before either
499            // corner is mapped: move_coord is not monotonic, so mapping the
500            // two corners independently can flip their numeric order even
501            // when they were written ascending — or "unflip" a range that
502            // was deliberately written descending. Once both are mapped
503            // there is no way to tell an order flip the move caused (needs
504            // correcting) apart from one that was simply never going to
505            // change (leave it), so the comparison has to be made against
506            // this original flag, not against the mapped coordinates alone.
507            let ascending = axis_coord(*start, mv.axis) <= axis_coord(*end, mv.axis);
508            let new_start = move_addr(*start, mv);
509            let new_end = move_addr(*end, mv);
510            let mapped_ascending = axis_coord(new_start, mv.axis) <= axis_coord(new_end, mv.axis);
511            let (start, end) = if ascending == mapped_ascending {
512                // The mapped corners kept the same relative order the
513                // originals had (both ascending or both descending): render
514                // exactly as computed, no swap.
515                (new_start, new_end)
516            } else {
517                // The move flipped the order: an originally-ascending range
518                // came out descending (swap back to ascending), or an
519                // originally-descending range came out ascending — the
520                // mapping "uncrossed" a range the user deliberately wrote
521                // backwards, so swap back to keep it descending. Either way
522                // this is the mapping's artifact, never the user's intent.
523                (new_end, new_start)
524            };
525            Ref::Range {
526                sheet: sheet.clone(),
527                start,
528                end,
529            }
530        }
531        Ref::Name(_) => unreachable!("collect_shiftable_refs never returns Ref::Name"),
532        Ref::Table { .. } => unreachable!("collect_shiftable_refs never returns Ref::Table"),
533    };
534    rewritten.to_string()
535}
536
537/// Parse `formula`, rewrite every reference that resolves to `edited_sheet`
538/// for the move `mv`, and splice the result back into the original text.
539///
540/// `formula_sheet` is the sheet the formula lives on — what a bare `A1`
541/// resolves to. Sheet matching is case-insensitive, as in
542/// [`shift_refs_text`]. No-op if no reference resolves to `edited_sheet`,
543/// and also if `mv.at` lands inside `mv.start..=mv.end` (see the module
544/// doc).
545pub(crate) fn shift_refs_for_move(
546    formula: &str,
547    formula_sheet: &str,
548    edited_sheet: &str,
549    mv: AxisMove,
550) -> Result<String, ParseError> {
551    if mv.start == 0 || mv.at == 0 {
552        return Err(ParseError {
553            message:
554                "shift_refs_for_move: rows and columns are 1-based; `start` and `at` must be >= 1"
555                    .into(),
556            position: 0,
557        });
558    }
559    if mv.start > mv.end {
560        return Err(ParseError {
561            message: "shift_refs_for_move: `start` must be <= `end`".into(),
562            position: 0,
563        });
564    }
565    let expr = crate::parser::parse_formula(formula)?;
566    if (mv.start..=mv.end).contains(&mv.at) {
567        // `at` inside the band has no well-defined destination: a no-op,
568        // not an error — see the module doc.
569        return Ok(formula.to_string());
570    }
571    let width = mv.end as u64 - mv.start as u64 + 1;
572    if mv.at as u64 + width - 1 > mv.axis_max() as u64 {
573        return Err(ParseError {
574            message: "shift_refs_for_move: destination pushes the band off the grid".into(),
575            position: 0,
576        });
577    }
578    let mut spans: Vec<_> = collect_shiftable_refs(&expr)
579        .into_iter()
580        .filter(|(_, r)| targets_edited_sheet(r, formula_sheet, edited_sheet))
581        .collect();
582    spans.sort_by_key(|s| std::cmp::Reverse(s.0.offset)); // right to left
583    let mut out = formula.to_string();
584    for (span, r) in spans {
585        let replacement = moved_ref_text(&r, mv);
586        let start = span.offset;
587        let end = span.offset + span.length;
588        out.replace_range(start..end, &replacement);
589    }
590    Ok(out)
591}
592
593#[cfg(test)]
594mod tests;