Skip to main content

formualizer_eval/engine/graph/editor/
reference_adjuster.rs

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