From c39d2ad197e00fe4bc55572e075bf1702b1cab40 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Thu, 22 Oct 2026 09:47:21 +0200 Subject: [PATCH] Keep '!' in structured-reference columns intact on XLSX save Found while testing crates/xlsx/src/fmla.rs:101 (with crates/formula/src/printer.rs:181) Co-Authored-By: Claude Opus 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/formula/src/printer.rs | 24 +++++++++++++++++++++++- crates/xlsx/src/fmla.rs | 12 ++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/crates/formula/src/printer.rs b/crates/formula/src/printer.rs index 055b0f145..e69351168 100644 --- a/crates/formula/src/printer.rs +++ b/crates/formula/src/printer.rs @@ -85,6 +85,28 @@ pub fn struct_ref_file(s: &StructRef) -> String { write_struct_ref(s, false) } +/// Splits a sheet-qualified name at its last `!` outside brackets. A `!` inside brackets +/// belongs to a structured reference's column (`Table1[Done!]`, `Table1[#REF!]`). +fn sheet_split(n: &str) -> Option<(&str, &str)> { + let mut depth = 0usize; + let mut escaped = false; + let mut at = None; + for (i, c) in n.char_indices() { + if escaped { + escaped = false; + continue; + } + match c { + '\'' if depth > 0 => escaped = true, + '[' => depth = depth.saturating_add(1), + ']' => depth = depth.saturating_sub(1), + '!' if depth == 0 => at = Some(i), + _ => {} + } + } + at.and_then(|i| Some((n.get(..i)?, n.get(i + 1..)?))) +} + fn write_struct_ref(s: &StructRef, at_shorthand: bool) -> String { let esc = |c: &str| c.replace('\'', "''").replace('[', "'[").replace(']', "']").replace('#', "'#"); let item = |i: &StructItem| match i { @@ -178,7 +200,7 @@ fn write_expr(s: &mut String, e: &Expr, r1c1: Option) { Some(at) => s.push_str(&reference_r1c1(r, at)), None => s.push_str(&reference_a1(r)), }, - Expr::Name(n) => match n.rsplit_once('!') { + Expr::Name(n) => match sheet_split(n) { // A sheet-qualified name is stored with the bare sheet name (`My Sheet!Total`): quote it so it parses back. Some((sheet, name)) => { s.push_str("e_sheet(sheet)); diff --git a/crates/xlsx/src/fmla.rs b/crates/xlsx/src/fmla.rs index bef43a5aa..c5e1f686c 100644 --- a/crates/xlsx/src/fmla.rs +++ b/crates/xlsx/src/fmla.rs @@ -183,6 +183,18 @@ mod tests { } } + #[test] + fn struct_ref_column_with_bang() { + // A `!` inside the brackets is part of the column name, not a sheet separator. + assert_eq!(to_file(&Formula::new("SUM(Table1[Done!])")), "SUM(Table1[Done!])"); + assert_eq!(text_to_file("=Table1[[#This Row],[a!b]]"), "Table1[[#This Row],[a!b]]"); + // `Table1[#REF!]` was found by the command-sequence fuzzer (seed 40). + for text in ["SUM(Table1[Done!])", "Table1[#REF!]", "Table1[@[x!]]", "Sheet2!A1+Table1[[#All],[a!b]]"] { + let f = Formula::new(text); + assert_eq!(from_file(&to_file(&f)), f.text); + } + } + #[test] fn prefixes() { assert_eq!(to_file(&Formula::new("XLOOKUP(1,A:A,B:B)+SUM(A1)")), "_xlfn.XLOOKUP(1,A:A,B:B)+SUM(A1)");