Files
iofficeai--officecli/src/officecli/Core/FormulaRefShifter.cs
T
wehub-resource-sync 8cb1f9f479
Publish SDK (PyPI) / publish (push) Has been cancelled
Publish SDK (npm) / publish (@aionui/officecli-sdk) (push) Has been cancelled
SDK smoke / smoke (windows-latest) (push) Has been cancelled
Publish SDK (npm) / publish (@officecli/officecli-sdk) (push) Has been cancelled
Publish SDK (npm) / publish (@officecli/sdk) (push) Has been cancelled
Publish SDK (npm) / publish (officecli-sdk) (push) Has been cancelled
SDK smoke / smoke (macos-latest) (push) Has been cancelled
SDK smoke / smoke (ubuntu-latest) (push) Has been cancelled
Skill parity / diff (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:09:29 +08:00

572 lines
24 KiB
C#

// Copyright 2026 OfficeCLI (https://OfficeCLI.AI)
// SPDX-License-Identifier: Apache-2.0
using System.Text;
using System.Text.RegularExpressions;
namespace OfficeCli.Core;
/// <summary>
/// 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 <c>#REF!</c>.
/// </summary>
public enum FormulaShiftDirection
{
/// <summary>A column was inserted; cell-ref columns at or past insertIdx shift right by 1.</summary>
ColumnsRight,
/// <summary>A row was inserted; cell-ref rows at or past insertIdx shift down by 1.</summary>
RowsDown,
/// <summary>A column was deleted; refs to that column collapse to <c>#REF!</c>, refs past it shift left by 1.</summary>
ColumnsLeft,
/// <summary>A row was deleted; refs to that row collapse to <c>#REF!</c>, refs past it shift up by 1.</summary>
RowsUp,
}
/// <summary>
/// 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.
///
/// <para>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.</para>
///
/// <para>The public API is intentionally minimal so a future tokenizer-based
/// implementation (Path B) can replace the body of <see cref="Shift"/>
/// without touching call sites or tests.</para>
/// </summary>
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(
@"(?<![\w.])" +
@"(?:(?<sheet>'(?:[^']|'')+'|[A-Za-z_][\w.]*)!)?" +
@"(?<c1>\$?[A-Z]{1,3})(?<r1>\$?\d+)" +
@"(?::(?<c2>\$?[A-Z]{1,3})(?<r2>\$?\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);
/// <summary>
/// Rewrite cell references in a formula by remapping their row numbers
/// through an arbitrary <paramref name="oldToNewRow"/> 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}).
///
/// <para>Refs whose row is not in the map pass through unchanged. For
/// ranges, both endpoints are remapped; if the result inverts (start &gt;
/// end) the original range is returned unchanged. Sheet-scope, string-
/// literal, and structured-ref skip rules are identical to <see cref="Shift"/>.</para>
/// </summary>
public static string ApplyRowRenumberMap(
string formula,
string currentSheet,
string modifiedSheet,
IReadOnlyDictionary<int, int> oldToNewRow)
{
if (string.IsNullOrEmpty(formula) || oldToNewRow.Count == 0) return formula;
return WalkFormulaTokens(formula, chunk =>
RenumberRefsInChunk(chunk, currentSheet, modifiedSheet, oldToNewRow));
}
/// <summary>
/// Outer tokenize-skip walker shared by every <c>FormulaRefShifter</c>
/// public entry point. Streams the formula char-by-char, copying string
/// literals (with the Excel <c>""</c> doubling escape) and bracket
/// content (structured refs like <c>Table1[Col1]</c>; cross-workbook
/// prefixes like <c>[Book2]Sheet1!A1</c>) verbatim. Hands every other
/// contiguous chunk to <paramref name="chunkProcessor"/>, which runs
/// the per-match cell-ref rewrite for that semantic (shift / renumber /
/// copy-delta) and returns the rewritten chunk.
/// </summary>
private static string WalkFormulaTokens(string formula, Func<string, string> 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<int, int> 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<int, int> 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<int>();
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<int, int> 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;
}
/// <summary>
/// Column-axis variant of <see cref="ApplyRowRenumberMap"/>. Same skip
/// rules, sheet scope, and contiguity guard. Map keys/values are 1-based
/// column indices (A=1, B=2, ...).
/// </summary>
public static string ApplyColRenumberMap(
string formula,
string currentSheet,
string modifiedSheet,
IReadOnlyDictionary<int, int> 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<int, int> 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<int, int> 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);
}
/// <summary>
/// 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.
///
/// <para>Sheet-scope, string-literal, and structured-ref skip rules are
/// identical to <see cref="Shift"/>. A ref whose absolute resulting row
/// or column would be &lt;= 0 collapses to <c>#REF!</c>.</para>
/// </summary>
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();
}
/// <summary>
/// Rewrite sheet-name prefixes when a sheet is renamed. The rewrite
/// only touches the formula's reference space — string literals
/// (<c>INDIRECT("Sheet1!A1")</c>) and bracketed structured-ref content
/// are left verbatim. <paramref name="oldRef"/> and <paramref name="newRef"/>
/// are the formula-form names with their trailing <c>!</c> already
/// applied (e.g. <c>"Sheet1!"</c> or <c>"'Sheet With Spaces'!"</c>),
/// matching how the existing rename code constructs them.
/// </summary>
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));
}
/// <summary>
/// Returns the formula text rewritten so that any references targeting
/// <paramref name="modifiedSheet"/> at or past <paramref name="insertIdx"/>
/// are shifted by 1 in <paramref name="direction"/>. Refs targeting other
/// sheets, references inside string literals, and references inside
/// structured-ref brackets are returned untouched.
/// </summary>
/// <param name="formula">Formula text without a leading '=' (matching how
/// the Excel handler stores <c>CellFormula</c> content).</param>
/// <param name="currentSheet">Sheet that contains the formula. Used to
/// resolve unqualified refs.</param>
/// <param name="modifiedSheet">Sheet on which the insert happened. Refs
/// shift only when their resolved sheet equals this.</param>
/// <param name="direction">Whether a column or row was inserted.</param>
/// <param name="insertIdx">1-based column index (for ColumnsRight) or
/// 1-based row index (for RowsDown) at which the insert happened.</param>
/// <returns>The rewritten formula text. Returns the input unchanged when
/// no refs match the shift criteria.</returns>
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);
}
}
/// <summary>
/// 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 <c>#REF!</c>.
/// </summary>
private static (string n1, string n2, bool refError) DeleteShiftAxis(
string p1, string p2, bool isRange, int deletedIdx,
Func<string, int> parseIdx,
Func<int, bool, string> formatIdx,
Func<string, bool> parseAbs,
Func<string, string> 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();
}
}