Files
gridcraft/crates/formula/src/printer.rs
T
NetterSilverandClaude Opus 5.5 393a524aa3 Speed up XLSX worksheet writing
Saving a large workbook spent about half its time building worksheet XML,
mostly in small per-cell allocations. The writer now appends to the part
directly:

- Cell references, row numbers, style ids, shared string indexes and
  numbers are appended without temporary strings (push_a1, push_u64,
  push_num), and text is escaped in place (push_esc, with a fast path for
  plain ASCII).
- sheetData walks the cell store in its own row-major order instead of
  copying every address into a BTreeSet and a per-row BTreeMap and looking
  each cell up again. Cells outside the store (pictures, spill values,
  table headers) still go through the sorted set.
- Formulas with nothing the file spells differently (no LET/LAMBDA, no
  _xlfn function, no spill, @, trim operator or structured reference) are
  printed from the shared expression instead of being cloned and rebuilt
  twice by the file rewrite.
- The A1 printer in gridcraft-formula appends references to the output
  instead of formatting each anchor into its own string.

The output is byte-for-byte unchanged.

write_perf, 200,000 rows (2.0M cells, 14.2 MB package), release, Ryzen
16 threads / Linux, 36 interleaved runs each:
  before: median 2083.9 ms, min 1615.7 ms
  after:  median 1501.3 ms, min 1192.3 ms  (-28.0 % median, -26.2 % min)

What remains is mostly deflate (zlib-rs, level 6), about 70 % of the save.

Tests pin the exact sheetData of a workbook covering row settings between
cell rows, spill values, every value kind, number spellings, escaping and
formulas with and without a file spelling (generated with the previous
writer), and check each appending helper and the formula fast path against
the code they replace.

The formula fast path is guarded by a test that runs both paths over every
function in the gridcraft-functions registry (a new dev-dependency, allowed
by the layer rules), in 15 surrounding shapes covering every operator and
rewrite, so a function or rewrite added later without updating the trigger
list fails it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 16:46:25 +01:00

360 lines
12 KiB
Rust

