Skip to main content

formualizer_eval/engine/graph/editor/
reference_adjuster.rs

1use crate::reference::{CellRef, Coord};
2use crate::{SheetId, engine::sheet_registry::SheetRegistry};
3use formualizer_common::{ExcelError, ExcelErrorKind, LiteralValue};
4use formualizer_parse::parser::{ASTNode, ASTNodeType, ReferenceType};
5
6/// How absolute (`$`-anchored) references respond to structural shifts.
7///
8/// Policy decision pinned on issue #168: absolute references TRACK structural
9/// inserts/deletes — the `$` pins copy/fill relocation only, it does not pin
10/// the reference against rows/columns physically moving. A delete that
11/// removes the referenced row/column yields `#REF!` exactly like a relative
12/// reference.
13///
14/// `Pin` preserves the legacy behavior (absolute refs never move under
15/// structural ops) and exists solely for named-range definition adjustment,
16/// which historically pinned absolute anchors; flipping named ranges to
17/// `Track` is a separate policy decision (see the #168 discussion).
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub(crate) enum AbsShiftPolicy {
20    /// Absolute references shift with structural inserts/deletes
21    /// (Excel-correct; the default for formula reference adjustment).
22    Track,
23    /// Legacy behavior: absolute references never move under structural ops.
24    Pin,
25}
26
27impl AbsShiftPolicy {
28    #[inline]
29    fn pins(self) -> bool {
30        matches!(self, Self::Pin)
31    }
32}
33
34/// Centralized reference adjustment logic for structural changes
35pub struct ReferenceAdjuster;
36
37/// Sheet binding context for references in the AST being adjusted.
38///
39/// This lets structural adjustment distinguish unqualified references on the
40/// formula's sheet from qualified references to another sheet.
41pub(crate) struct ReferenceContext<'a> {
42    formula_sheet_id: SheetId,
43    sheet_registry: &'a SheetRegistry,
44}
45
46impl<'a> ReferenceContext<'a> {
47    pub(crate) fn new(formula_sheet_id: SheetId, sheet_registry: &'a SheetRegistry) -> Self {
48        Self {
49            formula_sheet_id,
50            sheet_registry,
51        }
52    }
53
54    fn reference_sheet_id(&self, sheet: Option<&str>) -> Option<SheetId> {
55        match sheet {
56            Some(name) => self.sheet_registry.get_id(name),
57            None => Some(self.formula_sheet_id),
58        }
59    }
60}
61
62#[derive(Debug, Clone, PartialEq)]
63enum ReferenceAdjustment {
64    Reference(ReferenceType),
65    Invalidated,
66}
67
68#[derive(Debug, Clone)]
69pub enum ShiftOperation {
70    InsertRows {
71        sheet_id: u16,
72        before: u32,
73        count: u32,
74    },
75    DeleteRows {
76        sheet_id: u16,
77        start: u32,
78        count: u32,
79    },
80    InsertColumns {
81        sheet_id: u16,
82        before: u32,
83        count: u32,
84    },
85    DeleteColumns {
86        sheet_id: u16,
87        start: u32,
88        count: u32,
89    },
90}
91
92impl ReferenceAdjuster {
93    pub fn new() -> Self {
94        Self
95    }
96
97    /// Adjust an AST for a shift operation.
98    /// Absolute references track structural shifts.
99    ///
100    /// This compatibility API retains the historical context-free behavior and
101    /// assumes every cell/range reference binds to the operation's sheet.
102    pub fn adjust_ast(&self, ast: &ASTNode, op: &ShiftOperation) -> ASTNode {
103        self.adjust_ast_with_policy(ast, op, AbsShiftPolicy::Track)
104    }
105
106    /// Adjust an AST under an explicit absolute-reference policy.
107    pub(crate) fn adjust_ast_with_policy(
108        &self,
109        ast: &ASTNode,
110        op: &ShiftOperation,
111        policy: AbsShiftPolicy,
112    ) -> ASTNode {
113        self.adjust_ast_if_changed_inner(ast, op, policy, None)
114            .unwrap_or_else(|| ast.clone())
115    }
116
117    /// Return an adjusted AST only when at least one reference changed.
118    pub fn adjust_ast_if_changed(&self, ast: &ASTNode, op: &ShiftOperation) -> Option<ASTNode> {
119        self.adjust_ast_if_changed_with_policy(ast, op, AbsShiftPolicy::Track)
120    }
121
122    /// Adjust an AST only when references change under an explicit policy.
123    pub(crate) fn adjust_ast_if_changed_with_policy(
124        &self,
125        ast: &ASTNode,
126        op: &ShiftOperation,
127        policy: AbsShiftPolicy,
128    ) -> Option<ASTNode> {
129        self.adjust_ast_if_changed_inner(ast, op, policy, None)
130    }
131
132    pub(crate) fn adjust_ast_in_context(
133        &self,
134        ast: &ASTNode,
135        op: &ShiftOperation,
136        context: &ReferenceContext<'_>,
137    ) -> ASTNode {
138        self.adjust_ast_with_policy_in_context(ast, op, AbsShiftPolicy::Track, context)
139    }
140
141    pub(crate) fn adjust_ast_with_policy_in_context(
142        &self,
143        ast: &ASTNode,
144        op: &ShiftOperation,
145        policy: AbsShiftPolicy,
146        context: &ReferenceContext<'_>,
147    ) -> ASTNode {
148        self.adjust_ast_if_changed_inner(ast, op, policy, Some(context))
149            .unwrap_or_else(|| ast.clone())
150    }
151
152    pub(crate) fn adjust_ast_if_changed_in_context(
153        &self,
154        ast: &ASTNode,
155        op: &ShiftOperation,
156        context: &ReferenceContext<'_>,
157    ) -> Option<ASTNode> {
158        self.adjust_ast_if_changed_with_policy_in_context(ast, op, AbsShiftPolicy::Track, context)
159    }
160
161    pub(crate) fn adjust_ast_if_changed_with_policy_in_context(
162        &self,
163        ast: &ASTNode,
164        op: &ShiftOperation,
165        policy: AbsShiftPolicy,
166        context: &ReferenceContext<'_>,
167    ) -> Option<ASTNode> {
168        self.adjust_ast_if_changed_inner(ast, op, policy, Some(context))
169    }
170
171    fn adjust_ast_if_changed_inner(
172        &self,
173        ast: &ASTNode,
174        op: &ShiftOperation,
175        policy: AbsShiftPolicy,
176        context: Option<&ReferenceContext<'_>>,
177    ) -> Option<ASTNode> {
178        let changed_node_type = match &ast.node_type {
179            ASTNodeType::Reference { reference, .. } => {
180                match self.adjust_reference(reference, op, policy, context) {
181                    ReferenceAdjustment::Reference(adjusted) if adjusted == *reference => {
182                        return None;
183                    }
184                    ReferenceAdjustment::Reference(adjusted) => ASTNodeType::Reference {
185                        original: adjusted.normalise(),
186                        reference: adjusted,
187                    },
188                    ReferenceAdjustment::Invalidated => ASTNodeType::Literal(LiteralValue::Error(
189                        ExcelError::new(ExcelErrorKind::Ref),
190                    )),
191                }
192            }
193            ASTNodeType::BinaryOp {
194                op: bin_op,
195                left,
196                right,
197            } => {
198                let adjusted_left = self.adjust_ast_if_changed_inner(left, op, policy, context);
199                let adjusted_right = self.adjust_ast_if_changed_inner(right, op, policy, context);
200                if adjusted_left.is_none() && adjusted_right.is_none() {
201                    return None;
202                }
203                ASTNodeType::BinaryOp {
204                    op: bin_op.clone(),
205                    left: Box::new(adjusted_left.unwrap_or_else(|| (**left).clone())),
206                    right: Box::new(adjusted_right.unwrap_or_else(|| (**right).clone())),
207                }
208            }
209            ASTNodeType::UnaryOp { op: un_op, expr } => ASTNodeType::UnaryOp {
210                op: un_op.clone(),
211                expr: Box::new(self.adjust_ast_if_changed_inner(expr, op, policy, context)?),
212            },
213            ASTNodeType::Function { name, args } => {
214                let (args, changed) = self.adjust_children(args, op, policy, context);
215                if !changed {
216                    return None;
217                }
218                ASTNodeType::Function {
219                    name: name.clone(),
220                    args,
221                }
222            }
223            ASTNodeType::Call { callee, args } => {
224                let adjusted_callee = self.adjust_ast_if_changed_inner(callee, op, policy, context);
225                let (args, args_changed) = self.adjust_children(args, op, policy, context);
226                if adjusted_callee.is_none() && !args_changed {
227                    return None;
228                }
229                ASTNodeType::Call {
230                    callee: Box::new(adjusted_callee.unwrap_or_else(|| (**callee).clone())),
231                    args,
232                }
233            }
234            ASTNodeType::Array(rows) => {
235                let mut changed = false;
236                let rows = rows
237                    .iter()
238                    .map(|row| {
239                        let (row, row_changed) = self.adjust_children(row, op, policy, context);
240                        changed |= row_changed;
241                        row
242                    })
243                    .collect();
244                if !changed {
245                    return None;
246                }
247                ASTNodeType::Array(rows)
248            }
249            _ => return None,
250        };
251
252        Some(ASTNode {
253            node_type: changed_node_type,
254            source_token: None,
255            contains_volatile: ast.contains_volatile,
256        })
257    }
258
259    fn adjust_children(
260        &self,
261        children: &[ASTNode],
262        op: &ShiftOperation,
263        policy: AbsShiftPolicy,
264        context: Option<&ReferenceContext<'_>>,
265    ) -> (Vec<ASTNode>, bool) {
266        let mut changed = false;
267        let children = children
268            .iter()
269            .map(|child| {
270                if let Some(adjusted) = self.adjust_ast_if_changed_inner(child, op, policy, context)
271                {
272                    changed = true;
273                    adjusted
274                } else {
275                    child.clone()
276                }
277            })
278            .collect();
279        (children, changed)
280    }
281
282    /// Adjust a cell reference for a shift operation.
283    /// Returns None if the cell is deleted.
284    /// Absolute references track structural shifts.
285    pub fn adjust_cell_ref(&self, cell_ref: &CellRef, op: &ShiftOperation) -> Option<CellRef> {
286        self.adjust_cell_ref_with_policy(cell_ref, op, AbsShiftPolicy::Track)
287    }
288
289    /// Adjust a cell reference under an explicit absolute-reference policy.
290    pub(crate) fn adjust_cell_ref_with_policy(
291        &self,
292        cell_ref: &CellRef,
293        op: &ShiftOperation,
294        policy: AbsShiftPolicy,
295    ) -> Option<CellRef> {
296        let coord = cell_ref.coord;
297        let adjusted_coord = match op {
298            ShiftOperation::InsertRows {
299                sheet_id,
300                before,
301                count,
302            } if cell_ref.sheet_id == *sheet_id => {
303                if (policy.pins() && coord.row_abs()) || coord.row() < *before {
304                    // Cells before the insert point don't move (nor do
305                    // absolute references under the legacy Pin policy).
306                    coord
307                } else {
308                    // Shift down
309                    Coord::new(
310                        coord.row() + count,
311                        coord.col(),
312                        coord.row_abs(),
313                        coord.col_abs(),
314                    )
315                }
316            }
317            ShiftOperation::DeleteRows {
318                sheet_id,
319                start,
320                count,
321            } if cell_ref.sheet_id == *sheet_id => {
322                if policy.pins() && coord.row_abs() {
323                    // Legacy Pin policy: absolute references don't adjust
324                    coord
325                } else if coord.row() >= *start && coord.row() < start + count {
326                    // Cell deleted
327                    return None;
328                } else if coord.row() >= start + count {
329                    // Shift up
330                    Coord::new(
331                        coord.row() - count,
332                        coord.col(),
333                        coord.row_abs(),
334                        coord.col_abs(),
335                    )
336                } else {
337                    // Before delete range, no change
338                    coord
339                }
340            }
341            ShiftOperation::InsertColumns {
342                sheet_id,
343                before,
344                count,
345            } if cell_ref.sheet_id == *sheet_id => {
346                if (policy.pins() && coord.col_abs()) || coord.col() < *before {
347                    // Cells before the insert point don't move (nor do
348                    // absolute references under the legacy Pin policy).
349                    coord
350                } else {
351                    // Shift right
352                    Coord::new(
353                        coord.row(),
354                        coord.col() + count,
355                        coord.row_abs(),
356                        coord.col_abs(),
357                    )
358                }
359            }
360            ShiftOperation::DeleteColumns {
361                sheet_id,
362                start,
363                count,
364            } if cell_ref.sheet_id == *sheet_id => {
365                if policy.pins() && coord.col_abs() {
366                    // Legacy Pin policy: absolute references don't adjust
367                    coord
368                } else if coord.col() >= *start && coord.col() < start + count {
369                    // Cell deleted
370                    return None;
371                } else if coord.col() >= start + count {
372                    // Shift left
373                    Coord::new(
374                        coord.row(),
375                        coord.col() - count,
376                        coord.row_abs(),
377                        coord.col_abs(),
378                    )
379                } else {
380                    // Before delete range, no change
381                    coord
382                }
383            }
384            _ => coord,
385        };
386
387        Some(CellRef::new(cell_ref.sheet_id, adjusted_coord))
388    }
389
390    /// Adjust a reference type (cell or range) for a shift operation
391    fn adjust_reference(
392        &self,
393        reference: &ReferenceType,
394        op: &ShiftOperation,
395        policy: AbsShiftPolicy,
396        context: Option<&ReferenceContext<'_>>,
397    ) -> ReferenceAdjustment {
398        let op_sheet_id = match op {
399            ShiftOperation::InsertRows { sheet_id, .. }
400            | ShiftOperation::DeleteRows { sheet_id, .. }
401            | ShiftOperation::InsertColumns { sheet_id, .. }
402            | ShiftOperation::DeleteColumns { sheet_id, .. } => *sheet_id,
403        };
404        match reference {
405            ReferenceType::Cell { sheet, .. } | ReferenceType::Range { sheet, .. } => {
406                if context.is_some_and(|context| {
407                    context.reference_sheet_id(sheet.as_deref()) != Some(op_sheet_id)
408                }) {
409                    return ReferenceAdjustment::Reference(reference.clone());
410                }
411            }
412            _ => return ReferenceAdjustment::Reference(reference.clone()),
413        }
414
415        let shared = reference.to_sheet_ref_lossy();
416
417        match (reference, shared) {
418            (
419                ReferenceType::Cell {
420                    sheet,
421                    row_abs,
422                    col_abs,
423                    ..
424                },
425                Some(crate::reference::SharedRef::Cell(cell)),
426            ) => {
427                let temp_ref = CellRef::new(
428                    op_sheet_id,
429                    Coord::new(cell.coord.row(), cell.coord.col(), *row_abs, *col_abs),
430                );
431
432                match self.adjust_cell_ref_with_policy(&temp_ref, op, policy) {
433                    None => ReferenceAdjustment::Invalidated,
434                    Some(adjusted) => ReferenceAdjustment::Reference(ReferenceType::Cell {
435                        sheet: sheet.clone(),
436                        row: adjusted.coord.row() + 1,
437                        col: adjusted.coord.col() + 1,
438                        row_abs: *row_abs,
439                        col_abs: *col_abs,
440                    }),
441                }
442            }
443            (
444                ReferenceType::Range {
445                    sheet,
446                    start_row_abs,
447                    start_col_abs,
448                    end_row_abs,
449                    end_col_abs,
450                    ..
451                },
452                Some(crate::reference::SharedRef::Range(range)),
453            ) => {
454                let sr = range.start_row;
455                let sc = range.start_col;
456                let er = range.end_row;
457                let ec = range.end_col;
458
459                let adjust_insert = |b: formualizer_common::AxisBound, before: u32, count: u32| {
460                    if policy.pins() && b.abs {
461                        b.index
462                    } else if b.index >= before {
463                        b.index + count
464                    } else {
465                        b.index
466                    }
467                };
468
469                let adjust_delete = |idx: u32, abs: bool, start: u32, count: u32| {
470                    if policy.pins() && abs {
471                        idx
472                    } else if idx >= start + count {
473                        idx - count
474                    } else if idx >= start {
475                        start
476                    } else {
477                        idx
478                    }
479                };
480
481                let (adj_sr0, adj_er0) = match op {
482                    ShiftOperation::InsertRows { before, count, .. } => (
483                        sr.map(|b| adjust_insert(b, *before, *count)),
484                        er.map(|b| adjust_insert(b, *before, *count)),
485                    ),
486                    ShiftOperation::DeleteRows { start, count, .. } => match (sr, er) {
487                        (Some(range_start), Some(range_end))
488                            if !policy.pins() || (!range_start.abs && !range_end.abs) =>
489                        {
490                            let range_start = range_start.index;
491                            let range_end = range_end.index;
492                            if range_end < *start || range_start >= start + count {
493                                let adj_start = if range_start >= start + count {
494                                    range_start - count
495                                } else {
496                                    range_start
497                                };
498                                let adj_end = if range_end >= start + count {
499                                    range_end - count
500                                } else {
501                                    range_end
502                                };
503                                (Some(adj_start), Some(adj_end))
504                            } else if range_start >= *start && range_end < start + count {
505                                return ReferenceAdjustment::Invalidated;
506                            } else {
507                                let adj_start = if range_start < *start {
508                                    range_start
509                                } else {
510                                    *start
511                                };
512                                let adj_end = if range_end >= start + count {
513                                    range_end - count
514                                } else {
515                                    start.saturating_sub(1)
516                                };
517                                (Some(adj_start), Some(adj_end))
518                            }
519                        }
520                        (Some(range_start), Some(range_end)) => {
521                            let adj_start =
522                                adjust_delete(range_start.index, range_start.abs, *start, *count);
523                            let adj_end =
524                                adjust_delete(range_end.index, range_end.abs, *start, *count);
525                            (Some(adj_start), Some(adj_end))
526                        }
527                        _ => (
528                            sr.map(|b| adjust_delete(b.index, b.abs, *start, *count)),
529                            er.map(|b| adjust_delete(b.index, b.abs, *start, *count)),
530                        ),
531                    },
532                    _ => (sr.map(|b| b.index), er.map(|b| b.index)),
533                };
534
535                let (adj_sc0, adj_ec0) = match op {
536                    ShiftOperation::InsertColumns { before, count, .. } => (
537                        sc.map(|b| adjust_insert(b, *before, *count)),
538                        ec.map(|b| adjust_insert(b, *before, *count)),
539                    ),
540                    ShiftOperation::DeleteColumns { start, count, .. } => match (sc, ec) {
541                        (Some(range_start), Some(range_end))
542                            if !policy.pins() || (!range_start.abs && !range_end.abs) =>
543                        {
544                            let range_start = range_start.index;
545                            let range_end = range_end.index;
546                            if range_end < *start || range_start >= start + count {
547                                let adj_start = if range_start >= start + count {
548                                    range_start - count
549                                } else {
550                                    range_start
551                                };
552                                let adj_end = if range_end >= start + count {
553                                    range_end - count
554                                } else {
555                                    range_end
556                                };
557                                (Some(adj_start), Some(adj_end))
558                            } else if range_start >= *start && range_end < start + count {
559                                return ReferenceAdjustment::Invalidated;
560                            } else {
561                                let adj_start = if range_start < *start {
562                                    range_start
563                                } else {
564                                    *start
565                                };
566                                let adj_end = if range_end >= start + count {
567                                    range_end - count
568                                } else {
569                                    start.saturating_sub(1)
570                                };
571                                (Some(adj_start), Some(adj_end))
572                            }
573                        }
574                        (Some(range_start), Some(range_end)) => {
575                            let adj_start =
576                                adjust_delete(range_start.index, range_start.abs, *start, *count);
577                            let adj_end =
578                                adjust_delete(range_end.index, range_end.abs, *start, *count);
579                            (Some(adj_start), Some(adj_end))
580                        }
581                        _ => (
582                            sc.map(|b| adjust_delete(b.index, b.abs, *start, *count)),
583                            ec.map(|b| adjust_delete(b.index, b.abs, *start, *count)),
584                        ),
585                    },
586                    _ => (sc.map(|b| b.index), ec.map(|b| b.index)),
587                };
588
589                ReferenceAdjustment::Reference(ReferenceType::Range {
590                    sheet: sheet.clone(),
591                    start_row: adj_sr0.map(|i| i + 1),
592                    start_col: adj_sc0.map(|i| i + 1),
593                    end_row: adj_er0.map(|i| i + 1),
594                    end_col: adj_ec0.map(|i| i + 1),
595                    start_row_abs: *start_row_abs,
596                    start_col_abs: *start_col_abs,
597                    end_row_abs: *end_row_abs,
598                    end_col_abs: *end_col_abs,
599                })
600            }
601            _ => ReferenceAdjustment::Reference(reference.clone()),
602        }
603    }
604}
605
606impl Default for ReferenceAdjuster {
607    fn default() -> Self {
608        Self::new()
609    }
610}
611
612/// Helper for adjusting references when copying/moving ranges
613pub struct RelativeReferenceAdjuster {
614    row_offset: i32,
615    col_offset: i32,
616}
617
618impl RelativeReferenceAdjuster {
619    pub fn new(row_offset: i32, col_offset: i32) -> Self {
620        Self {
621            row_offset,
622            col_offset,
623        }
624    }
625
626    pub fn adjust_formula(&self, ast: &ASTNode) -> ASTNode {
627        match &ast.node_type {
628            ASTNodeType::Reference {
629                original,
630                reference,
631            } => {
632                let adjusted = self.adjust_reference(reference);
633                ASTNode {
634                    node_type: ASTNodeType::Reference {
635                        original: original.clone(),
636                        reference: adjusted,
637                    },
638                    source_token: ast.source_token.clone(),
639                    contains_volatile: ast.contains_volatile,
640                }
641            }
642            ASTNodeType::BinaryOp { op, left, right } => ASTNode {
643                node_type: ASTNodeType::BinaryOp {
644                    op: op.clone(),
645                    left: Box::new(self.adjust_formula(left)),
646                    right: Box::new(self.adjust_formula(right)),
647                },
648                source_token: ast.source_token.clone(),
649                contains_volatile: ast.contains_volatile,
650            },
651            ASTNodeType::UnaryOp { op, expr } => ASTNode {
652                node_type: ASTNodeType::UnaryOp {
653                    op: op.clone(),
654                    expr: Box::new(self.adjust_formula(expr)),
655                },
656                source_token: ast.source_token.clone(),
657                contains_volatile: ast.contains_volatile,
658            },
659            ASTNodeType::Function { name, args } => ASTNode {
660                node_type: ASTNodeType::Function {
661                    name: name.clone(),
662                    args: args.iter().map(|arg| self.adjust_formula(arg)).collect(),
663                },
664                source_token: ast.source_token.clone(),
665                contains_volatile: ast.contains_volatile,
666            },
667            _ => ast.clone(),
668        }
669    }
670
671    fn adjust_reference(
672        &self,
673        reference: &formualizer_parse::parser::ReferenceType,
674    ) -> formualizer_parse::parser::ReferenceType {
675        use formualizer_parse::parser::ReferenceType;
676
677        let Some(shared) = reference.to_sheet_ref_lossy() else {
678            return reference.clone();
679        };
680
681        match (reference, shared) {
682            (ReferenceType::Cell { sheet, .. }, crate::reference::SharedRef::Cell(cell)) => {
683                let owned = cell.into_owned();
684                let row0 = owned.coord.row();
685                let col0 = owned.coord.col();
686                let row_abs = owned.coord.row_abs();
687                let col_abs = owned.coord.col_abs();
688
689                let new_row0 = if row_abs {
690                    row0
691                } else {
692                    (row0 as i32 + self.row_offset).max(0) as u32
693                };
694                let new_col0 = if col_abs {
695                    col0
696                } else {
697                    (col0 as i32 + self.col_offset).max(0) as u32
698                };
699
700                ReferenceType::Cell {
701                    sheet: sheet.clone(),
702                    row: new_row0 + 1,
703                    col: new_col0 + 1,
704                    row_abs,
705                    col_abs,
706                }
707            }
708            (ReferenceType::Range { sheet, .. }, crate::reference::SharedRef::Range(range)) => {
709                let owned = range.into_owned();
710
711                let adj_axis = |b: formualizer_common::AxisBound, off: i32| {
712                    if b.abs {
713                        b.index
714                    } else {
715                        (b.index as i32 + off).max(0) as u32
716                    }
717                };
718
719                let adj_start_row = owned.start_row.map(|b| adj_axis(b, self.row_offset) + 1);
720                let adj_start_col = owned.start_col.map(|b| adj_axis(b, self.col_offset) + 1);
721                let adj_end_row = owned.end_row.map(|b| adj_axis(b, self.row_offset) + 1);
722                let adj_end_col = owned.end_col.map(|b| adj_axis(b, self.col_offset) + 1);
723
724                let start_row_abs = owned.start_row.map(|b| b.abs).unwrap_or(false);
725                let start_col_abs = owned.start_col.map(|b| b.abs).unwrap_or(false);
726                let end_row_abs = owned.end_row.map(|b| b.abs).unwrap_or(false);
727                let end_col_abs = owned.end_col.map(|b| b.abs).unwrap_or(false);
728
729                ReferenceType::Range {
730                    sheet: sheet.clone(),
731                    start_row: adj_start_row,
732                    start_col: adj_start_col,
733                    end_row: adj_end_row,
734                    end_col: adj_end_col,
735                    start_row_abs,
736                    start_col_abs,
737                    end_row_abs,
738                    end_col_abs,
739                }
740            }
741            _ => reference.clone(),
742        }
743    }
744}
745
746/// Helper for adjusting references to moved ranges.
747/// This is used when a block of cells is moved; any formula references to cells
748/// fully inside the source rectangle are translated to the destination.
749pub struct MoveReferenceAdjuster {
750    from_sheet_id: crate::SheetId,
751    from_sheet_name: String,
752    from_start_row: u32,
753    from_start_col: u32,
754    from_end_row: u32,
755    from_end_col: u32,
756    to_sheet_id: crate::SheetId,
757    to_sheet_name: String,
758    row_offset: i32,
759    col_offset: i32,
760}
761
762impl MoveReferenceAdjuster {
763    pub fn new(
764        from_sheet_id: crate::SheetId,
765        from_sheet_name: String,
766        from_start_row: u32,
767        from_start_col: u32,
768        from_end_row: u32,
769        from_end_col: u32,
770        to_sheet_id: crate::SheetId,
771        to_sheet_name: String,
772        row_offset: i32,
773        col_offset: i32,
774    ) -> Self {
775        Self {
776            from_sheet_id,
777            from_sheet_name,
778            from_start_row,
779            from_start_col,
780            from_end_row,
781            from_end_col,
782            to_sheet_id,
783            to_sheet_name,
784            row_offset,
785            col_offset,
786        }
787    }
788
789    pub fn adjust_if_references(
790        &self,
791        formula: &ASTNode,
792        formula_sheet_id: crate::SheetId,
793    ) -> Option<ASTNode> {
794        let (adjusted, changed) = self.adjust_ast_inner(formula, formula_sheet_id);
795        if changed { Some(adjusted) } else { None }
796    }
797
798    fn adjust_ast_inner(&self, ast: &ASTNode, formula_sheet_id: crate::SheetId) -> (ASTNode, bool) {
799        match &ast.node_type {
800            ASTNodeType::Reference {
801                original,
802                reference,
803            } => {
804                let (adjusted_ref, changed) = self.adjust_reference(reference, formula_sheet_id);
805                if !changed {
806                    return (ast.clone(), false);
807                }
808                (
809                    ASTNode {
810                        node_type: ASTNodeType::Reference {
811                            original: original.clone(),
812                            reference: adjusted_ref,
813                        },
814                        source_token: ast.source_token.clone(),
815                        contains_volatile: ast.contains_volatile,
816                    },
817                    true,
818                )
819            }
820            ASTNodeType::BinaryOp { op, left, right } => {
821                let (l_adj, l_ch) = self.adjust_ast_inner(left, formula_sheet_id);
822                let (r_adj, r_ch) = self.adjust_ast_inner(right, formula_sheet_id);
823                if !l_ch && !r_ch {
824                    return (ast.clone(), false);
825                }
826                (
827                    ASTNode {
828                        node_type: ASTNodeType::BinaryOp {
829                            op: op.clone(),
830                            left: Box::new(l_adj),
831                            right: Box::new(r_adj),
832                        },
833                        source_token: ast.source_token.clone(),
834                        contains_volatile: ast.contains_volatile,
835                    },
836                    true,
837                )
838            }
839            ASTNodeType::UnaryOp { op, expr } => {
840                let (e_adj, e_ch) = self.adjust_ast_inner(expr, formula_sheet_id);
841                if !e_ch {
842                    return (ast.clone(), false);
843                }
844                (
845                    ASTNode {
846                        node_type: ASTNodeType::UnaryOp {
847                            op: op.clone(),
848                            expr: Box::new(e_adj),
849                        },
850                        source_token: ast.source_token.clone(),
851                        contains_volatile: ast.contains_volatile,
852                    },
853                    true,
854                )
855            }
856            ASTNodeType::Function { name, args } => {
857                let mut any = false;
858                let new_args: Vec<_> = args
859                    .iter()
860                    .map(|a| {
861                        let (adj, ch) = self.adjust_ast_inner(a, formula_sheet_id);
862                        any |= ch;
863                        adj
864                    })
865                    .collect();
866                if !any {
867                    return (ast.clone(), false);
868                }
869                (
870                    ASTNode {
871                        node_type: ASTNodeType::Function {
872                            name: name.clone(),
873                            args: new_args,
874                        },
875                        source_token: ast.source_token.clone(),
876                        contains_volatile: ast.contains_volatile,
877                    },
878                    true,
879                )
880            }
881            ASTNodeType::Array(rows) => {
882                let mut any = false;
883                let new_rows: Vec<_> = rows
884                    .iter()
885                    .map(|row| {
886                        row.iter()
887                            .map(|c| {
888                                let (adj, ch) = self.adjust_ast_inner(c, formula_sheet_id);
889                                any |= ch;
890                                adj
891                            })
892                            .collect()
893                    })
894                    .collect();
895                if !any {
896                    return (ast.clone(), false);
897                }
898                (
899                    ASTNode {
900                        node_type: ASTNodeType::Array(new_rows),
901                        source_token: ast.source_token.clone(),
902                        contains_volatile: ast.contains_volatile,
903                    },
904                    true,
905                )
906            }
907            _ => (ast.clone(), false),
908        }
909    }
910
911    fn adjust_reference(
912        &self,
913        reference: &formualizer_parse::parser::ReferenceType,
914        formula_sheet_id: crate::SheetId,
915    ) -> (formualizer_parse::parser::ReferenceType, bool) {
916        use formualizer_parse::parser::ReferenceType;
917
918        let sheet_matches_source = |sheet: &Option<String>| {
919            if let Some(name) = sheet.as_deref() {
920                name == self.from_sheet_name
921            } else {
922                formula_sheet_id == self.from_sheet_id
923            }
924        };
925
926        if !sheet_matches_source(match reference {
927            ReferenceType::Cell { sheet, .. } => sheet,
928            ReferenceType::Range { sheet, .. } => sheet,
929            _ => &None,
930        }) {
931            return (reference.clone(), false);
932        }
933
934        let Some(shared) = reference.to_sheet_ref_lossy() else {
935            return (reference.clone(), false);
936        };
937
938        match (reference, shared) {
939            (ReferenceType::Cell { sheet, .. }, crate::reference::SharedRef::Cell(cell)) => {
940                let owned = cell.into_owned();
941                let row0 = owned.coord.row();
942                let col0 = owned.coord.col();
943                let row_abs = owned.coord.row_abs();
944                let col_abs = owned.coord.col_abs();
945
946                if row0 < self.from_start_row
947                    || row0 > self.from_end_row
948                    || col0 < self.from_start_col
949                    || col0 > self.from_end_col
950                {
951                    return (reference.clone(), false);
952                }
953
954                let new_row0 = (row0 as i32 + self.row_offset).max(0) as u32;
955                let new_col0 = (col0 as i32 + self.col_offset).max(0) as u32;
956
957                let new_sheet = if self.to_sheet_id != self.from_sheet_id {
958                    Some(self.to_sheet_name.clone())
959                } else {
960                    sheet.clone()
961                };
962
963                (
964                    ReferenceType::Cell {
965                        sheet: new_sheet,
966                        row: new_row0 + 1,
967                        col: new_col0 + 1,
968                        row_abs,
969                        col_abs,
970                    },
971                    true,
972                )
973            }
974            (ReferenceType::Range { sheet, .. }, crate::reference::SharedRef::Range(range)) => {
975                let owned = range.into_owned();
976                let (Some(sr), Some(sc), Some(er), Some(ec)) = (
977                    owned.start_row,
978                    owned.start_col,
979                    owned.end_row,
980                    owned.end_col,
981                ) else {
982                    return (reference.clone(), false);
983                };
984
985                let sr0 = sr.index;
986                let sc0 = sc.index;
987                let er0 = er.index;
988                let ec0 = ec.index;
989                let start_row_abs = sr.abs;
990                let start_col_abs = sc.abs;
991                let end_row_abs = er.abs;
992                let end_col_abs = ec.abs;
993
994                let fully_contained = sr0 >= self.from_start_row
995                    && er0 <= self.from_end_row
996                    && sc0 >= self.from_start_col
997                    && ec0 <= self.from_end_col;
998                if !fully_contained {
999                    return (reference.clone(), false);
1000                }
1001
1002                let new_sr0 = (sr0 as i32 + self.row_offset).max(0) as u32;
1003                let new_er0 = (er0 as i32 + self.row_offset).max(0) as u32;
1004                let new_sc0 = (sc0 as i32 + self.col_offset).max(0) as u32;
1005                let new_ec0 = (ec0 as i32 + self.col_offset).max(0) as u32;
1006
1007                let new_sheet = if self.to_sheet_id != self.from_sheet_id {
1008                    Some(self.to_sheet_name.clone())
1009                } else {
1010                    sheet.clone()
1011                };
1012
1013                (
1014                    ReferenceType::Range {
1015                        sheet: new_sheet,
1016                        start_row: Some(new_sr0 + 1),
1017                        start_col: Some(new_sc0 + 1),
1018                        end_row: Some(new_er0 + 1),
1019                        end_col: Some(new_ec0 + 1),
1020                        start_row_abs,
1021                        start_col_abs,
1022                        end_row_abs,
1023                        end_col_abs,
1024                    },
1025                    true,
1026                )
1027            }
1028            _ => (reference.clone(), false),
1029        }
1030    }
1031}
1032
1033#[cfg(test)]
1034mod tests {
1035    use super::*;
1036    use formualizer_parse::parser::parse;
1037    use std::sync::OnceLock;
1038
1039    fn context(sheet_id: SheetId) -> ReferenceContext<'static> {
1040        static REGISTRY: OnceLock<SheetRegistry> = OnceLock::new();
1041        let registry = REGISTRY.get_or_init(|| {
1042            let mut registry = SheetRegistry::new();
1043            registry.id_for("Sheet1");
1044            registry.id_for("Other");
1045            registry.id_for("#REF");
1046            registry
1047        });
1048        ReferenceContext::new(sheet_id, registry)
1049    }
1050
1051    fn format_formula(ast: &ASTNode) -> String {
1052        // TODO: Use the actual formualizer_parse::parser::to_string when available
1053        // For now, a simple representation
1054        format!("{ast:?}")
1055    }
1056
1057    #[test]
1058    fn context_free_adjuster_compatibility_api_remains_available() {
1059        let adjuster = ReferenceAdjuster::new();
1060        let ast = parse("=A1").unwrap();
1061        let op = ShiftOperation::InsertRows {
1062            sheet_id: 0,
1063            before: 0,
1064            count: 1,
1065        };
1066
1067        let _ = adjuster.adjust_ast(&ast, &op);
1068        let _ = adjuster.adjust_ast_with_policy(&ast, &op, AbsShiftPolicy::Track);
1069        let _ = adjuster.adjust_ast_if_changed(&ast, &op);
1070        let _ = adjuster.adjust_ast_if_changed_with_policy(&ast, &op, AbsShiftPolicy::Track);
1071    }
1072
1073    #[test]
1074    fn adjust_ast_if_changed_returns_none_for_unaffected_column_insert() {
1075        let adjuster = ReferenceAdjuster::new();
1076        let ast = parse("=A1+1").unwrap();
1077
1078        let adjusted = adjuster.adjust_ast_if_changed_in_context(
1079            &ast,
1080            &ShiftOperation::InsertColumns {
1081                sheet_id: 0,
1082                before: 3,
1083                count: 1,
1084            },
1085            &context(0),
1086        );
1087
1088        assert!(adjusted.is_none());
1089    }
1090
1091    #[test]
1092    fn adjust_ast_if_changed_returns_adjusted_for_insert_before_a() {
1093        let adjuster = ReferenceAdjuster::new();
1094        let ast = parse("=A1+1").unwrap();
1095
1096        let adjusted = adjuster
1097            .adjust_ast_if_changed_in_context(
1098                &ast,
1099                &ShiftOperation::InsertColumns {
1100                    sheet_id: 0,
1101                    before: 0,
1102                    count: 1,
1103                },
1104                &context(0),
1105            )
1106            .expect("A1 reference should shift");
1107
1108        if let ASTNodeType::BinaryOp { left, .. } = &adjusted.node_type
1109            && let ASTNodeType::Reference {
1110                reference: formualizer_parse::parser::ReferenceType::Cell { row, col, .. },
1111                ..
1112            } = &left.node_type
1113        {
1114            assert_eq!(*row, 1);
1115            assert_eq!(*col, 2);
1116            return;
1117        }
1118
1119        panic!("expected adjusted A1 reference to become B1");
1120    }
1121
1122    #[test]
1123    fn test_reference_adjustment_on_row_insert() {
1124        let adjuster = ReferenceAdjuster::new();
1125
1126        // Formula: =A5+B10
1127        let ast = parse("=A5+B10").unwrap();
1128
1129        // Insert 2 rows before row 7
1130        let adjusted = adjuster.adjust_ast_in_context(
1131            &ast,
1132            &ShiftOperation::InsertRows {
1133                sheet_id: 0,
1134                before: 7,
1135                count: 2,
1136            },
1137            &context(0),
1138        );
1139
1140        // A5 unchanged (before insert point), B10 -> B12
1141        // Verify by checking the AST structure
1142        if let ASTNodeType::BinaryOp { left, right, .. } = &adjusted.node_type {
1143            if let ASTNodeType::Reference {
1144                reference: formualizer_parse::parser::ReferenceType::Cell { row, col, .. },
1145                ..
1146            } = &left.node_type
1147            {
1148                assert_eq!(*row, 5); // A5 unchanged
1149                assert_eq!(*col, 1);
1150            }
1151            if let ASTNodeType::Reference {
1152                reference: formualizer_parse::parser::ReferenceType::Cell { row, col, .. },
1153                ..
1154            } = &right.node_type
1155            {
1156                assert_eq!(*row, 12); // B10 -> B12
1157                assert_eq!(*col, 2);
1158            }
1159        }
1160    }
1161
1162    #[test]
1163    fn test_reference_adjustment_on_column_delete() {
1164        let adjuster = ReferenceAdjuster::new();
1165
1166        // Formula: =C1+F1
1167        let ast = parse("=C1+F1").unwrap();
1168
1169        // Delete columns B and C (columns 2 and 3)
1170        let adjusted = adjuster.adjust_ast_in_context(
1171            &ast,
1172            &ShiftOperation::DeleteColumns {
1173                sheet_id: 0,
1174                start: 2, // Column B
1175                count: 2,
1176            },
1177            &context(0),
1178        );
1179
1180        // C1 -> #REF! (deleted), F1 -> D1 (shifted left by 2).
1181        let ASTNodeType::BinaryOp { left, right, .. } = &adjusted.node_type else {
1182            panic!("expected adjusted binary expression, got {adjusted:?}");
1183        };
1184        match &left.node_type {
1185            ASTNodeType::Literal(LiteralValue::Error(error)) => {
1186                assert_eq!(error.kind, ExcelErrorKind::Ref);
1187                assert!(left.source_token.is_none());
1188            }
1189            other => panic!("expected deleted C1 to become a #REF! literal, got {other:?}"),
1190        }
1191        match &right.node_type {
1192            ASTNodeType::Reference {
1193                original,
1194                reference: ReferenceType::Cell { row, col, .. },
1195            } => {
1196                assert_eq!(original, "D1");
1197                assert_eq!(*row, 1); // Row unchanged
1198                assert_eq!(*col, 4); // F1 (col 6) -> D1 (col 4)
1199                assert!(right.source_token.is_none());
1200            }
1201            other => panic!("expected surviving F1 reference to shift to D1, got {other:?}"),
1202        }
1203        assert!(adjusted.source_token.is_none());
1204    }
1205
1206    #[test]
1207    fn test_range_reference_adjustment() {
1208        let adjuster = ReferenceAdjuster::new();
1209
1210        // Formula: =SUM(A1:A10)
1211        let ast = parse("=SUM(A1:A10)").unwrap();
1212
1213        // Insert 3 rows before row 5
1214        let adjusted = adjuster.adjust_ast_in_context(
1215            &ast,
1216            &ShiftOperation::InsertRows {
1217                sheet_id: 0,
1218                before: 5,
1219                count: 3,
1220            },
1221            &context(0),
1222        );
1223
1224        // Range should expand: A1:A10 -> A1:A13
1225        if let ASTNodeType::Function { args, .. } = &adjusted.node_type
1226            && let Some(ASTNodeType::Reference {
1227                reference:
1228                    formualizer_parse::parser::ReferenceType::Range {
1229                        start_row, end_row, ..
1230                    },
1231                ..
1232            }) = args.first().map(|arg| &arg.node_type)
1233        {
1234            assert_eq!(start_row.unwrap_or(0), 1); // A1 start unchanged
1235            assert_eq!(end_row.unwrap_or(0), 13); // A10 -> A13
1236        }
1237    }
1238
1239    #[test]
1240    fn test_relative_reference_copy() {
1241        let adjuster = RelativeReferenceAdjuster::new(2, 3); // Move 2 rows down, 3 cols right
1242
1243        // Formula: =A1+B2
1244        let ast = parse("=A1+B2").unwrap();
1245        let adjusted = adjuster.adjust_formula(&ast);
1246
1247        // A1 -> D3, B2 -> E4
1248        if let ASTNodeType::BinaryOp { left, right, .. } = &adjusted.node_type {
1249            if let ASTNodeType::Reference {
1250                reference: formualizer_parse::parser::ReferenceType::Cell { row, col, .. },
1251                ..
1252            } = &left.node_type
1253            {
1254                assert_eq!(*row, 3); // A1 (1,1) -> D3 (3,4)
1255                assert_eq!(*col, 4);
1256            }
1257            if let ASTNodeType::Reference {
1258                reference: formualizer_parse::parser::ReferenceType::Cell { row, col, .. },
1259                ..
1260            } = &right.node_type
1261            {
1262                assert_eq!(*row, 4); // B2 (2,2) -> E4 (4,5)
1263                assert_eq!(*col, 5);
1264            }
1265        }
1266    }
1267
1268    #[test]
1269    fn test_absolute_row_tracks_row_insert() {
1270        let adjuster = ReferenceAdjuster::new();
1271
1272        // Test with absolute row references ($5)
1273        let cell_abs_row = CellRef::new(
1274            0,
1275            Coord::new(5, 2, true, false), // Row 5 absolute, col 2 relative
1276        );
1277
1278        let op = ShiftOperation::InsertRows {
1279            sheet_id: 0,
1280            before: 3,
1281            count: 2,
1282        };
1283
1284        // Issue #168 policy: absolute references TRACK structural shifts —
1285        // the `$` pins copy/fill relocation only.
1286        let result = adjuster.adjust_cell_ref(&cell_abs_row, &op);
1287        assert!(result.is_some());
1288        let adjusted = result.unwrap();
1289        assert_eq!(adjusted.coord.row(), 7); // Row 5 -> 7 (shifted)
1290        assert_eq!(adjusted.coord.col(), 2); // Column unchanged
1291        assert!(adjusted.coord.row_abs()); // Anchor flag preserved
1292        assert!(!adjusted.coord.col_abs());
1293
1294        // Legacy Pin policy (named-range definitions) keeps the old behavior.
1295        let pinned = adjuster
1296            .adjust_cell_ref_with_policy(&cell_abs_row, &op, AbsShiftPolicy::Pin)
1297            .unwrap();
1298        assert_eq!(pinned.coord.row(), 5);
1299        assert!(pinned.coord.row_abs());
1300    }
1301
1302    #[test]
1303    fn test_absolute_column_tracks_column_delete() {
1304        let adjuster = ReferenceAdjuster::new();
1305
1306        // Test with absolute column references ($B)
1307        let cell_abs_col = CellRef::new(
1308            0,
1309            Coord::new(5, 2, false, true), // Row 5 relative, col 2 absolute
1310        );
1311
1312        let op = ShiftOperation::DeleteColumns {
1313            sheet_id: 0,
1314            start: 1,
1315            count: 1,
1316        };
1317
1318        // Issue #168 policy: the absolute column shifts left with the delete.
1319        let result = adjuster.adjust_cell_ref(&cell_abs_col, &op);
1320        assert!(result.is_some());
1321        let adjusted = result.unwrap();
1322        assert_eq!(adjusted.coord.row(), 5); // Row unchanged
1323        assert_eq!(adjusted.coord.col(), 1); // Column 2 -> 1 (shifted left)
1324        assert!(!adjusted.coord.row_abs());
1325        assert!(adjusted.coord.col_abs()); // Anchor flag preserved
1326
1327        // Legacy Pin policy keeps the old behavior.
1328        let pinned = adjuster
1329            .adjust_cell_ref_with_policy(&cell_abs_col, &op, AbsShiftPolicy::Pin)
1330            .unwrap();
1331        assert_eq!(pinned.coord.col(), 2);
1332        assert!(pinned.coord.col_abs());
1333    }
1334
1335    #[test]
1336    fn test_absolute_reference_deleted_becomes_ref_error() {
1337        let adjuster = ReferenceAdjuster::new();
1338
1339        // $F$1 with the delete removing row 0 (the target row): the
1340        // reference is gone, exactly like a relative reference (issue #168).
1341        let fully_abs = CellRef::new(0, Coord::new(0, 5, true, true));
1342        let result = adjuster.adjust_cell_ref(
1343            &fully_abs,
1344            &ShiftOperation::DeleteRows {
1345                sheet_id: 0,
1346                start: 0,
1347                count: 1,
1348            },
1349        );
1350        assert!(result.is_none(), "deleted absolute target must be #REF!");
1351    }
1352
1353    #[test]
1354    fn test_mixed_absolute_relative_references() {
1355        let adjuster = ReferenceAdjuster::new();
1356
1357        // Test 1: $A5 (col absolute, row relative) with row insertion
1358        let mixed1 = CellRef::new(
1359            0,
1360            Coord::new(5, 1, false, true), // Row 5 relative, col 1 absolute
1361        );
1362
1363        let result1 = adjuster.adjust_cell_ref(
1364            &mixed1,
1365            &ShiftOperation::InsertRows {
1366                sheet_id: 0,
1367                before: 3,
1368                count: 2,
1369            },
1370        );
1371
1372        assert!(result1.is_some());
1373        let adj1 = result1.unwrap();
1374        assert_eq!(adj1.coord.row(), 7); // Row 5 -> 7 (shifted)
1375        assert_eq!(adj1.coord.col(), 1); // Column stays at 1 (absolute)
1376
1377        // Test 2: B$10 (col relative, row absolute) with column deletion
1378        let mixed2 = CellRef::new(
1379            0,
1380            Coord::new(10, 3, true, false), // Row 10 absolute, col 3 relative
1381        );
1382
1383        let result2 = adjuster.adjust_cell_ref(
1384            &mixed2,
1385            &ShiftOperation::DeleteColumns {
1386                sheet_id: 0,
1387                start: 1,
1388                count: 1,
1389            },
1390        );
1391
1392        assert!(result2.is_some());
1393        let adj2 = result2.unwrap();
1394        assert_eq!(adj2.coord.row(), 10); // Row stays at 10 (absolute)
1395        assert_eq!(adj2.coord.col(), 2); // Column 3 -> 2 (shifted left)
1396    }
1397
1398    #[test]
1399    fn test_fully_absolute_reference_tracks_structural_ops() {
1400        let adjuster = ReferenceAdjuster::new();
1401
1402        // Test $B$2 - fully absolute (0-based row 1, col 1)
1403        let fully_abs = CellRef::new(
1404            0,
1405            Coord::new(1, 1, true, true), // Both row and col absolute
1406        );
1407
1408        // Issue #168 policy: absolute references track structural shifts.
1409
1410        // Insert rows at/above the target: the target row moves down.
1411        let insert = ShiftOperation::InsertRows {
1412            sheet_id: 0,
1413            before: 1,
1414            count: 5,
1415        };
1416        let result1 = adjuster.adjust_cell_ref(&fully_abs, &insert).unwrap();
1417        assert_eq!(result1.coord.row(), 6); // Row 1 -> 6 (shifted)
1418        assert_eq!(result1.coord.col(), 1);
1419        assert!(result1.coord.row_abs());
1420        assert!(result1.coord.col_abs());
1421
1422        // Delete a column before the target: the target column moves left.
1423        let delete = ShiftOperation::DeleteColumns {
1424            sheet_id: 0,
1425            start: 0,
1426            count: 1,
1427        };
1428        let result2 = adjuster.adjust_cell_ref(&fully_abs, &delete).unwrap();
1429        assert_eq!(result2.coord.row(), 1);
1430        assert_eq!(result2.coord.col(), 0); // Col 1 -> 0 (shifted left)
1431        assert!(result2.coord.row_abs());
1432        assert!(result2.coord.col_abs());
1433
1434        // Legacy Pin policy (named ranges): nothing moves.
1435        let pinned1 = adjuster
1436            .adjust_cell_ref_with_policy(&fully_abs, &insert, AbsShiftPolicy::Pin)
1437            .unwrap();
1438        assert_eq!(pinned1.coord.row(), 1);
1439        assert_eq!(pinned1.coord.col(), 1);
1440        let pinned2 = adjuster
1441            .adjust_cell_ref_with_policy(&fully_abs, &delete, AbsShiftPolicy::Pin)
1442            .unwrap();
1443        assert_eq!(pinned2.coord.row(), 1);
1444        assert_eq!(pinned2.coord.col(), 1);
1445    }
1446
1447    #[test]
1448    fn test_deleted_reference_becomes_ref_error() {
1449        let adjuster = ReferenceAdjuster::new();
1450
1451        // Test deleting a cell that's referenced
1452        let cell = CellRef::new(
1453            0,
1454            Coord::new(5, 3, false, false), // Row 5, col 3, both relative
1455        );
1456
1457        // Delete the row containing the cell
1458        let result = adjuster.adjust_cell_ref(
1459            &cell,
1460            &ShiftOperation::DeleteRows {
1461                sheet_id: 0,
1462                start: 5,
1463                count: 1,
1464            },
1465        );
1466
1467        // Should return None to indicate deletion
1468        assert!(result.is_none());
1469
1470        // Delete the column containing the cell
1471        let result2 = adjuster.adjust_cell_ref(
1472            &cell,
1473            &ShiftOperation::DeleteColumns {
1474                sheet_id: 0,
1475                start: 3,
1476                count: 1,
1477            },
1478        );
1479
1480        // Should return None to indicate deletion
1481        assert!(result2.is_none());
1482    }
1483
1484    #[test]
1485    fn test_range_expansion_on_insert() {
1486        let adjuster = ReferenceAdjuster::new();
1487
1488        // Test that ranges expand when rows/cols are inserted within them
1489        let ast = parse("=SUM(B2:D10)").unwrap();
1490
1491        // Insert rows in the middle of the range
1492        let adjusted = adjuster.adjust_ast_in_context(
1493            &ast,
1494            &ShiftOperation::InsertRows {
1495                sheet_id: 0,
1496                before: 5,
1497                count: 3,
1498            },
1499            &context(0),
1500        );
1501
1502        // Range should expand: B2:D10 -> B2:D13
1503        if let ASTNodeType::Function { args, .. } = &adjusted.node_type
1504            && let Some(ASTNodeType::Reference {
1505                reference:
1506                    formualizer_parse::parser::ReferenceType::Range {
1507                        start_row,
1508                        end_row,
1509                        start_col,
1510                        end_col,
1511                        ..
1512                    },
1513                ..
1514            }) = args.first().map(|arg| &arg.node_type)
1515        {
1516            assert_eq!(*start_row, Some(2)); // Start unchanged
1517            assert_eq!(*end_row, Some(13)); // End expanded from 10 to 13
1518            assert_eq!(*start_col, Some(2)); // B column
1519            assert_eq!(*end_col, Some(4)); // D column
1520        }
1521    }
1522
1523    #[test]
1524    fn test_range_contraction_on_delete() {
1525        let adjuster = ReferenceAdjuster::new();
1526
1527        // Test that ranges contract when rows/cols are deleted within them
1528        let ast = parse("=SUM(A5:A20)").unwrap();
1529
1530        // Delete rows in the middle of the range
1531        let adjusted = adjuster.adjust_ast_in_context(
1532            &ast,
1533            &ShiftOperation::DeleteRows {
1534                sheet_id: 0,
1535                start: 10,
1536                count: 5,
1537            },
1538            &context(0),
1539        );
1540
1541        // Range should contract: A5:A20 -> A5:A15
1542        if let ASTNodeType::Function { args, .. } = &adjusted.node_type
1543            && let Some(ASTNodeType::Reference {
1544                reference:
1545                    formualizer_parse::parser::ReferenceType::Range {
1546                        start_row, end_row, ..
1547                    },
1548                ..
1549            }) = args.first().map(|arg| &arg.node_type)
1550        {
1551            assert_eq!(*start_row, Some(5)); // Start unchanged
1552            assert_eq!(*end_row, Some(15)); // End contracted from 20 to 15
1553        }
1554    }
1555
1556    #[test]
1557    fn fully_deleted_range_becomes_ref_error_literal() {
1558        let ast = parse("=SUM(A2:A4)").unwrap();
1559        let adjusted = ReferenceAdjuster::new().adjust_ast_in_context(
1560            &ast,
1561            &ShiftOperation::DeleteRows {
1562                sheet_id: 0,
1563                start: 1,
1564                count: 3,
1565            },
1566            &context(0),
1567        );
1568
1569        let ASTNodeType::Function { args, .. } = &adjusted.node_type else {
1570            panic!("expected SUM function, got {adjusted:?}");
1571        };
1572        match &args[0].node_type {
1573            ASTNodeType::Literal(LiteralValue::Error(error)) => {
1574                assert_eq!(error.kind, ExcelErrorKind::Ref)
1575            }
1576            other => panic!("expected deleted range to become #REF!, got {other:?}"),
1577        }
1578    }
1579
1580    #[test]
1581    fn structural_adjustment_respects_formula_and_qualified_sheets() {
1582        let adjuster = ReferenceAdjuster::new();
1583        let op = ShiftOperation::DeleteRows {
1584            sheet_id: 0,
1585            start: 0,
1586            count: 1,
1587        };
1588
1589        // An unqualified reference belongs to the formula's own sheet.
1590        assert!(
1591            adjuster
1592                .adjust_ast_if_changed_in_context(&parse("=A1").unwrap(), &op, &context(1))
1593                .is_none()
1594        );
1595
1596        // Explicit references change only when their named sheet is edited.
1597        assert!(
1598            adjuster
1599                .adjust_ast_if_changed_in_context(&parse("=Other!A1").unwrap(), &op, &context(0))
1600                .is_none()
1601        );
1602        let matching = adjuster
1603            .adjust_ast_if_changed_in_context(&parse("=Sheet1!A1").unwrap(), &op, &context(1))
1604            .expect("qualified reference to edited sheet must change");
1605        assert!(matches!(
1606            matching.node_type,
1607            ASTNodeType::Literal(LiteralValue::Error(ref error))
1608                if error.kind == ExcelErrorKind::Ref
1609        ));
1610
1611        // `#REF` remains a legitimate quoted worksheet name, never a magic marker.
1612        let real_ref_sheet = parse("='#REF'!A1").unwrap();
1613        assert!(
1614            adjuster
1615                .adjust_ast_if_changed_in_context(&real_ref_sheet, &op, &context(0))
1616                .is_none()
1617        );
1618        let shifted = adjuster
1619            .adjust_ast_if_changed_in_context(
1620                &real_ref_sheet,
1621                &ShiftOperation::InsertRows {
1622                    sheet_id: 2,
1623                    before: 0,
1624                    count: 1,
1625                },
1626                &context(0),
1627            )
1628            .expect("a real #REF sheet reference should shift normally");
1629        match shifted.node_type {
1630            ASTNodeType::Reference {
1631                original,
1632                reference:
1633                    ReferenceType::Cell {
1634                        sheet, row, col, ..
1635                    },
1636            } => {
1637                assert_eq!(original, "'#REF'!A2");
1638                assert_eq!(sheet.as_deref(), Some("#REF"));
1639                assert_eq!((row, col), (2, 1));
1640            }
1641            other => panic!("expected a shifted ordinary sheet reference, got {other:?}"),
1642        }
1643    }
1644
1645    #[test]
1646    fn whole_axis_ranges_adjust_only_on_their_bounded_axis() {
1647        let adjuster = ReferenceAdjuster::new();
1648        let whole_col = parse("=SUM(A:A)").unwrap();
1649        assert!(
1650            adjuster
1651                .adjust_ast_if_changed_in_context(
1652                    &whole_col,
1653                    &ShiftOperation::DeleteRows {
1654                        sheet_id: 0,
1655                        start: 0,
1656                        count: 1,
1657                    },
1658                    &context(0),
1659                )
1660                .is_none()
1661        );
1662        let deleted_col = adjuster.adjust_ast_in_context(
1663            &whole_col,
1664            &ShiftOperation::DeleteColumns {
1665                sheet_id: 0,
1666                start: 0,
1667                count: 1,
1668            },
1669            &context(0),
1670        );
1671        let ASTNodeType::Function { args, .. } = &deleted_col.node_type else {
1672            panic!("expected SUM function");
1673        };
1674        assert!(matches!(
1675            args[0].node_type,
1676            ASTNodeType::Literal(LiteralValue::Error(ref error))
1677                if error.kind == ExcelErrorKind::Ref
1678        ));
1679
1680        let whole_row = parse("=SUM(1:1)").unwrap();
1681        assert!(
1682            adjuster
1683                .adjust_ast_if_changed_in_context(
1684                    &whole_row,
1685                    &ShiftOperation::DeleteColumns {
1686                        sheet_id: 0,
1687                        start: 0,
1688                        count: 1,
1689                    },
1690                    &context(0),
1691                )
1692                .is_none()
1693        );
1694        let deleted_row = adjuster.adjust_ast_in_context(
1695            &whole_row,
1696            &ShiftOperation::DeleteRows {
1697                sheet_id: 0,
1698                start: 0,
1699                count: 1,
1700            },
1701            &context(0),
1702        );
1703        let ASTNodeType::Function { args, .. } = &deleted_row.node_type else {
1704            panic!("expected SUM function");
1705        };
1706        assert!(matches!(
1707            args[0].node_type,
1708            ASTNodeType::Literal(LiteralValue::Error(ref error))
1709                if error.kind == ExcelErrorKind::Ref
1710        ));
1711    }
1712
1713    #[test]
1714    fn call_and_array_children_receive_literal_rewrites() {
1715        let adjuster = ReferenceAdjuster::new();
1716        let op = ShiftOperation::DeleteColumns {
1717            sheet_id: 0,
1718            start: 0,
1719            count: 1,
1720        };
1721
1722        let call =
1723            adjuster.adjust_ast_in_context(&parse("=LAMBDA(x,x)(A1)").unwrap(), &op, &context(0));
1724        let ASTNodeType::Call { args, .. } = &call.node_type else {
1725            panic!("expected immediate call, got {call:?}");
1726        };
1727        assert!(matches!(
1728            args[0].node_type,
1729            ASTNodeType::Literal(LiteralValue::Error(ref error))
1730                if error.kind == ExcelErrorKind::Ref
1731        ));
1732        assert!(call.source_token.is_none());
1733
1734        let array = adjuster.adjust_ast_in_context(&parse("={A1,B1}").unwrap(), &op, &context(0));
1735        let ASTNodeType::Array(rows) = &array.node_type else {
1736            panic!("expected array, got {array:?}");
1737        };
1738        assert!(matches!(
1739            rows[0][0].node_type,
1740            ASTNodeType::Literal(LiteralValue::Error(ref error))
1741                if error.kind == ExcelErrorKind::Ref
1742        ));
1743        match &rows[0][1].node_type {
1744            ASTNodeType::Reference {
1745                original,
1746                reference: ReferenceType::Cell { row, col, .. },
1747            } => {
1748                assert_eq!(original, "A1");
1749                assert_eq!((*row, *col), (1, 1));
1750            }
1751            other => panic!("expected B1 to shift to A1, got {other:?}"),
1752        }
1753    }
1754}