#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Op {
Insert,
Delete,
Move,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Axis {
Row,
Col,
}
#[derive(Clone, Debug)]
pub struct StructuralEdit {
pub axis: Axis,
pub at: u32, pub count: u32, pub op: Op,
pub sheet: String, pub dest: u32, }
pub fn move_row_sigma(pos: u32, a: u32, n: u32, b: u32) -> u32 {
if a <= b && b <= a + n {
pos
} else if b > a + n {
if pos >= a && pos < a + n {
b - n + (pos - a) } else if pos >= a + n && pos < b {
pos - n } else {
pos }
} else {
if pos >= a && pos < a + n {
b + (pos - a) } else if pos >= b && pos < a {
pos + n } else {
pos }
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Shift {
Unchanged,
Shifted(String),
Ref,
}
pub(crate) fn grid_max(axis: Axis) -> u32 {
match axis {
Axis::Row => 1_048_576,
Axis::Col => 16_384,
}
}
pub(crate) fn shift_index(pos: u32, edit: &StructuralEdit) -> Option<u32> {
let (k, n) = (edit.at, edit.count);
match edit.op {
Op::Insert => {
let np = if pos >= k { pos + n } else { pos };
(np <= grid_max(edit.axis)).then_some(np)
}
Op::Delete => {
if pos < k {
Some(pos)
} else if pos >= k + n {
Some(pos - n)
} else {
None }
}
Op::Move => Some(move_row_sigma(pos, k, n, edit.dest)),
}
}
fn shift_span(head: u32, tail: u32, edit: &StructuralEdit) -> Option<(u32, u32)> {
let (k, n) = (edit.at, edit.count);
match edit.op {
Op::Insert => {
let h = if head >= k { head + n } else { head };
let t = if tail >= k { tail + n } else { tail };
let max = grid_max(edit.axis);
if h > max {
None
} else {
Some((h, t.min(max)))
}
}
Op::Delete => {
let band_end = k + n; let head_below = head < k;
let tail_below = tail < k;
let head_after = head >= band_end;
let tail_after = tail >= band_end;
if tail_below {
Some((head, tail)) } else if head_after {
Some((head - n, tail - n)) } else if head_below && tail_after {
Some((head, tail - n)) } else if !head_below && !head_after && tail_after {
Some((k, tail - n)) } else if head_below && !tail_below && !tail_after {
Some((head, k - 1)) } else {
None }
}
Op::Move => {
let block_end = k + n - 1;
let contains_move =
head <= k && block_end <= tail && edit.dest >= head && edit.dest <= tail + 1;
if contains_move {
return Some((head, tail));
}
let h = move_row_sigma(head, k, n, edit.dest);
let t = move_row_sigma(tail, k, n, edit.dest);
if h <= t && t - h == tail - head {
Some((h, t))
} else {
None
}
}
}
}
pub fn col_to_num(s: &str) -> Option<u32> {
if s.is_empty() {
return None;
}
let mut n: u32 = 0;
for c in s.bytes() {
if !c.is_ascii_alphabetic() {
return None;
}
n = n
.checked_mul(26)?
.checked_add((c.to_ascii_uppercase() - b'A' + 1) as u32)?;
}
Some(n)
}
pub fn looks_like_cell_ref(name: &str) -> bool {
let b = name.as_bytes();
let mut i = 0;
if i < b.len() && b[i] == b'$' {
i += 1;
}
let col_start = i;
while i < b.len() && b[i].is_ascii_alphabetic() {
i += 1;
}
let col = &name[col_start..i];
if col.is_empty() || !col_to_num(col).is_some_and(|n| (1..=16384).contains(&n)) {
return false;
}
if i < b.len() && b[i] == b'$' {
i += 1;
}
let row_start = i;
while i < b.len() && b[i].is_ascii_digit() {
i += 1;
}
i == b.len()
&& i > row_start
&& name[row_start..i]
.parse::<u32>()
.is_ok_and(|r| (1..=1048576).contains(&r))
}
pub fn num_to_col(mut n: u32) -> String {
let mut s = Vec::new();
while n > 0 {
let rem = ((n - 1) % 26) as u8;
s.push(b'A' + rem);
n = (n - 1) / 26;
}
s.reverse();
String::from_utf8(s).unwrap()
}
fn parse_endpoint(s: &str) -> Option<(bool, Option<u32>, bool, Option<u32>)> {
let b = s.as_bytes();
let mut i = 0;
let first_dollar = i < b.len() && b[i] == b'$';
if first_dollar {
i += 1;
}
let col_start = i;
while i < b.len() && b[i].is_ascii_alphabetic() {
i += 1;
}
let col = if i > col_start {
Some(col_to_num(&s[col_start..i])?)
} else {
None
};
let (col_abs, mut row_abs) = if col.is_some() {
(first_dollar, false)
} else {
(false, first_dollar)
};
if col.is_some() {
row_abs = i < b.len() && b[i] == b'$';
if row_abs {
i += 1;
}
}
let row_start = i;
while i < b.len() && b[i].is_ascii_digit() {
i += 1;
}
let row = if i > row_start {
Some(s[row_start..i].parse::<u32>().ok()?)
} else {
None
};
if i != b.len() {
return None; }
if col.is_none() && row.is_none() {
return None;
}
if (col_abs && col.is_none()) || (row_abs && row.is_none()) {
return None;
}
Some((col_abs, col, row_abs, row))
}
fn fmt_endpoint(col_abs: bool, col: Option<u32>, row_abs: bool, row: Option<u32>) -> String {
let mut s = String::new();
if let Some(c) = col {
if col_abs {
s.push('$');
}
s.push_str(&num_to_col(c));
}
if let Some(r) = row {
if row_abs {
s.push('$');
}
s.push_str(&r.to_string());
}
s
}
fn shift_body(body: &str, edit: &StructuralEdit) -> Shift {
if let Some((h, t)) = body.split_once(':') {
let (h, t) = (h.trim(), t.trim());
let hp = parse_endpoint(h);
let tp = parse_endpoint(t);
let (hp, tp) = match (hp, tp) {
(Some(a), Some(b)) => (a, b),
_ => return Shift::Unchanged, };
let (h_line, t_line) = match edit.axis {
Axis::Row => (hp.3, tp.3),
Axis::Col => (hp.1, tp.1),
};
match (h_line, t_line) {
(Some(hl), Some(tl)) => {
let (lo, hi) = if hl <= tl { (hl, tl) } else { (tl, hl) };
match shift_span(lo, hi, edit) {
None => Shift::Ref,
Some((nl, nh)) => {
if nl == lo && nh == hi {
return Shift::Unchanged;
}
let (nh_ep, nt_ep) = rebuild_range(hp, tp, edit.axis, nl, nh, hl <= tl);
Shift::Shifted(format!("{}:{}", nh_ep, nt_ep))
}
}
}
_ => Shift::Unchanged, }
} else {
let ep = match parse_endpoint(body) {
Some(e) => e,
None => return Shift::Unchanged,
};
let line = match edit.axis {
Axis::Row => ep.3,
Axis::Col => ep.1,
};
match line {
None => Shift::Unchanged, Some(l) => match shift_index(l, edit) {
None => Shift::Ref,
Some(nl) => {
if nl == l {
Shift::Unchanged
} else {
let (col_abs, col, row_abs, row) = ep;
let ne = match edit.axis {
Axis::Row => fmt_endpoint(col_abs, col, row_abs, Some(nl)),
Axis::Col => fmt_endpoint(col_abs, Some(nl), row_abs, row),
};
Shift::Shifted(ne)
}
}
},
}
}
}
fn rebuild_range(
hp: (bool, Option<u32>, bool, Option<u32>),
tp: (bool, Option<u32>, bool, Option<u32>),
axis: Axis,
new_lo: u32,
new_hi: u32,
head_was_lo: bool,
) -> (String, String) {
let (h_new, t_new) = if head_was_lo {
(new_lo, new_hi)
} else {
(new_hi, new_lo)
};
let head = set_axis(hp, axis, h_new);
let tail = set_axis(tp, axis, t_new);
(head, tail)
}
fn set_axis(ep: (bool, Option<u32>, bool, Option<u32>), axis: Axis, line: u32) -> String {
let (col_abs, col, row_abs, row) = ep;
match axis {
Axis::Row => fmt_endpoint(col_abs, col, row_abs, Some(line)),
Axis::Col => fmt_endpoint(col_abs, Some(line), row_abs, row),
}
}
pub fn shift_ref(reference: &str, current_sheet: &str, edit: &StructuralEdit) -> Shift {
if reference.starts_with('[') {
return Shift::Unchanged;
}
if let Some(bang) = reference.rfind('!') {
let (sheet_part, body) = reference.split_at(bang);
let body = &body[1..];
let target = unquote_sheet(sheet_part);
let targets_edit = if let Some((s1, s2)) = target.split_once(':') {
eq_sheet(s1, &edit.sheet) || eq_sheet(s2, &edit.sheet)
} else {
eq_sheet(&target, &edit.sheet)
};
if !targets_edit {
return Shift::Unchanged;
}
match shift_body(body, edit) {
Shift::Unchanged => Shift::Unchanged,
Shift::Ref => Shift::Ref,
Shift::Shifted(nb) => Shift::Shifted(format!("{}!{}", sheet_part, nb)),
}
} else {
if !eq_sheet(current_sheet, &edit.sheet) {
return Shift::Unchanged;
}
shift_body(reference, edit)
}
}
fn unquote_sheet(s: &str) -> String {
let s = s.trim();
if s.starts_with('\'') && s.ends_with('\'') && s.len() >= 2 {
s[1..s.len() - 1].replace("''", "'")
} else {
s.to_string()
}
}
fn eq_sheet(a: &str, b: &str) -> bool {
unquote_sheet(a).eq_ignore_ascii_case(&unquote_sheet(b))
}
#[inline]
fn utf8_len(b0: u8) -> usize {
if b0 < 0x80 {
1
} else if b0 >= 0xF0 {
4
} else if b0 >= 0xE0 {
3
} else {
2
}
}
fn ref_start_boundary(prev: Option<char>) -> bool {
match prev {
None => true,
Some(p) => {
!(p.is_ascii_alphanumeric()
|| p == '_'
|| p == '.'
|| p == '$'
|| p == '!'
|| p == '\''
|| p == '\\'
|| !p.is_ascii())
}
}
}
pub fn shift_formula(formula: &str, current_sheet: &str, edit: &StructuralEdit) -> (String, u32) {
let b = formula.as_bytes();
let mut out = String::with_capacity(formula.len());
let mut i = 0;
let mut shifted = 0u32;
while i < b.len() {
let c = b[i];
if c == b'"' {
out.push('"');
i += 1;
while i < b.len() {
if b[i] == b'"' {
out.push('"');
i += 1;
if i < b.len() && b[i] == b'"' {
out.push('"');
i += 1;
continue;
}
break;
}
let l = utf8_len(b[i]);
out.push_str(&formula[i..i + l]);
i += l;
}
continue;
}
let boundary = ref_start_boundary(out.chars().last());
if boundary
&& (c == b'\''
|| c == b'['
|| c.is_ascii_alphabetic()
|| c == b'$'
|| c.is_ascii_digit())
{
if let Some((tok_len, replacement, did_shift)) =
try_reference(&formula[i..], current_sheet, edit)
{
out.push_str(&replacement);
if did_shift {
shifted += 1;
}
i += tok_len;
continue;
}
}
let l = utf8_len(c);
out.push_str(&formula[i..i + l]);
i += l;
}
(out, shifted)
}
fn ref_box(body: &str) -> Option<(u32, u32, u32, u32)> {
let (h, t) = match body.split_once(':') {
Some((h, t)) => (h.trim(), t.trim()),
None => (body, body),
};
let hp = parse_endpoint(h)?;
let tp = parse_endpoint(t)?;
let (col_lo, col_hi) = match (hp.1, tp.1) {
(Some(a), Some(b)) => (a.min(b), a.max(b)),
(None, None) => (1, 16384), _ => return None,
};
let (row_lo, row_hi) = match (hp.3, tp.3) {
(Some(a), Some(b)) => (a.min(b), a.max(b)),
(None, None) => (1, 1_048_576), _ => return None,
};
Some((col_lo, col_hi, row_lo, row_hi))
}
fn ref_token_covers(
token: &str,
home_sheet: &str,
target_sheet: &str,
target_col: u32,
target_row: u32,
sheets: &[String],
) -> bool {
if token.starts_with('[') {
return false; }
let (sheet_matches, body) = if let Some(bang) = token.rfind('!') {
let (sheet_part, rest) = token.split_at(bang);
let target = unquote_sheet(sheet_part);
let m = if let Some((s1, s2)) = target.split_once(':') {
let (s1, s2) = (s1.trim(), s2.trim());
if sheets.is_empty() {
eq_sheet(s1, target_sheet) || eq_sheet(s2, target_sheet)
} else {
let idx = |nm: &str| sheets.iter().position(|s| eq_sheet(s, nm));
match (idx(s1), idx(s2), idx(target_sheet)) {
(Some(a), Some(b), Some(t)) => (a.min(b)..=a.max(b)).contains(&t),
_ => true,
}
}
} else {
eq_sheet(&target, target_sheet)
};
(m, &rest[1..])
} else {
(eq_sheet(home_sheet, target_sheet), token)
};
if !sheet_matches {
return false;
}
match ref_box(body) {
Some((cl, ch, rl, rh)) => {
(cl..=ch).contains(&target_col) && (rl..=rh).contains(&target_row)
}
None => true, }
}
pub(crate) fn is_plain_reference(body: &str) -> bool {
let mut s = body.trim();
if let Some(rest) = s.strip_prefix('=') {
s = rest.trim();
}
if s.is_empty() || s.starts_with('[') {
return false; }
let Some(body_start) = parse_ref_prefix(s) else {
return false;
};
let (body_len, is_ref) = scan_ref_body(&s[body_start..]);
is_ref && body_len > 0 && body_start + body_len == s.len()
}
pub(crate) fn formula_references_cell(
formula: &str,
home_sheet: &str,
target_sheet: &str,
target_col: u32,
target_row: u32,
sheets: &[String],
) -> bool {
let b = formula.as_bytes();
let mut prev: Option<char> = None;
let mut i = 0;
while i < b.len() {
let c = b[i];
if c == b'"' {
i += 1;
while i < b.len() {
if b[i] == b'"' {
i += 1;
if i < b.len() && b[i] == b'"' {
i += 1;
continue;
}
break;
}
i += utf8_len(b[i]);
}
prev = Some('"');
continue;
}
if ref_start_boundary(prev)
&& (c == b'\''
|| c == b'['
|| c.is_ascii_alphabetic()
|| c == b'$'
|| c.is_ascii_digit())
{
let s = &formula[i..];
if let Some(body_start) = parse_ref_prefix(s) {
let (body_len, is_ref) = scan_ref_body(&s[body_start..]);
if is_ref && body_len > 0 {
let total = body_start + body_len;
let token = &s[..total];
if ref_token_covers(
token,
home_sheet,
target_sheet,
target_col,
target_row,
sheets,
) {
return true;
}
prev = token.chars().last();
i += total;
continue;
}
}
}
let l = utf8_len(c);
prev = formula[i..i + l].chars().next();
i += l;
}
false
}
pub fn has_unquoted_non_ascii_qualifier(f: &str) -> bool {
let b = f.as_bytes();
let mut i = 0;
while i < b.len() {
match b[i] {
b'"' => {
i += 1;
while i < b.len() {
if b[i] == b'"' {
if i + 1 < b.len() && b[i + 1] == b'"' {
i += 2;
continue;
}
break;
}
i += 1;
}
i += 1;
}
b'\'' => {
i += 1;
while i < b.len() {
if b[i] == b'\'' {
if i + 1 < b.len() && b[i + 1] == b'\'' {
i += 2;
continue;
}
break;
}
i += 1;
}
i += 1;
}
b'!' => {
let mut j = i;
while j > 0 {
let p = b[j - 1];
if p < 0x80
&& matches!(
p,
b'(' | b')'
| b','
| b'+'
| b'-'
| b'*'
| b'/'
| b'^'
| b'&'
| b'='
| b'<'
| b'>'
| b';'
| b' '
| b'{'
| b'}'
| b'%'
| b'"'
| b'\''
)
{
break;
}
j -= 1;
}
if b[j..i].iter().any(|&c| c >= 0x80) {
return true;
}
i += 1;
}
_ => i += 1,
}
}
false
}
pub(crate) fn neutralize_non_ascii_quals(f: &str) -> Option<String> {
let b = f.as_bytes();
let mut spans: Vec<(usize, usize)> = Vec::new();
let mut i = 0;
while i < b.len() {
match b[i] {
b'"' => {
i += 1;
while i < b.len() {
if b[i] == b'"' {
if i + 1 < b.len() && b[i + 1] == b'"' {
i += 2;
continue;
}
break;
}
i += 1;
}
i += 1;
}
b'\'' => {
i += 1;
while i < b.len() {
if b[i] == b'\'' {
if i + 1 < b.len() && b[i + 1] == b'\'' {
i += 2;
continue;
}
break;
}
i += 1;
}
i += 1;
}
b'!' => {
let mut j = i;
while j > 0 {
let p = b[j - 1];
if p < 0x80
&& matches!(
p,
b'(' | b')'
| b','
| b'+'
| b'-'
| b'*'
| b'/'
| b'^'
| b'&'
| b'='
| b'<'
| b'>'
| b';'
| b' '
| b'{'
| b'}'
| b'%'
| b'"'
| b'\''
)
{
break;
}
j -= 1;
}
if b[j..i].iter().any(|&c| c >= 0x80) {
if b[j..i].contains(&b':') {
return None;
}
let mut k = i + 1;
while k < b.len()
&& (b[k].is_ascii_alphanumeric() || b[k] == b'$' || b[k] == b':')
{
k += 1;
}
spans.push((j, k));
i = k;
continue;
}
i += 1;
}
_ => i += 1,
}
}
if spans.is_empty() {
return Some(f.to_string());
}
let mut out = String::with_capacity(f.len());
let mut pos = 0;
for (s, e) in spans {
out.push_str(&f[pos..s]);
out.push('0');
pos = e;
}
out.push_str(&f[pos..]);
Some(out)
}
fn parse_ref_prefix(s: &str) -> Option<usize> {
let b = s.as_bytes();
let mut i = 0;
if i < b.len() && b[i] == b'[' {
let close = s[i..].find(']')? + i;
i = close + 1;
}
if i < b.len() && b[i] == b'\'' {
let mut j = i + 1;
loop {
if j >= b.len() {
return None;
}
if b[j] == b'\'' {
if j + 1 < b.len() && b[j + 1] == b'\'' {
j += 2;
continue;
}
break;
}
j += 1;
}
if j + 1 < b.len() && b[j + 1] == b'!' {
Some(j + 2)
} else {
None
}
} else {
let mut j = i;
while j < b.len()
&& (b[j].is_ascii_alphanumeric() || b[j] == b'_' || b[j] == b'.' || b[j] == b':')
{
j += 1;
}
if j < b.len() && b[j] == b'!' && j > i {
Some(j + 1)
} else {
Some(i)
}
}
}
fn try_reference(
s: &str,
current_sheet: &str,
edit: &StructuralEdit,
) -> Option<(usize, String, bool)> {
let body_start = parse_ref_prefix(s)?;
let (body_len, is_ref) = scan_ref_body(&s[body_start..]);
if !is_ref || body_len == 0 {
return None;
}
let total = body_start + body_len;
let full = &s[..total];
match shift_ref(full, current_sheet, edit) {
Shift::Unchanged => Some((total, full.to_string(), false)),
Shift::Shifted(ns) => Some((total, ns, true)),
Shift::Ref => {
let qual = &s[..body_start];
Some((total, format!("{}#REF!", qual), true))
}
}
}
pub fn offset_formula(formula: &str, dr: i64, dc: i64) -> String {
let b = formula.as_bytes();
let mut out = String::with_capacity(formula.len());
let mut i = 0;
while i < b.len() {
let c = b[i];
if c == b'"' {
out.push('"');
i += 1;
while i < b.len() {
if b[i] == b'"' {
out.push('"');
i += 1;
if i < b.len() && b[i] == b'"' {
out.push('"');
i += 1;
continue;
}
break;
}
let l = utf8_len(b[i]);
out.push_str(&formula[i..i + l]);
i += l;
}
continue;
}
let boundary = ref_start_boundary(out.chars().last());
if boundary
&& (c == b'\''
|| c == b'['
|| c.is_ascii_alphabetic()
|| c == b'$'
|| c.is_ascii_digit())
{
if let Some((len, repl)) = try_offset_reference(&formula[i..], dr, dc) {
out.push_str(&repl);
i += len;
continue;
}
}
let l = utf8_len(c);
out.push_str(&formula[i..i + l]);
i += l;
}
out
}
fn try_offset_reference(s: &str, dr: i64, dc: i64) -> Option<(usize, String)> {
let body_start = parse_ref_prefix(s)?;
let (body_len, is_ref) = scan_ref_body(&s[body_start..]);
if !is_ref || body_len == 0 {
return None;
}
let total = body_start + body_len;
let qual = &s[..body_start];
let body = &s[body_start..total];
let new_body = offset_body(body, dr, dc)?;
Some((total, format!("{}{}", qual, new_body)))
}
fn offset_endpoint(ep: (bool, Option<u32>, bool, Option<u32>), dr: i64, dc: i64) -> Option<String> {
let (col_abs, col, row_abs, row) = ep;
let new_col = match (col, col_abs) {
(Some(c), false) => {
let v = c as i64 + dc;
if v < 1 || v > grid_max(Axis::Col) as i64 {
return None;
}
Some(v as u32)
}
(c, _) => c,
};
let new_row = match (row, row_abs) {
(Some(r), false) => {
let v = r as i64 + dr;
if v < 1 || v > grid_max(Axis::Row) as i64 {
return None;
}
Some(v as u32)
}
(r, _) => r,
};
Some(fmt_endpoint(col_abs, new_col, row_abs, new_row))
}
fn offset_body(body: &str, dr: i64, dc: i64) -> Option<String> {
if let Some((h, t)) = body.split_once(':') {
let hp = parse_endpoint(h)?;
let tp = parse_endpoint(t)?;
match (offset_endpoint(hp, dr, dc), offset_endpoint(tp, dr, dc)) {
(Some(nh), Some(nt)) => Some(format!("{}:{}", nh, nt)),
_ => Some("#REF!".to_string()),
}
} else {
let ep = parse_endpoint(body)?;
Some(offset_endpoint(ep, dr, dc).unwrap_or_else(|| "#REF!".to_string()))
}
}
fn scan_ref_body(s: &str) -> (usize, bool) {
fn scan_endpoint(s: &str) -> (usize, bool, bool) {
let b = s.as_bytes();
let mut i = 0;
if i < b.len() && b[i] == b'$' {
i += 1;
}
let col_start = i;
while i < b.len() && b[i].is_ascii_alphabetic() {
i += 1;
}
let has_col =
i > col_start && col_to_num(&s[col_start..i]).is_some_and(|n| (1..=16384).contains(&n));
let mut had_row_dollar = false;
if i < b.len() && b[i] == b'$' {
had_row_dollar = true;
i += 1;
}
let row_start = i;
while i < b.len() && b[i].is_ascii_digit() {
i += 1;
}
let has_row = i > row_start
&& s[row_start..i]
.parse::<u32>()
.is_ok_and(|r| (1..=1048576).contains(&r));
if had_row_dollar && !has_row {
i -= 1;
}
(i, has_col, has_row)
}
let (l1, c1, r1) = scan_endpoint(s);
if l1 == 0 {
return (0, false);
}
let sb = s.as_bytes();
let ident_tail = |end: usize| {
end < sb.len()
&& (sb[end].is_ascii_alphabetic()
|| sb[end] == b'_'
|| sb[end] == b'.'
|| sb[end] == b'(')
};
let is_ws = |b: Option<&u8>| matches!(b, Some(b' ' | b'\t' | b'\n' | b'\r'));
let mut colon = l1;
while is_ws(sb.get(colon)) {
colon += 1;
}
if sb.get(colon) == Some(&b':') {
let mut tail_start = colon + 1;
while is_ws(sb.get(tail_start)) {
tail_start += 1;
}
let (l2, c2, r2) = scan_endpoint(&s[tail_start..]);
if l2 > 0 {
let total = tail_start + l2;
let both_full = (c1 && r1) && (c2 && r2);
let both_wholecol = (c1 && !r1) && (c2 && !r2);
let both_wholerow = (!c1 && r1) && (!c2 && r2);
if both_full || both_wholecol || both_wholerow {
return (total, !ident_tail(total));
}
}
}
(l1, c1 && r1 && !ident_tail(l1))
}
fn walk_qual_token_back(b: &[u8], end: usize) -> usize {
if end == 0 {
return 0;
}
if b[end - 1] == b'\'' {
let mut j = end - 1;
while j > 0 {
j -= 1;
if b[j] == b'\'' {
if j > 0 && b[j - 1] == b'\'' {
j -= 1; continue;
}
return j; }
}
j
} else {
let mut j = end;
while j > 0 {
let c = b[j - 1];
if c.is_ascii_alphanumeric() || c == b'_' || c == b'.' {
j -= 1;
} else {
break;
}
}
j
}
}
fn walk_full_qualifier_back(b: &[u8], end: usize) -> usize {
let mut pos = end;
loop {
let tok_start = walk_qual_token_back(b, pos);
if tok_start == pos {
break; }
pos = tok_start;
if pos > 0 && b[pos - 1] == b':' {
pos -= 1; continue;
}
break;
}
pos
}
fn split_span_qualifier(qual: &str) -> Option<(String, String)> {
let qual = qual.trim();
let b = qual.as_bytes();
let mut in_q = false;
let mut k = 0;
while k < b.len() {
match b[k] {
b'\'' => {
if in_q && b.get(k + 1) == Some(&b'\'') {
k += 2; continue;
}
in_q = !in_q;
}
b':' if !in_q => {
return Some((
normalize_sheet_token(&qual[..k]),
normalize_sheet_token(&qual[k + 1..]),
));
}
_ => {}
}
k += 1;
}
if qual.len() >= 2 && qual.starts_with('\'') && qual.ends_with('\'') {
let inner = &qual[1..qual.len() - 1];
if let Some((a, c)) = inner.split_once(':') {
return Some((
a.replace("''", "'").trim().to_string(),
c.replace("''", "'").trim().to_string(),
));
}
}
None
}
fn normalize_sheet_token(raw: &str) -> String {
let t = raw.trim();
let t = t
.strip_prefix('\'')
.and_then(|s| s.strip_suffix('\''))
.unwrap_or(t);
t.replace("''", "'")
}
fn span_ref_shifts(after: &str, sheet: &str, edit: &StructuralEdit) -> bool {
let refstr: String = after
.chars()
.take_while(|c| c.is_ascii_alphanumeric() || *c == '$' || *c == ':')
.collect();
if refstr.is_empty() {
return true;
}
shift_formula(&refstr, sheet, edit).0 != refstr
}
fn span_covers_edited(s1: &str, s2: &str, order: &[String], edited: &str) -> bool {
let idx = |name: &str| order.iter().position(|s| s.eq_ignore_ascii_case(name));
let (Some(i1), Some(i2), Some(ie)) = (idx(s1), idx(s2), idx(edited)) else {
return true;
};
let (lo, hi) = if i1 <= i2 { (i1, i2) } else { (i2, i1) };
lo <= ie && ie <= hi
}
pub fn has_unverifiable_3d_span(
formula: &str,
sheet_order: &[String],
edit: &StructuralEdit,
) -> bool {
let b = formula.as_bytes();
let mut i = 0;
while i < b.len() {
if b[i] == b'"' {
i += 1;
while i < b.len() {
if b[i] == b'"' {
i += 1;
if i < b.len() && b[i] == b'"' {
i += 1;
continue;
}
break;
}
i += 1;
}
continue;
}
if b[i] == b'!' {
let qstart = walk_full_qualifier_back(b, i);
if let Some((s1, s2)) = split_span_qualifier(&formula[qstart..i]) {
if !s1.eq_ignore_ascii_case(&s2)
&& span_covers_edited(&s1, &s2, sheet_order, &edit.sheet)
&& span_ref_shifts(&formula[i + 1..], &edit.sheet, edit)
{
return true;
}
}
}
i += 1;
}
false
}
pub fn formula_contains_3d_span(formula: &str) -> bool {
let b = formula.as_bytes();
let mut i = 0;
while i < b.len() {
if b[i] == b'"' {
i += 1;
while i < b.len() {
if b[i] == b'"' {
i += 1;
if i < b.len() && b[i] == b'"' {
i += 1;
continue;
}
break;
}
i += 1;
}
continue;
}
if b[i] == b'!' {
let qstart = walk_full_qualifier_back(b, i);
if let Some((s1, s2)) = split_span_qualifier(&formula[qstart..i]) {
if !s1.eq_ignore_ascii_case(&s2) {
return true;
}
}
}
i += 1;
}
false
}
#[allow(dead_code)]
pub fn residual_reason(formula_attrs: &str) -> Option<&'static str> {
if formula_attrs.contains("t=\"array\"") {
return Some("array_formula_present");
}
if formula_attrs.contains("t=\"shared\"") {
return Some("shared_formula_present");
}
None
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn is_plain_reference_distinguishes_plain_refs_from_unevaluable_names() {
assert!(is_plain_reference("Sheet1!$A$1"));
assert!(is_plain_reference("=Sheet1!$A$1"));
assert!(is_plain_reference("Sheet1!$A$1:$A$10"));
assert!(is_plain_reference("A1"));
assert!(is_plain_reference("'My Sheet'!$B$2"));
assert!(!is_plain_reference("0.2")); assert!(!is_plain_reference("Sheet1!$Z$1*2")); assert!(!is_plain_reference("OFFSET(Sheet1!$Z$1,0,0)")); assert!(!is_plain_reference("INDIRECT(\"A1\")"));
assert!(!is_plain_reference("Sheet1!A1,Sheet1!B2")); assert!(!is_plain_reference("OtherName")); assert!(!is_plain_reference("")); assert!(!is_plain_reference("[1]Sheet1!A1")); assert!(is_plain_reference("Sheet1:Sheet3!$A$1"));
assert!(formula_contains_3d_span("Sheet1:Sheet3!$A$1"));
}
#[test]
fn formula_references_cell_covers_single_range_sheet_and_boundaries() {
let refs = |f: &str| formula_references_cell(f, "S", "S", 2, 2, &[]);
assert!(refs("=B2+1"));
assert!(refs("=$B$2*2"));
assert!(refs("=SUM(A1,B2,C3)"));
assert!(refs("=SUM(A1:C3)"));
assert!(refs("=SUM(2:2)"));
assert!(refs("=SUM(B:B)"));
assert!(!refs("=B3+A2"));
assert!(!refs("=SUM(C1:D9)"));
assert!(!refs("=B20")); assert!(!refs("=ABB2")); assert!(!refs("=\"B2 in a string\""));
assert!(!formula_references_cell("=B2", "Other", "S", 2, 2, &[]));
assert!(formula_references_cell("=S!B2", "Other", "S", 2, 2, &[]));
assert!(formula_references_cell(
"='S'!$B$2",
"Other",
"S",
2,
2,
&[]
));
assert!(!formula_references_cell(
"=Other!B2",
"Other",
"S",
2,
2,
&[]
));
assert!(!formula_references_cell("=[1]S!B2", "S", "S", 2, 2, &[]));
assert!(formula_references_cell("=SUM(S:T!B2)", "X", "S", 2, 2, &[]));
let order = |v: &[&str]| v.iter().map(|s| s.to_string()).collect::<Vec<_>>();
let sheets = order(&["S1", "S2", "S3"]);
assert!(formula_references_cell(
"=SUM(S1:S3!B2)",
"X",
"S2",
2,
2,
&sheets
));
assert!(formula_references_cell(
"=SUM(S1:S3!B2)",
"X",
"S1",
2,
2,
&sheets
));
assert!(formula_references_cell(
"=SUM(S1:S3!B2)",
"X",
"S3",
2,
2,
&sheets
));
let sheets4 = order(&["S1", "S2", "S3", "S4"]);
assert!(!formula_references_cell(
"=SUM(S1:S2!B2)",
"X",
"S4",
2,
2,
&sheets4
));
assert!(!formula_references_cell(
"=SUM(S1:S2!B2)",
"X",
"S3",
2,
2,
&sheets4
));
}
fn row_edit(op: Op, at: u32, count: u32) -> StructuralEdit {
StructuralEdit {
axis: Axis::Row,
at,
count,
op,
sheet: "Sheet1".into(),
dest: 0,
}
}
#[test]
fn shift_preserves_non_ascii_literals() {
let f = r#"IF(C8="","",IF(C8=$IA$4,"大当たり!","はずれ!もう一度考えよう!"))"#;
let (out, n) = shift_formula(f, "Sheet1", &row_edit(Op::Insert, 2, 1));
assert_eq!(
out,
r#"IF(C9="","",IF(C9=$IA$5,"大当たり!","はずれ!もう一度考えよう!"))"#
);
assert_eq!(n, 3);
let g = r#"IF(A5=1,"café ○ 𝄞","×")&B5"#;
let (out2, n2) = shift_formula(g, "Sheet1", &row_edit(Op::Insert, 2, 1));
assert_eq!(out2, r#"IF(A6=1,"café ○ 𝄞","×")&B6"#);
assert_eq!(n2, 2);
}
#[test]
fn offset_preserves_non_ascii_literals() {
let out = offset_formula(r#"IF(B2="","",$A$1&"○×表")"#, 1, 0);
assert_eq!(out, r#"IF(B3="","",$A$1&"○×表")"#);
}
#[test]
fn function_endpoint_range_head_shifts() {
let e = row_edit(Op::Insert, 2, 1);
let (o1, n1) = shift_formula("SUM(A2:CHOOSE(3,A3,A4,A5))", "Sheet1", &e);
assert_eq!(o1, "SUM(A3:CHOOSE(3,A4,A5,A6))");
assert_eq!(n1, 4);
let (o2, _) = shift_formula("SUM(A9:CHOOSE(2,A10,A11,A12))", "Sheet1", &e);
assert_eq!(o2, "SUM(A10:CHOOSE(2,A11,A12,A13))");
let ec = col_edit(Op::Insert, 2, 1);
let (o3, _) = shift_formula("SUM(B1:OFFSET(B1,3,0))", "Sheet1", &ec);
assert_eq!(o3, "SUM(C1:OFFSET(C1,3,0))");
let (o4, _) = shift_formula("SUM(A2:B5)+SUM(F:F)", "Sheet1", &e);
assert_eq!(o4, "SUM(A3:B6)+SUM(F:F)");
}
#[test]
fn detects_unquoted_non_ascii_qualifier() {
assert!(has_unquoted_non_ascii_qualifier("集計01!CI3"));
assert!(has_unquoted_non_ascii_qualifier("SUM(集計01!CI3:CI9)+A1"));
assert!(has_unquoted_non_ascii_qualifier("データ!B2"));
assert!(!has_unquoted_non_ascii_qualifier("'集計01'!CI3"));
assert!(!has_unquoted_non_ascii_qualifier("SUM('データ'!B2:B9)"));
assert!(!has_unquoted_non_ascii_qualifier(
r#"IF(A1=1,"集計!","x")&Sheet2!B1"#
));
assert!(!has_unquoted_non_ascii_qualifier("Sheet2!A1+SUM(B1:B9)"));
assert!(!has_unquoted_non_ascii_qualifier("SUM(A1:B2)"));
}
#[test]
fn neutralizes_non_ascii_qualified_refs() {
assert_eq!(neutralize_non_ascii_quals("集計!A5").as_deref(), Some("0"));
assert_eq!(
neutralize_non_ascii_quals("集計!A5+A5").as_deref(),
Some("0+A5")
);
assert_eq!(neutralize_non_ascii_quals("A1計!B5").as_deref(), Some("0"));
assert_eq!(
neutralize_non_ascii_quals("A1計!B5:B9+Sheet1!A5").as_deref(),
Some("0+Sheet1!A5")
);
assert_eq!(neutralize_non_ascii_quals("SUM(集計:売上!A5)"), None);
assert_eq!(
neutralize_non_ascii_quals("'集計'!A5").as_deref(),
Some("'集計'!A5")
);
assert_eq!(
neutralize_non_ascii_quals(r#"IF(A1=1,"集計!",A5)"#).as_deref(),
Some(r#"IF(A1=1,"集計!",A5)"#)
);
assert_eq!(
neutralize_non_ascii_quals("Sheet2!A1+A5").as_deref(),
Some("Sheet2!A1+A5")
);
}
fn col_edit(op: Op, at: u32, count: u32) -> StructuralEdit {
StructuralEdit {
axis: Axis::Col,
at,
count,
op,
sheet: "Sheet1".into(),
dest: 0,
}
}
fn move_edit(at: u32, count: u32, dest: u32) -> StructuralEdit {
StructuralEdit {
axis: Axis::Row,
at,
count,
op: Op::Move,
sheet: "Sheet1".into(),
dest,
}
}
fn s(x: &str) -> Shift {
Shift::Shifted(x.into())
}
#[test]
fn col_letters() {
assert_eq!(col_to_num("A"), Some(1));
assert_eq!(col_to_num("Z"), Some(26));
assert_eq!(col_to_num("AA"), Some(27));
assert_eq!(num_to_col(1), "A");
assert_eq!(num_to_col(27), "AA");
assert_eq!(num_to_col(702), "ZZ");
}
#[test]
fn insert_below_shifts_whole_range() {
assert_eq!(
shift_body("A5:A10", &row_edit(Op::Insert, 2, 1)),
s("A6:A11")
);
}
#[test]
fn insert_inside_grows_range() {
assert_eq!(
shift_body("A5:A10", &row_edit(Op::Insert, 7, 1)),
s("A5:A11")
);
}
#[test]
fn insert_above_whole_range_unchanged() {
assert_eq!(
shift_body("A5:A10", &row_edit(Op::Insert, 20, 1)),
Shift::Unchanged
);
}
#[test]
fn insert_at_head_boundary_shifts_not_grows() {
assert_eq!(
shift_body("A5:A10", &row_edit(Op::Insert, 5, 1)),
s("A6:A11")
);
}
#[test]
fn insert_at_tail_boundary_grows() {
assert_eq!(
shift_body("A5:A10", &row_edit(Op::Insert, 10, 1)),
s("A5:A11")
);
}
#[test]
fn delete_clip_head_endpoint() {
assert_eq!(
shift_body("A5:A10", &row_edit(Op::Delete, 5, 2)),
s("A5:A8")
);
}
#[test]
fn delete_straddle_shrinks() {
assert_eq!(
shift_body("A3:A10", &row_edit(Op::Delete, 5, 2)),
s("A3:A8")
);
}
#[test]
fn delete_head_in_band_clamps_to_k() {
assert_eq!(
shift_body("A5:A10", &row_edit(Op::Delete, 3, 4)),
s("A3:A6")
);
}
#[test]
fn delete_entirely_consumed_is_ref() {
assert_eq!(shift_body("A5:A6", &row_edit(Op::Delete, 3, 6)), Shift::Ref);
}
#[test]
fn delete_single_cell_in_band_is_ref() {
assert_eq!(shift_body("A5", &row_edit(Op::Delete, 5, 1)), Shift::Ref);
}
#[test]
fn delete_below_shifts_up() {
assert_eq!(shift_body("A10", &row_edit(Op::Delete, 5, 2)), s("A8"));
}
#[test]
fn delete_above_unchanged() {
assert_eq!(
shift_body("A3", &row_edit(Op::Delete, 5, 2)),
Shift::Unchanged
);
}
#[test]
fn delete_tail_in_band_clamps_to_k_minus_1() {
assert_eq!(shift_body("A3:A6", &row_edit(Op::Delete, 5, 4)), s("A3:A4"));
}
#[test]
fn absolute_ref_still_shifts_structurally() {
assert_eq!(shift_body("$A$5", &row_edit(Op::Insert, 5, 1)), s("$A$6"));
}
#[test]
fn mixed_ref_preserves_dollars() {
assert_eq!(
shift_body("$A5:B$10", &row_edit(Op::Insert, 2, 3)),
s("$A8:B$13")
);
}
#[test]
fn row_op_leaves_whole_column_ref_untouched() {
assert_eq!(
shift_body("A:A", &row_edit(Op::Insert, 5, 1)),
Shift::Unchanged
);
}
#[test]
fn row_op_shifts_whole_row_ref() {
assert_eq!(shift_body("5:5", &row_edit(Op::Insert, 2, 1)), s("6:6"));
}
#[test]
fn col_op_shifts_columns_only() {
assert_eq!(
shift_body("B5:D10", &col_edit(Op::Insert, 3, 1)),
s("B5:E10")
);
}
#[test]
fn col_op_leaves_whole_row_ref_untouched() {
assert_eq!(
shift_body("5:5", &col_edit(Op::Insert, 3, 1)),
Shift::Unchanged
);
}
#[test]
fn same_sheet_unqualified_shifts() {
let e = row_edit(Op::Insert, 5, 1); assert_eq!(shift_ref("A5", "Sheet1", &e), s("A6"));
}
#[test]
fn other_sheet_formula_unqualified_does_not_shift() {
let e = row_edit(Op::Insert, 5, 1);
assert_eq!(shift_ref("A5", "Sheet2", &e), Shift::Unchanged);
}
#[test]
fn cross_sheet_ref_to_edited_sheet_shifts() {
let e = row_edit(Op::Insert, 5, 1);
assert_eq!(shift_ref("Sheet1!A5", "Sheet2", &e), s("Sheet1!A6"));
}
#[test]
fn cross_sheet_ref_to_other_sheet_unchanged() {
let e = row_edit(Op::Insert, 5, 1);
assert_eq!(shift_ref("Sheet3!A5", "Sheet2", &e), Shift::Unchanged);
}
#[test]
fn external_ref_never_shifts() {
let e = row_edit(Op::Insert, 5, 1);
assert_eq!(shift_ref("[1]Sheet1!A5", "Sheet1", &e), Shift::Unchanged);
}
#[test]
fn quoted_sheet_name_scoping() {
let mut e = row_edit(Op::Insert, 5, 1);
e.sheet = "My Sheet".into();
assert_eq!(shift_ref("'My Sheet'!A5", "Other", &e), s("'My Sheet'!A6"));
}
#[test]
fn three_d_span_including_edited_sheet_shifts() {
let e = row_edit(Op::Insert, 5, 1);
assert_eq!(
shift_ref("Sheet1:Sheet3!A5", "Sheet2", &e),
s("Sheet1:Sheet3!A6")
);
}
fn sf(f: &str, sheet: &str, e: &StructuralEdit) -> String {
shift_formula(f, sheet, e).0
}
#[test]
fn formula_shifts_simple_range() {
assert_eq!(
sf("SUM(A5:A10)", "Sheet1", &row_edit(Op::Insert, 2, 1)),
"SUM(A6:A11)"
);
}
#[test]
fn formula_leaves_function_names_alone() {
assert_eq!(
sf("SUM(A5)+MAX(B2)", "Sheet1", &row_edit(Op::Insert, 100, 1)),
"SUM(A5)+MAX(B2)"
);
}
#[test]
fn formula_does_not_touch_string_literals() {
assert_eq!(
sf(
r#"IF(A5>0,"row A5 here",B10)"#,
"Sheet1",
&row_edit(Op::Insert, 2, 1)
),
r#"IF(A6>0,"row A5 here",B11)"#
);
}
#[test]
fn formula_shifts_cross_sheet_and_scopes() {
assert_eq!(
sf(
"Sheet1!A5+Sheet2!B10",
"SheetX",
&row_edit(Op::Insert, 3, 1)
),
"Sheet1!A6+Sheet2!B10"
);
}
#[test]
fn formula_preserves_absolute_and_mixed() {
assert_eq!(
sf("$A$5+B$10", "Sheet1", &row_edit(Op::Insert, 5, 2)),
"$A$7+B$12"
);
}
#[test]
fn formula_delete_produces_ref_error() {
assert_eq!(
sf("A5+B10", "Sheet1", &row_edit(Op::Delete, 5, 1)),
"#REF!+B9"
);
}
#[test]
fn formula_indirect_text_arg_not_shifted() {
assert_eq!(
sf(
r#"INDIRECT("A5")+B10"#,
"Sheet1",
&row_edit(Op::Insert, 2, 1)
),
r#"INDIRECT("A5")+B11"#
);
}
#[test]
fn formula_whole_column_under_row_op_unchanged() {
assert_eq!(
sf("SUM(A:A)", "Sheet1", &row_edit(Op::Insert, 5, 1)),
"SUM(A:A)"
);
}
#[test]
fn formula_whole_column_under_col_op_shifts() {
assert_eq!(
sf("SUM(A:A)", "Sheet1", &col_edit(Op::Insert, 1, 1)),
"SUM(B:B)"
);
assert_eq!(
sf("SUM(A:C)", "Sheet1", &col_edit(Op::Insert, 1, 1)),
"SUM(B:D)"
);
assert_eq!(
sf("SUM($A:$C)", "Sheet1", &col_edit(Op::Insert, 1, 1)),
"SUM($B:$D)"
);
}
#[test]
fn formula_whole_column_delete_consumed_is_ref() {
assert_eq!(
sf("SUM(A:A)", "Sheet1", &col_edit(Op::Delete, 1, 1)),
"SUM(#REF!)"
);
}
#[test]
fn formula_whole_row_under_row_op_shifts() {
assert_eq!(
sf("SUM(5:5)", "Sheet1", &row_edit(Op::Insert, 2, 1)),
"SUM(6:6)"
);
}
#[test]
fn formula_quoted_sheet() {
let mut e = row_edit(Op::Insert, 5, 1);
e.sheet = "My Sheet".into();
assert_eq!(sf("'My Sheet'!A5*2", "Other", &e), "'My Sheet'!A6*2");
}
#[test]
fn formula_counts_shifts() {
let (nf, n) = shift_formula("A5+A6+A100", "Sheet1", &row_edit(Op::Insert, 50, 1));
assert_eq!(nf, "A5+A6+A101");
assert_eq!(n, 1); }
#[test]
fn formula_function_name_with_digits_not_shifted() {
assert_eq!(
sf("BIN2DEC(A2)", "Sheet1", &row_edit(Op::Insert, 2, 1)),
"BIN2DEC(A3)"
);
assert_eq!(
sf("BIN2HEX(A2,B2)", "Sheet1", &row_edit(Op::Insert, 2, 1)),
"BIN2HEX(A3,B3)"
);
assert_eq!(
sf("Sales2020+A2", "Sheet1", &row_edit(Op::Insert, 2, 1)),
"Sales2020+A3"
);
assert_eq!(
sf("LOG10(A10)", "Sheet1", &row_edit(Op::Insert, 2, 1)),
"LOG10(A11)"
);
assert_eq!(
sf("A10+LOG10(A10)", "Sheet1", &row_edit(Op::Insert, 2, 1)),
"A11+LOG10(A11)"
);
assert_eq!(
sf("A1.tax+A2", "Sheet1", &row_edit(Op::Insert, 1, 1)),
"A1.tax+A3"
);
assert_eq!(
sf("Q3.total*2", "Sheet1", &row_edit(Op::Insert, 1, 1)),
"Q3.total*2"
);
}
#[test]
fn formula_non_ascii_or_backslash_prefixed_name_suffix_not_shifted() {
let ins1 = row_edit(Op::Insert, 1, 1);
assert_eq!(sf("売上A5", "Sheet1", &ins1), "売上A5");
assert_eq!(sf("予算Q1", "Sheet1", &ins1), "予算Q1");
assert_eq!(sf("\\A5", "Sheet1", &ins1), "\\A5");
assert_eq!(sf("SUM(売上A5)", "Sheet1", &ins1), "SUM(売上A5)");
assert_eq!(
sf("IF(A1=1,\"x\",売上A5)", "Sheet1", &ins1),
"IF(A2=1,\"x\",売上A5)"
);
assert_eq!(sf("売上&A5", "Sheet1", &ins1), "売上&A6");
}
#[test]
fn formula_out_of_grid_tokens_not_shifted() {
assert_eq!(
sf("XFE9+A5", "Sheet1", &row_edit(Op::Insert, 2, 1)),
"XFE9+A6"
);
assert_eq!(
sf("ZZZ9+A5", "Sheet1", &row_edit(Op::Insert, 2, 1)),
"ZZZ9+A6"
);
assert_eq!(
sf("A2000000+A5", "Sheet1", &row_edit(Op::Insert, 2, 1)),
"A2000000+A6"
);
assert_eq!(
sf("XFD9+A5", "Sheet1", &row_edit(Op::Insert, 2, 1)),
"XFD10+A6"
);
}
#[test]
fn offset_translates_relative_refs() {
assert_eq!(offset_formula("A2*2", 3, 0), "A5*2");
assert_eq!(offset_formula("A2*2", 0, 0), "A2*2");
}
#[test]
fn offset_keeps_absolute_fixed() {
assert_eq!(offset_formula("$A$1+B2", 3, 0), "$A$1+B5");
assert_eq!(offset_formula("$A2+A$1", 3, 2), "$A5+C$1");
}
#[test]
fn offset_range_and_cross_sheet() {
assert_eq!(offset_formula("SUM(A2:A10)", 5, 0), "SUM(A7:A15)");
assert_eq!(offset_formula("Sheet2!A2*Q1", 0, 1), "Sheet2!B2*R1");
}
#[test]
fn offset_leaves_non_ascii_or_backslash_prefixed_name_intact() {
assert_eq!(offset_formula("名A5*2", 1, 0), "名A5*2");
assert_eq!(offset_formula("予算Q1+1", 3, 0), "予算Q1+1");
assert_eq!(offset_formula("\\A5", 5, 0), "\\A5");
assert_eq!(offset_formula("名A5+B2", 3, 0), "名A5+B5");
}
#[test]
fn offset_underflow_is_ref() {
assert_eq!(offset_formula("A2", -5, 0), "#REF!");
}
#[test]
fn offset_overflow_is_ref() {
assert_eq!(offset_formula("XFC1", 0, 2), "#REF!"); assert_eq!(offset_formula("A1048575", 5, 0), "#REF!"); assert_eq!(offset_formula("XFC1", 0, 1), "XFD1"); }
#[test]
fn offset_leaves_strings_and_functions() {
assert_eq!(
offset_formula(r#"IF(A2,"A2",B2)"#, 1, 0),
r#"IF(A3,"A2",B3)"#
);
}
#[test]
fn defined_name_cell_ref_collision_detection() {
assert!(looks_like_cell_ref("FY2021")); assert!(looks_like_cell_ref("Q1"));
assert!(looks_like_cell_ref("$A$5"));
assert!(looks_like_cell_ref("XFD1"));
assert!(!looks_like_cell_ref("TaxRate")); assert!(!looks_like_cell_ref("XFE9")); assert!(!looks_like_cell_ref("A2000000")); assert!(!looks_like_cell_ref("Sales2020")); assert!(!looks_like_cell_ref("FY2021x")); assert!(!looks_like_cell_ref("Total"));
}
#[test]
fn detects_array_and_shared_residual() {
assert_eq!(
residual_reason("t=\"array\" ref=\"C2:C10\""),
Some("array_formula_present")
);
assert_eq!(
residual_reason("t=\"shared\" ref=\"B2:B100\" si=\"0\""),
Some("shared_formula_present")
);
assert_eq!(residual_reason(""), None);
}
#[test]
fn move_sigma_down_matches_hand() {
assert_eq!(move_row_sigma(5, 5, 2, 9), 7);
assert_eq!(move_row_sigma(6, 5, 2, 9), 8);
assert_eq!(move_row_sigma(7, 5, 2, 9), 5);
assert_eq!(move_row_sigma(8, 5, 2, 9), 6);
assert_eq!(move_row_sigma(4, 5, 2, 9), 4);
assert_eq!(move_row_sigma(9, 5, 2, 9), 9);
assert_eq!(move_row_sigma(10, 5, 2, 9), 10);
}
#[test]
fn move_sigma_up_matches_hand() {
assert_eq!(move_row_sigma(6, 6, 1, 3), 3);
assert_eq!(move_row_sigma(3, 6, 1, 3), 4);
assert_eq!(move_row_sigma(4, 6, 1, 3), 5);
assert_eq!(move_row_sigma(5, 6, 1, 3), 6);
assert_eq!(move_row_sigma(2, 6, 1, 3), 2);
assert_eq!(move_row_sigma(7, 6, 1, 3), 7);
}
#[test]
fn move_sigma_identity_when_adjacent() {
for pos in 1..=12u32 {
assert_eq!(move_row_sigma(pos, 5, 2, 5), pos, "dest==a identity");
assert_eq!(move_row_sigma(pos, 5, 2, 7), pos, "dest==a+n identity");
assert_eq!(
move_row_sigma(pos, 5, 2, 6),
pos,
"dest inside block identity"
);
}
}
#[test]
fn move_sigma_is_a_bijection_on_1_to_maxrow() {
let maxrow = 20u32;
for a in 1..=maxrow {
for n in 1..=(maxrow + 1 - a) {
for b in 1..=(maxrow + 1) {
if b > a + n && b > maxrow + 1 {
continue;
}
let mut seen = std::collections::BTreeSet::new();
for pos in 1..=maxrow {
let img = move_row_sigma(pos, a, n, b);
assert!(
(1..=maxrow).contains(&img),
"σ out of grid: a={a} n={n} b={b} pos={pos} -> {img}"
);
assert!(
seen.insert(img),
"σ not injective: a={a} n={n} b={b} collision at {img}"
);
}
assert_eq!(seen.len(), maxrow as usize);
}
}
}
}
#[test]
fn move_sigma_preserves_block_order() {
for &(a, n, b) in &[(5u32, 3u32, 12u32), (8, 4, 2), (6, 1, 3), (2, 5, 15)] {
for p in a..(a + n - 1) {
assert!(
move_row_sigma(p, a, n, b) < move_row_sigma(p + 1, a, n, b),
"block order broken a={a} n={n} b={b} at p={p}"
);
}
}
}
#[test]
fn move_single_cell_refs_follow_sigma_down() {
assert_eq!(shift_body("A5", &move_edit(5, 2, 9)), s("A7"));
assert_eq!(shift_body("A10", &move_edit(5, 2, 9)), Shift::Unchanged);
assert_eq!(shift_body("A7", &move_edit(5, 2, 9)), s("A5"));
assert_eq!(shift_body("$A$5", &move_edit(5, 2, 9)), s("$A$7"));
}
#[test]
fn move_single_cell_refs_follow_sigma_up() {
assert_eq!(shift_body("A6", &move_edit(6, 1, 3)), s("A3"));
assert_eq!(shift_body("A3", &move_edit(6, 1, 3)), s("A4"));
assert_eq!(shift_body("A5", &move_edit(6, 1, 3)), s("A6"));
assert_eq!(shift_body("A2", &move_edit(6, 1, 3)), Shift::Unchanged);
}
#[test]
fn move_range_within_block_or_gap_shifts_as_rectangle() {
assert_eq!(shift_body("A5:A6", &move_edit(5, 2, 9)), s("A7:A8"));
assert_eq!(shift_body("A3:A5", &move_edit(6, 1, 3)), s("A4:A6"));
}
#[test]
fn move_range_straddling_boundary_is_ref() {
assert_eq!(shift_body("A4:A6", &move_edit(6, 1, 3)), Shift::Ref);
assert_eq!(shift_body("A6:A8", &move_edit(5, 2, 9)), Shift::Ref);
}
#[test]
fn move_identity_leaves_refs_unchanged() {
assert_eq!(shift_body("A5", &move_edit(5, 2, 5)), Shift::Unchanged); assert_eq!(shift_body("A6", &move_edit(5, 2, 7)), Shift::Unchanged); }
#[test]
fn move_formula_shifts_single_cells_and_detects_straddle() {
assert_eq!(sf("A5+A10", "Sheet1", &move_edit(5, 2, 9)), "A7+A10");
assert_eq!(sf("A6+A3", "Sheet1", &move_edit(6, 1, 3)), "A3+A4");
assert!(sf("SUM(A4:A6)", "Sheet1", &move_edit(6, 1, 3)).contains("#REF!"));
assert!(sf("SUM(A4:A6)", "Sheet1", &move_edit(5, 3, 20)).contains("#REF!"));
assert_eq!(
sf("SUM(A1:A10)", "Sheet1", &move_edit(1, 1, 3)),
"SUM(A1:A10)"
);
assert_eq!(
sf("SUM(A1:A10)", "Sheet1", &move_edit(10, 1, 3)),
"SUM(A1:A10)"
);
assert_eq!(
sf("SUM($5:$10)", "Sheet1", &row_edit(Op::Insert, 2, 1)),
"SUM($6:$11)"
);
assert_eq!(
sf("SUM(5:$10)", "Sheet1", &row_edit(Op::Insert, 2, 1)),
"SUM(6:$11)"
);
assert_eq!(
sf("SUM($5:$5)", "Sheet1", &row_edit(Op::Insert, 2, 1)),
"SUM($6:$6)"
);
assert_eq!(
sf("SUM($A:$C)", "Sheet1", &row_edit(Op::Insert, 2, 1)),
"SUM($A:$C)"
);
}
#[test]
fn whitespace_range_and_grid_boundary() {
assert_eq!(
sf("SUM(A2 : A8)", "Sheet1", &row_edit(Op::Insert, 3, 1)),
"SUM(A2:A9)"
);
assert!(sf("SUM(A2 : A8)", "Sheet1", &move_edit(5, 1, 20)).contains("#REF!"));
assert!(sf("A1048576", "Sheet1", &row_edit(Op::Insert, 1, 1)).contains("#REF!"));
assert!(sf("XFD1", "Sheet1", &col_edit(Op::Insert, 1, 1)).contains("#REF!"));
assert_eq!(
sf("A1048575", "Sheet1", &row_edit(Op::Insert, 1, 1)),
"A1048576"
);
assert_eq!(
sf("SUM(A1:A1048576)", "Sheet1", &row_edit(Op::Insert, 2, 1)),
"SUM(A1:A1048576)"
);
assert_eq!(
sf("SUM(A2:A1048576)", "Sheet1", &row_edit(Op::Insert, 2, 1)),
"SUM(A3:A1048576)"
);
assert!(!sf("A5+A10", "Sheet1", &move_edit(5, 2, 9)).contains("#REF!"));
assert_eq!(
sf(r#"IF(A6>0,"A6",A3)"#, "Sheet1", &move_edit(6, 1, 3)),
r#"IF(A3>0,"A6",A4)"#
);
}
fn parse_res(sh: &Shift, lo: u32, hi: u32) -> Option<(u32, u32)> {
match sh {
Shift::Unchanged => Some((lo, hi)),
Shift::Ref => None,
Shift::Shifted(s) => {
if let Some((a, b)) = s.split_once(':') {
let pa: u32 = a.trim_start_matches('A').parse().unwrap();
let pb: u32 = b.trim_start_matches('A').parse().unwrap();
Some((pa.min(pb), pa.max(pb)))
} else {
let p: u32 = s.trim_start_matches('A').parse().unwrap();
Some((p, p))
}
}
}
}
fn body_for(lo: u32, hi: u32) -> String {
if lo == hi {
format!("A{}", lo)
} else {
format!("A{}:A{}", lo, hi)
}
}
#[test]
fn fuzz_delete_against_set_oracle() {
let g = 14u32;
let mut fails = Vec::new();
for lo in 1..=g {
for hi in lo..=g {
for k in 1..=g {
for n in 1..=g {
let mut imgs = Vec::new();
for r in lo..=hi {
if r < k {
imgs.push(r);
} else if r >= k + n {
imgs.push(r - n);
}
}
let oracle = if imgs.is_empty() {
None
} else {
Some((*imgs.iter().min().unwrap(), *imgs.iter().max().unwrap()))
};
let got = parse_res(
&shift_body(&body_for(lo, hi), &row_edit(Op::Delete, k, n)),
lo,
hi,
);
if got != oracle {
fails.push(format!(
"DEL lo={} hi={} k={} n={} oracle={:?} got={:?}",
lo, hi, k, n, oracle, got
));
}
}
}
}
}
assert!(fails.is_empty(), "DELETE mismatches:\n{}", fails.join("\n"));
}
#[test]
fn fuzz_move_against_set_oracle() {
let g = 12u32;
let mut fails = Vec::new();
for lo in 1..=g {
for hi in lo..=g {
for a in 1..=g {
for n in 1..=g {
if a + n - 1 > g {
continue;
}
for b in 1..=(g + 1) {
let imgs: Vec<u32> =
(lo..=hi).map(|r| move_row_sigma(r, a, n, b)).collect();
let mn = *imgs.iter().min().unwrap();
let mx = *imgs.iter().max().unwrap();
let oracle = if mx - mn == hi - lo {
Some((mn, mx))
} else {
None
};
let got = parse_res(
&shift_body(&body_for(lo, hi), &move_edit(a, n, b)),
lo,
hi,
);
if got != oracle {
fails.push(format!(
"MOV lo={} hi={} a={} n={} b={} oracle={:?} got={:?}",
lo, hi, a, n, b, oracle, got
));
}
}
}
}
}
}
assert!(fails.is_empty(), "MOVE mismatches:\n{}", fails.join("\n"));
}
#[test]
fn fuzz_move_sigma_independent() {
let g = 12u32;
let mut fails = Vec::new();
for a in 1..=g {
for n in 1..=g {
if a + n - 1 > g {
continue;
}
for b in 1..=(g + 1) {
let block: Vec<u32> = (a..a + n).collect();
let rem: Vec<u32> = (1..=g).filter(|r| !(*r >= a && *r < a + n)).collect();
let pos = rem.iter().position(|&r| r >= b).unwrap_or(rem.len());
let mut newlist = rem[..pos].to_vec();
newlist.extend(block.iter().copied());
newlist.extend_from_slice(&rem[pos..]);
for orig in 1..=g {
let want = newlist.iter().position(|&r| r == orig).unwrap() as u32 + 1;
let got = move_row_sigma(orig, a, n, b);
if got != want {
fails.push(format!(
"a={} n={} b={} orig={} want={} got={}",
a, n, b, orig, want, got
));
}
}
}
}
}
assert!(fails.is_empty(), "sigma mismatches:\n{}", fails.join("\n"));
}
}