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;