//! [`Expr`] → formula text (without `=`), A1 or R1C1.
use std::fmt::Write;
use gridcraft_core::{CellRef, number_to_text};
use crate::ast::{Anchor, BinOp, Expr, RefKind, Reference, SheetSel, StructItem, StructRef, UnOp};
pub use gridcraft_core::{quote_sheet, sheet_needs_quotes as needs_quotes};
fn sheet_prefix(s: &SheetSel) -> String {
match s {
SheetSel::Current => String::new(),
SheetSel::Named(n) => format!("{}!", quote_sheet(n)),
SheetSel::Span(a, b) => {
let joined = format!("{a}:{b}");
if needs_quotes(a) || needs_quotes(b) { format!("'{}'!", joined.replace('\'', "''")) } else { format!("{joined}!") }
}
}
}
fn push_anchor_a1(s: &mut String, a: &Anchor) {
if a.col_abs {
s.push('$');
}
push_col(s, a.col);
if a.row_abs {
s.push('$');
}
let _ = write!(s, "{}", a.row as u64 + 1);
}
/// Appends [`gridcraft_core::col_to_letters`]`(col)` without allocating.
fn push_col(s: &mut String, col: u32) {
let mut letters = [0u8; 8];
let mut i = letters.len();
let mut n = col as u64 + 1;
while n > 0 && i > 0 {
i -= 1;
if let Some(l) = letters.get_mut(i) {
*l = b'A' + ((n - 1) % 26) as u8;
}
n = (n - 1) / 26;
}
for &l in letters.get(i..).unwrap_or_default() {
s.push(char::from(l));
}
}
pub fn reference_a1(r: &Reference) -> String {
range_a1(r, ":")
}
/// A1 text with `sep` between the two ends of a range (`:` or a trim operator).
fn range_a1(r: &Reference, sep: &str) -> String {
let mut s = String::new();
push_range_a1(&mut s, r, sep);
s
}
/// Appends [`range_a1`]`(r, sep)` to `s`.
fn push_range_a1(s: &mut String, r: &Reference, sep: &str) {
match &r.sheet {
SheetSel::Current => {}
SheetSel::Named(n) => {
s.push_str(&quote_sheet(n));
s.push('!');
}
sheet @ SheetSel::Span(_, _) => s.push_str(&sheet_prefix(sheet)),
}
let dollar = |abs: bool| if abs { "$" } else { "" };
match &r.kind {
RefKind::Cell(a) => push_anchor_a1(s, a),
RefKind::Range(a, b) => {
push_anchor_a1(s, a);
s.push_str(sep);
push_anchor_a1(s, b);
}
RefKind::Rows(r0, a0, r1, a1) => {
let _ = write!(s, "{}{}{sep}{}{}", dollar(*a0), r0 + 1, dollar(*a1), r1 + 1);
}
RefKind::Cols(c0, a0, c1, a1) => {
s.push_str(dollar(*a0));
push_col(s, *c0);
s.push_str(sep);
s.push_str(dollar(*a1));
push_col(s, *c1);
}
}
}
fn r1c1_part(letter: char, v: u32, abs: bool, base: u32) -> String {
if abs {
format!("{letter}{}", v + 1)
} else {
let d = v as i64 - base as i64;
if d == 0 { letter.to_string() } else { format!("{letter}[{d}]") }
}
}
fn anchor_r1c1(a: &Anchor, at: CellRef) -> String {
format!("{}{}", r1c1_part('R', a.row, a.row_abs, at.row), r1c1_part('C', a.col, a.col_abs, at.col))
}
pub fn reference_r1c1(r: &Reference, at: CellRef) -> String {
range_r1c1(r, at, ":")
}
/// R1C1 text with `sep` between the two ends of a range; a trim operator never collapses
/// `R1:R1` to `R1`.
fn range_r1c1(r: &Reference, at: CellRef, sep: &str) -> String {
let body = match &r.kind {
RefKind::Cell(a) => anchor_r1c1(a, at),
RefKind::Range(a, b) => format!("{}{sep}{}", anchor_r1c1(a, at), anchor_r1c1(b, at)),
RefKind::Rows(r0, a0, r1, a1) => {
let (x, y) = (r1c1_part('R', *r0, *a0, at.row), r1c1_part('R', *r1, *a1, at.row));
if x == y && sep == ":" { x } else { format!("{x}{sep}{y}") }
}
RefKind::Cols(c0, a0, c1, a1) => {
let (x, y) = (r1c1_part('C', *c0, *a0, at.col), r1c1_part('C', *c1, *a1, at.col));
if x == y && sep == ":" { x } else { format!("{x}{sep}{y}") }
}
};
format!("{}{}", sheet_prefix(&r.sheet), body)
}
pub fn struct_ref(s: &StructRef) -> String {
write_struct_ref(s, true)
}
/// Structured reference as files spell it: `[@Col]` is only Excel's display shorthand, files
/// hold `[[#This Row],[Col]]` (and `[#This Row]` for `[@]`).
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 {
StructItem::All => "#All",
StructItem::Data => "#Data",
StructItem::Headers => "#Headers",
StructItem::Totals => "#Totals",
StructItem::ThisRow => "#This Row",
};
let this_row = at_shorthand && s.specifiers == [StructItem::ThisRow];
let mut out = s.table.clone();
let simple_col = |c: &str| !c.chars().any(|ch| matches!(ch, ' ' | '[' | ']' | '#' | '\'' | ',' | ':'));
match (&s.col_start, &s.col_end) {
(None, None) if s.specifiers.is_empty() => out.push_str("[]"),
(None, None) if this_row => out.push_str("[@]"),
(None, None) if s.specifiers.len() == 1 => {
let _ = write!(out, "[{}]", s.specifiers.first().map(item).unwrap_or(""));
}
(Some(c), None) if s.specifiers.is_empty() => {
// Excel writes a name with spaces at its edges in the double-bracket form.
if c.trim() == c {
let _ = write!(out, "[{}]", esc(c));
} else {
let _ = write!(out, "[[{}]]", esc(c));
}
}
(Some(c), None) if this_row => {
if simple_col(c) {
let _ = write!(out, "[@{}]", esc(c));
} else {
let _ = write!(out, "[@[{}]]", esc(c));
}
}
_ => {
let mut parts: Vec<String> = s.specifiers.iter().map(|i| format!("[{}]", item(i))).collect();
match (&s.col_start, &s.col_end) {
(Some(a), Some(b)) => parts.push(format!("[{}]:[{}]", esc(a), esc(b))),
(Some(a), None) => parts.push(format!("[{}]", esc(a))),
_ => {}
}
let _ = write!(out, "[{}]", parts.join(","));
}
}
out
}
fn number(n: f64) -> String {
let text = number_to_text(n);
// 15 significant digits can round past f64::MAX, which no longer parses.
if text.parse::<f64>().is_ok_and(f64::is_finite) {
return text;
}
let exact = format!("{:E}", n);
match exact.split_once('E') {
Some((m, e)) if !e.starts_with('-') => format!("{m}E+{e}"),
_ => exact,
}
}
/// Prints an expression in A1 notation.
pub fn print(e: &Expr) -> String {
let mut s = String::new();
write_expr(&mut s, e, None);
s
}
/// Prints an expression in R1C1 notation relative to `at`.
pub fn print_r1c1(e: &Expr, at: CellRef) -> String {
let mut s = String::new();
write_expr(&mut s, e, Some(at));
s
}
fn write_expr(s: &mut String, e: &Expr, r1c1: Option<CellRef>) {
match e {
Expr::Number(n) => s.push_str(&number(*n)),
Expr::Text(t) => {
s.push('"');
s.push_str(&t.replace('"', "\"\""));
s.push('"');
}
Expr::Bool(b) => s.push_str(if *b { "TRUE" } else { "FALSE" }),
Expr::Error(err) => s.push_str(err.as_str()),
Expr::Array(rows) => {
s.push('{');
for (ri, row) in rows.iter().enumerate() {
if ri > 0 {
s.push(';');
}
for (ci, el) in row.iter().enumerate() {
if ci > 0 {
s.push(',');
}
write_expr(s, el, r1c1);
}
}
s.push('}');
}
Expr::Ref(r) => match r1c1 {
Some(at) => s.push_str(&reference_r1c1(r, at)),
None => push_range_a1(s, r, ":"),
},
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(&quote_sheet(sheet));
s.push('!');
s.push_str(name);
}
_ => s.push_str(n),
},
Expr::Struct(st) => s.push_str(&struct_ref(st)),
Expr::Unary(op, x) => match op {
UnOp::Neg => {
s.push('-');
write_expr(s, x, r1c1);
}
UnOp::Plus => {
s.push('+');
write_expr(s, x, r1c1);
}
UnOp::At => {
s.push('@');
write_expr(s, x, r1c1);
}
UnOp::Percent => {
write_expr(s, x, r1c1);
s.push('%');
}
UnOp::Spill => {
write_expr(s, x, r1c1);
// A reference that became #REF! (its sheet or cell was deleted) stays `#REF!`.
if !matches!(**x, Expr::Error(_)) {
s.push('#');
}
}
UnOp::Trim(mode) => match &**x {
Expr::Ref(r) if !matches!(r.kind, RefKind::Cell(_)) => match r1c1 {
Some(at) => s.push_str(&range_r1c1(r, at, mode.symbol())),
None => push_range_a1(s, r, mode.symbol()),
},
Expr::Binary(BinOp::Range, a, b) => {
write_expr(s, a, r1c1);
s.push_str(mode.symbol());
write_expr(s, b, r1c1);
}
// A reference that became #REF! stays `#REF!`.
Expr::Error(_) => write_expr(s, x, r1c1),
// No operator form (e.g. a range shrunk to one cell): the equivalent call.
other => {
s.push_str("TRIMRANGE(");
write_expr(s, other, r1c1);
let _ = write!(s, ",{0},{0})", mode.code());
}
},
},
Expr::Binary(..) => {
// Walk the left spine iteratively: a flat chain such as `1+1+1+...` parses into a
// tree one level deeper per operator, and recursing on it could exhaust the stack.
let mut rest: Vec<(&BinOp, &Expr)> = Vec::new();
let mut first = e;
while let Expr::Binary(op, a, b) = first {
rest.push((op, b));
first = a;
}
write_expr(s, first, r1c1);
for (op, right) in rest.into_iter().rev() {
s.push_str(op.symbol());
write_expr(s, right, r1c1);
}
}
Expr::Call(name, args) => {
s.push_str(name);
s.push('(');
for (i, a) in args.iter().enumerate() {
if i > 0 {
s.push(',');
}
write_expr(s, a, r1c1);
}
s.push(')');
}
Expr::Invoke(c, args) => {
write_expr(s, c, r1c1);
s.push('(');
for (i, a) in args.iter().enumerate() {
if i > 0 {
s.push(',');
}
write_expr(s, a, r1c1);
}
s.push(')');
}
Expr::Missing => {}
Expr::Paren(x) => {
s.push('(');
write_expr(s, x, r1c1);
s.push(')');
}
}
}