// Copyright 2026 OfficeCLI (https://OfficeCLI.AI) // SPDX-License-Identifier: Apache-2.0 using System.Text; using System.Text.RegularExpressions; namespace OfficeCli.Core; /// /// Direction of insertion or deletion that triggered a formula reference shift. /// Insert directions shift refs by +1; delete directions shift refs by -1 /// and collapse refs that landed on the deleted index into #REF!. /// public enum FormulaShiftDirection { /// A column was inserted; cell-ref columns at or past insertIdx shift right by 1. ColumnsRight, /// A row was inserted; cell-ref rows at or past insertIdx shift down by 1. RowsDown, /// A column was deleted; refs to that column collapse to #REF!, refs past it shift left by 1. ColumnsLeft, /// A row was deleted; refs to that row collapse to #REF!, refs past it shift up by 1. RowsUp, } /// /// Rewrites Excel formula text after a column or row was inserted, so that /// references that previously pointed to a moved cell continue to point to /// the same cell. /// /// This is the regex-based "good enough" implementation (Path A). It /// handles the common ~90% of formulas: A1 / $A$1 / $A1 / A$1 single refs, /// A1:B5 ranges, sheet-qualified refs (Sheet2!A1, 'Sheet With Spaces'!A1), /// and skips string literals and structured-ref bracket content. It does /// NOT handle: cross-workbook refs ([Book]Sheet!A1), R1C1 notation, /// whole-column (A:A) or whole-row (1:1) refs, or structured table refs /// (Table1[Col1]) — those pass through verbatim. /// /// The public API is intentionally minimal so a future tokenizer-based /// implementation (Path B) can replace the body of /// without touching call sites or tests. /// public static class FormulaRefShifter { // One regex matches either a single A1 ref or a range, optionally // sheet-qualified. Whole-col / whole-row refs are NOT matched here — // they require digits in r1, which is mandatory in this pattern. // // Capture groups: // sheet — optional sheet name (with surrounding quotes preserved) // c1, r1 — first cell column letters (with optional leading $) and row digits // c2, r2 — range end (or empty for single-cell) private static readonly Regex CellRefPattern = new( @"(?'(?:[^']|'')+'|[A-Za-z_][\w.]*)!)?" + @"(?\$?[A-Z]{1,3})(?\$?\d+)" + @"(?::(?\$?[A-Z]{1,3})(?\$?\d+))?" + // (?![\w(]) — also reject when followed by '(' so that function names // shaped like `LOG10` / `ATAN2` (col-letters + row-digits) are not // misread as cell refs. Cell refs are never followed by '('. @"(?![\w(])", RegexOptions.Compiled); /// /// Rewrite cell references in a formula by remapping their row numbers /// through an arbitrary mapping. Used by /// move/reorder operations where the change is not a uniform +1/-1 shift /// but a permutation of row indices (e.g. moving row 3 before row 2 /// produces the map {1→1, 2→3, 3→2}). /// /// Refs whose row is not in the map pass through unchanged. For /// ranges, both endpoints are remapped; if the result inverts (start > /// end) the original range is returned unchanged. Sheet-scope, string- /// literal, and structured-ref skip rules are identical to . /// public static string ApplyRowRenumberMap( string formula, string currentSheet, string modifiedSheet, IReadOnlyDictionary oldToNewRow) { if (string.IsNullOrEmpty(formula) || oldToNewRow.Count == 0) return formula; return WalkFormulaTokens(formula, chunk => RenumberRefsInChunk(chunk, currentSheet, modifiedSheet, oldToNewRow)); } /// /// Outer tokenize-skip walker shared by every FormulaRefShifter /// public entry point. Streams the formula char-by-char, copying string /// literals (with the Excel "" doubling escape) and bracket /// content (structured refs like Table1[Col1]; cross-workbook /// prefixes like [Book2]Sheet1!A1) verbatim. Hands every other /// contiguous chunk to , which runs /// the per-match cell-ref rewrite for that semantic (shift / renumber / /// copy-delta) and returns the rewritten chunk. /// private static string WalkFormulaTokens(string formula, Func chunkProcessor) { var sb = new StringBuilder(formula.Length); int i = 0; while (i < formula.Length) { char ch = formula[i]; if (ch == '"') { sb.Append(ch); i++; while (i < formula.Length) { sb.Append(formula[i]); if (formula[i] == '"') { if (i + 1 < formula.Length && formula[i + 1] == '"') { sb.Append(formula[i + 1]); i += 2; continue; } i++; break; } i++; } } else if (ch == '[') { int depth = 0; while (i < formula.Length) { char c = formula[i]; sb.Append(c); if (c == '[') depth++; else if (c == ']') { depth--; if (depth == 0) { i++; break; } } i++; } } else { int start = i; while (i < formula.Length && formula[i] != '"' && formula[i] != '[') i++; sb.Append(chunkProcessor(formula.AsSpan(start, i - start).ToString())); } } return sb.ToString(); } private static string RenumberRefsInChunk( string chunk, string currentSheet, string modifiedSheet, IReadOnlyDictionary oldToNewRow) { return CellRefPattern.Replace(chunk, m => { var sheetGroup = m.Groups["sheet"].Value; string targetSheet = string.IsNullOrEmpty(sheetGroup) ? currentSheet : (sheetGroup.StartsWith('\'') && sheetGroup.EndsWith('\'') ? sheetGroup[1..^1].Replace("''", "'") : sheetGroup); if (!targetSheet.Equals(modifiedSheet, StringComparison.OrdinalIgnoreCase)) return m.Value; string c1 = m.Groups["c1"].Value; string r1 = m.Groups["r1"].Value; string c2 = m.Groups["c2"].Value; string r2 = m.Groups["r2"].Value; bool isRange = !string.IsNullOrEmpty(c2); string sheetPrefix = string.IsNullOrEmpty(sheetGroup) ? "" : sheetGroup + "!"; string newR1 = RemapRow(r1, oldToNewRow); if (!isRange) return $"{sheetPrefix}{c1}{newR1}"; string newR2 = RemapRow(r2, oldToNewRow); // The range covers a contiguous SET of rows [r1..r2]. After // renumber, that set must remain contiguous (and represent // the same row content) for the new range to be a faithful // rewrite. If the mapped set is not contiguous or doesn't // match [min..max] of the new endpoints, fall back to the // original text rather than write a misleading ref. int Parse(string s) => int.Parse(s.StartsWith('$') ? s[1..] : s); int oldR1 = Parse(r1), oldR2 = Parse(r2); int newR1Int = Parse(newR1), newR2Int = Parse(newR2); if (!RangeRemapStillContiguous(oldR1, oldR2, newR1Int, newR2Int, oldToNewRow)) return m.Value; return $"{sheetPrefix}{c1}{newR1}:{c2}{newR2}"; }); } private static bool RangeRemapStillContiguous( int oldStart, int oldEnd, int newStart, int newEnd, IReadOnlyDictionary map) { if (oldStart > oldEnd) return false; int newMin = Math.Min(newStart, newEnd); int newMax = Math.Max(newStart, newEnd); // Build the mapped set and check it equals [newMin..newMax] exactly. var mappedSet = new HashSet(); for (int i = oldStart; i <= oldEnd; i++) { int mapped = map.TryGetValue(i, out var n) ? n : i; mappedSet.Add(mapped); } if (mappedSet.Count != (newMax - newMin + 1)) return false; for (int i = newMin; i <= newMax; i++) if (!mappedSet.Contains(i)) return false; return newStart <= newEnd; } private static string RemapRow(string rowPart, IReadOnlyDictionary map) { bool abs = rowPart.StartsWith('$'); int oldNum = int.Parse(abs ? rowPart[1..] : rowPart); if (!map.TryGetValue(oldNum, out var newNum)) return rowPart; return (abs ? "$" : "") + newNum; } /// /// Column-axis variant of . Same skip /// rules, sheet scope, and contiguity guard. Map keys/values are 1-based /// column indices (A=1, B=2, ...). /// public static string ApplyColRenumberMap( string formula, string currentSheet, string modifiedSheet, IReadOnlyDictionary oldToNewCol) { if (string.IsNullOrEmpty(formula) || oldToNewCol.Count == 0) return formula; return WalkFormulaTokens(formula, chunk => RenumberColRefsInChunk(chunk, currentSheet, modifiedSheet, oldToNewCol)); } private static string RenumberColRefsInChunk( string chunk, string currentSheet, string modifiedSheet, IReadOnlyDictionary oldToNewCol) { return CellRefPattern.Replace(chunk, m => { var sheetGroup = m.Groups["sheet"].Value; string targetSheet = string.IsNullOrEmpty(sheetGroup) ? currentSheet : (sheetGroup.StartsWith('\'') && sheetGroup.EndsWith('\'') ? sheetGroup[1..^1].Replace("''", "'") : sheetGroup); if (!targetSheet.Equals(modifiedSheet, StringComparison.OrdinalIgnoreCase)) return m.Value; string c1 = m.Groups["c1"].Value; string r1 = m.Groups["r1"].Value; string c2 = m.Groups["c2"].Value; string r2 = m.Groups["r2"].Value; bool isRange = !string.IsNullOrEmpty(c2); string sheetPrefix = string.IsNullOrEmpty(sheetGroup) ? "" : sheetGroup + "!"; string newC1 = RemapCol(c1, oldToNewCol); if (!isRange) return $"{sheetPrefix}{newC1}{r1}"; string newC2 = RemapCol(c2, oldToNewCol); int Idx(string s) => ColumnLettersToIndex(s.StartsWith('$') ? s[1..] : s); int oldC1Idx = Idx(c1), oldC2Idx = Idx(c2); int newC1Idx = Idx(newC1), newC2Idx = Idx(newC2); if (!RangeRemapStillContiguous(oldC1Idx, oldC2Idx, newC1Idx, newC2Idx, oldToNewCol)) return m.Value; return $"{sheetPrefix}{newC1}{r1}:{newC2}{r2}"; }); } private static string RemapCol(string colPart, IReadOnlyDictionary map) { bool abs = colPart.StartsWith('$'); string letters = abs ? colPart[1..] : colPart; int oldIdx = ColumnLettersToIndex(letters); if (!map.TryGetValue(oldIdx, out var newIdx)) return colPart; return (abs ? "$" : "") + IndexToColumnLetters(newIdx); } /// /// Shift relative cell references in a formula by a (deltaCol, deltaRow) /// vector. Models Excel's "copy formula" semantics: refs without a $ /// marker shift by the delta, refs with $ stay absolute. Used when a /// row or column is copied to a new position — the cloned formulas keep /// their relative spatial relationships but their literal text needs to /// reflect the new anchor cell. /// /// Sheet-scope, string-literal, and structured-ref skip rules are /// identical to . A ref whose absolute resulting row /// or column would be <= 0 collapses to #REF!. /// public static string ApplyCopyDelta( string formula, string currentSheet, string modifiedSheet, int deltaCol, int deltaRow) { if (string.IsNullOrEmpty(formula) || (deltaCol == 0 && deltaRow == 0)) return formula; return WalkFormulaTokens(formula, chunk => DeltaShiftRefsInChunk(chunk, currentSheet, modifiedSheet, deltaCol, deltaRow)); } private static string DeltaShiftRefsInChunk( string chunk, string currentSheet, string modifiedSheet, int deltaCol, int deltaRow) { return CellRefPattern.Replace(chunk, m => { var sheetGroup = m.Groups["sheet"].Value; string targetSheet = string.IsNullOrEmpty(sheetGroup) ? currentSheet : (sheetGroup.StartsWith('\'') && sheetGroup.EndsWith('\'') ? sheetGroup[1..^1].Replace("''", "'") : sheetGroup); if (!targetSheet.Equals(modifiedSheet, StringComparison.OrdinalIgnoreCase)) return m.Value; string c1 = m.Groups["c1"].Value; string r1 = m.Groups["r1"].Value; string c2 = m.Groups["c2"].Value; string r2 = m.Groups["r2"].Value; bool isRange = !string.IsNullOrEmpty(c2); string sheetPrefix = string.IsNullOrEmpty(sheetGroup) ? "" : sheetGroup + "!"; string? newC1 = DeltaShiftCol(c1, deltaCol); string? newR1 = DeltaShiftRow(r1, deltaRow); if (newC1 == null || newR1 == null) return "#REF!"; if (!isRange) return $"{sheetPrefix}{newC1}{newR1}"; string? newC2 = DeltaShiftCol(c2, deltaCol); string? newR2 = DeltaShiftRow(r2, deltaRow); if (newC2 == null || newR2 == null) return "#REF!"; return $"{sheetPrefix}{newC1}{newR1}:{newC2}{newR2}"; }); } private static string? DeltaShiftCol(string colPart, int delta) { bool abs = colPart.StartsWith('$'); if (abs || delta == 0) return colPart; int idx = ColumnLettersToIndex(colPart); int newIdx = idx + delta; if (newIdx < 1) return null; return IndexToColumnLetters(newIdx); } private static string? DeltaShiftRow(string rowPart, int delta) { bool abs = rowPart.StartsWith('$'); if (abs || delta == 0) return rowPart; int num = int.Parse(rowPart); int newNum = num + delta; if (newNum < 1) return null; return newNum.ToString(); } /// /// Rewrite sheet-name prefixes when a sheet is renamed. The rewrite /// only touches the formula's reference space — string literals /// (INDIRECT("Sheet1!A1")) and bracketed structured-ref content /// are left verbatim. and /// are the formula-form names with their trailing ! already /// applied (e.g. "Sheet1!" or "'Sheet With Spaces'!"), /// matching how the existing rename code constructs them. /// public static string RenameSheetRef(string formula, string oldRef, string newRef) { if (string.IsNullOrEmpty(formula) || string.IsNullOrEmpty(oldRef) || oldRef.Equals(newRef, StringComparison.Ordinal)) return formula; return WalkFormulaTokens(formula, chunk => chunk.Replace(oldRef, newRef, StringComparison.OrdinalIgnoreCase)); } /// /// Returns the formula text rewritten so that any references targeting /// at or past /// are shifted by 1 in . Refs targeting other /// sheets, references inside string literals, and references inside /// structured-ref brackets are returned untouched. /// /// Formula text without a leading '=' (matching how /// the Excel handler stores CellFormula content). /// Sheet that contains the formula. Used to /// resolve unqualified refs. /// Sheet on which the insert happened. Refs /// shift only when their resolved sheet equals this. /// Whether a column or row was inserted. /// 1-based column index (for ColumnsRight) or /// 1-based row index (for RowsDown) at which the insert happened. /// The rewritten formula text. Returns the input unchanged when /// no refs match the shift criteria. public static string Shift( string formula, string currentSheet, string modifiedSheet, FormulaShiftDirection direction, int insertIdx) { if (string.IsNullOrEmpty(formula)) return formula; return WalkFormulaTokens(formula, chunk => ShiftRefsInChunk(chunk, currentSheet, modifiedSheet, direction, insertIdx)); } private static string ShiftRefsInChunk( string chunk, string currentSheet, string modifiedSheet, FormulaShiftDirection direction, int insertIdx) { return CellRefPattern.Replace(chunk, m => { var sheetGroup = m.Groups["sheet"].Value; string targetSheet; if (string.IsNullOrEmpty(sheetGroup)) { targetSheet = currentSheet; } else if (sheetGroup.StartsWith('\'') && sheetGroup.EndsWith('\'')) { targetSheet = sheetGroup[1..^1].Replace("''", "'"); } else { targetSheet = sheetGroup; } if (!targetSheet.Equals(modifiedSheet, StringComparison.OrdinalIgnoreCase)) return m.Value; string c1 = m.Groups["c1"].Value; string r1 = m.Groups["r1"].Value; string c2 = m.Groups["c2"].Value; string r2 = m.Groups["r2"].Value; string sheetPrefix = string.IsNullOrEmpty(sheetGroup) ? "" : sheetGroup + "!"; bool isRange = !string.IsNullOrEmpty(c2); // For each axis (col, row), compute the new value or null=#REF!. // For Insert directions, shifts never produce #REF!. For Delete // directions, an endpoint exactly on the deleted index either // collapses (single ref → #REF!), keeps the same row/col number // when it is the start endpoint of a range (now points to the // next row/col), or moves up/left when it is the end endpoint // (range shrinks by 1). var (newC1, newC2, colRef) = ShiftColAxis(c1, c2, isRange, direction, insertIdx); var (newR1, newR2, rowRef) = ShiftRowAxis(r1, r2, isRange, direction, insertIdx); if (colRef || rowRef) return "#REF!"; if (!isRange) return $"{sheetPrefix}{newC1}{newR1}"; return $"{sheetPrefix}{newC1}{newR1}:{newC2}{newR2}"; }); } private static (string newC1, string newC2, bool refError) ShiftColAxis( string c1, string c2, bool isRange, FormulaShiftDirection direction, int insertIdx) { switch (direction) { case FormulaShiftDirection.ColumnsRight: return (ShiftColPart(c1, insertIdx), isRange ? ShiftColPart(c2, insertIdx) : c2, false); case FormulaShiftDirection.ColumnsLeft: var (nc1, nc2, refErr) = DeleteShiftAxis( c1, c2, isRange, insertIdx, parseIdx: ColumnLettersToIndex, formatIdx: (idx, abs) => (abs ? "$" : "") + IndexToColumnLetters(idx), parseAbs: s => s.StartsWith('$'), parseDigits: s => s.StartsWith('$') ? s[1..] : s); return (nc1, nc2, refErr); default: return (c1, c2, false); } } private static (string newR1, string newR2, bool refError) ShiftRowAxis( string r1, string r2, bool isRange, FormulaShiftDirection direction, int insertIdx) { switch (direction) { case FormulaShiftDirection.RowsDown: return (ShiftRowPart(r1, insertIdx), isRange ? ShiftRowPart(r2, insertIdx) : r2, false); case FormulaShiftDirection.RowsUp: var (nr1, nr2, refErr) = DeleteShiftAxis( r1, r2, isRange, insertIdx, parseIdx: s => int.Parse(s), formatIdx: (idx, abs) => (abs ? "$" : "") + idx, parseAbs: s => s.StartsWith('$'), parseDigits: s => s.StartsWith('$') ? s[1..] : s); return (nr1, nr2, refErr); default: return (r1, r2, false); } } /// /// Shared delete-direction logic for both row and column axes. Returns /// the new endpoint strings and a refError flag set when the ref must /// collapse to #REF!. /// private static (string n1, string n2, bool refError) DeleteShiftAxis( string p1, string p2, bool isRange, int deletedIdx, Func parseIdx, Func formatIdx, Func parseAbs, Func parseDigits) { bool abs1 = parseAbs(p1); int idx1 = parseIdx(parseDigits(p1)); if (!isRange) { if (idx1 == deletedIdx) return (p1, p2, true); if (idx1 > deletedIdx) return (formatIdx(idx1 - 1, abs1), p2, false); return (p1, p2, false); } bool abs2 = parseAbs(p2); int idx2 = parseIdx(parseDigits(p2)); // Endpoint at deleted index: as start, stays at deletedIdx (now points // to the next survivor); as end, becomes deletedIdx-1 (range shrinks). int newIdx1 = idx1 == deletedIdx ? deletedIdx : idx1 > deletedIdx ? idx1 - 1 : idx1; int newIdx2 = idx2 == deletedIdx ? deletedIdx - 1 : idx2 > deletedIdx ? idx2 - 1 : idx2; // Range collapsed past zero or inverted (e.g. A3:A3 with row 3 deleted). if (newIdx1 > newIdx2 || newIdx2 < 1) return (p1, p2, true); return (formatIdx(newIdx1, abs1), formatIdx(newIdx2, abs2), false); } private static string ShiftColPart(string colPart, int insertColIdx) { bool isAbs = colPart.StartsWith('$'); string letters = isAbs ? colPart[1..] : colPart; int idx = ColumnLettersToIndex(letters); if (idx < insertColIdx) return colPart; return (isAbs ? "$" : "") + IndexToColumnLetters(idx + 1); } private static string ShiftRowPart(string rowPart, int insertRow) { bool isAbs = rowPart.StartsWith('$'); int num = int.Parse(isAbs ? rowPart[1..] : rowPart); if (num < insertRow) return rowPart; return (isAbs ? "$" : "") + (num + 1); } // Local copies — keep Core/ free of Handlers/ dependencies so the shifter // can be used by any handler or tested in isolation. private static int ColumnLettersToIndex(string letters) { int idx = 0; foreach (char c in letters) idx = idx * 26 + (char.ToUpperInvariant(c) - 'A' + 1); return idx; } private static string IndexToColumnLetters(int idx) { var sb = new StringBuilder(); while (idx > 0) { int rem = (idx - 1) % 26; sb.Insert(0, (char)('A' + rem)); idx = (idx - 1) / 26; } return sb.ToString(); } }