* XLSX: legacy formulas (no dynamic-array cm) open with Excel's implicit @ and calculate like Excel; implied @ saved back without cm
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* XLSX: long operator chains analysed in full and unfinished legacy analysis leaves the formula unchanged (never saved as SINGLE); @ of a union keeps its parentheses; IF's condition intersected inside SUMPRODUCT
* XLSX: a legacy union already in parentheses saves back without a second pair (never as SINGLE); a name's definition analysed as an array throughout, inserting no @
* XLSX: drop the unreachable keep-unions retry; an @ of a bare union saves as a dynamic formula
* XLSX: an @ keeps parentheses the reader would not put back (redundant ones, a union's)
* XLSX: a redundant @ keeps a legacy formula legacy; IFERROR and IFNA intersected inside SUMPRODUCT; file parentheses and unfinished analyses saved as read
An edit could leave an implied @ on a single cell: deleting rows 2:3 under the legacy SQRT(A1:A3), read as SQRT(@A1:A3), gives SQRT(@A1:A1), and a name redefined to one cell does the same to SQRT(@Rng). The writer kept an @ only when re-analysing the formula without it put the same @ back, which it no longer does for one cell, so the formula was saved as SQRT(_xlfn.SINGLE(A1:A1)) with cm, readable only by dynamic-array Excel. The writer now analyses the formula with its own @ in place: an @ is accepted when the analysis implies it at that position or when its operand is a single value there (such an @ changes nothing), and any @ inserted elsewhere or any other @ still makes the formula dynamic. SQRT(@A1:A1) saves as SQRT(A1:A1), SQRT(@A1) as SQRT(A1) and @SUM(A1:A3) as SUM(A1:A3), all without cm; SUMPRODUCT(@SQRT(A1:A3)) stays dynamic because SQRT(A1:A3) is an array there.
Legacy Excel intersects IFERROR's and IFNA's arguments inside SUMPRODUCT unless the formula is entered with Ctrl+Shift+Enter, like IF's condition: =SUMPRODUCT(IFERROR(VALUE(C19:C21),0)) returns one row's value or #VALUE!, and the accepted fix is CSE or N(+range) (https://stackoverflow.com/questions/59228707). They now get the same single-value parameter class as IF's condition (Param::Single), so the legacy SUMPRODUCT(IFERROR(A1:A3/A1:A3,0)) reads as SUMPRODUCT(IFERROR(@A1:A3/@A1:A3,0)), and a typed SUMPRODUCT(IFERROR(VALUE(A1:A3),0)), which dynamic-array Excel evaluates as an array, is saved with cm. IF's result arguments are left as arrays: in https://stackoverflow.com/questions/29682328, a non-CSE =SUMPRODUCT(--(B4=IF(NOT($C$4:$C$11),$B$4:$B$11))) in row 4 returns 2 (both "apple" rows of B4:B11 counted, so the result branch was not intersected), 0 in a row where C is TRUE and #VALUE! outside rows 4:11 (the condition was intersected).
The reader's @ in front of a whole-formula array result took no extra parentheses when the file already had them, so ({1,2}+1) and {1,2}+1 both read as @({1,2}+1), and the writer dropped the file's parentheses. An operand already in parentheses now gets a second pair, so ({1,2}+1) saves as written.
A legacy formula the analysis could not finish (node budget, nesting) was read unchanged, evaluated as a dynamic-array formula and saved with cm, so Excel spilled it. Names were analysed again at every use, so a 399-term name used six times exhausted the 4096-node budget; their kinds are now remembered per formula, and the budget is 16384 nodes. The nesting limit is 128, so Excel's 64 levels of nested functions are analysed. A formula that still exceeds the limits is saved as it was read, without cm.
* XLSX: an implied @ is dropped and any other @ of a single value kept as _xlfn.SINGLE in a legacy formula (as Excel writes it); a legacy formula the analysis cannot finish or whose semantics it does not know opens with a whole-formula @ and never spills; analysis depth follows the parser's limit
* XLSX: a legacy union given to a function's single-value parameter keeps its implied @, so it calculates to #VALUE! instead of spilling and saves as read; checked on a calculated workbook
* XLSX: only a one-cell range an @ applies to counts as one an edit shrank; a reading past the parser's nesting limit leaves the formula as read, and the writer saves the text a formula it cannot analyse was read from
* XLSX: a formula nested past the parser's limit for its reading is left as read (documented: it may spill); test covers it
* XLSX: a name's definition counts its one-cell ranges as collapsed only when it is itself a reference, so CHOOSE's index or IF's condition in it keeps the name legacy
* XLSX: an @ on a name or one-cell range counts as implied when ranges an edit may have shrunk, anywhere the name returns them (CHOOSE's choices, parentheses), make it so; deleting rows keeps such formulas legacy
* XLSX: the collapsed-range retry of an @ runs only when the first pass reached a one-cell range written as a range (tracked through cached names)
* docs: describe unfinished formula export fallback
* docs: the unfinished-analysis fallback differs for formulas with an explicit @
* Preserve main formula analyzer fixes
* Order file-format revision history
---------
Co-authored-by: neilcawse <8085959+neilcawse@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Local contributor <contributor@localhost>
adjust had no way to report that an edit does not affect a formula, so
callers copied, rebuilt and printed every formula in the workbook.
adjust_if_changed borrows the formula and returns None when nothing
moves. Its check reuses adjust_ref on each reference into the edited
sheet, on a copy without the sheet name, so it cannot disagree with
adjust. adjust_ref and shift_ref take the reference by value and no
longer clone sheet names.
An integration test keeps the previous implementation as an oracle
over every edit kind and a randomized property test.
Fixes#3576
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Found while testing crates/formula/src/printer.rs:164 (write_struct_ref, single column without specifiers)
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
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>
* Sonar: explicit imports, fn paths for closures, extract helpers for cognitive complexity
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
* Fix imports
* Fix serde imports
* Fix engine imports
* Fix clippy gate and a type error from the explicit-import change
Remove the unused imports the review flagged (cf.rs, pivot.rs, tables.rs, cmd/sheet.rs) so clippy -D warnings passes, and point the backstage parent-folder lookup at Path::to_str, which the fn-path rewrite had broken.
Generated with Codebuff
Co-Authored-By: Codebuff <noreply@codebuff.com>
* Apply rustfmt to the explicit-import cleanup so the fmt gate passes
* Share the per-directory font path expansion in theme.rs
The candidate font lists repeated the same loop over the Linux font
directories in every role. A single helper expands them in the same order,
so the lookup order is unchanged and the duplicated blocks go away.
🤖 Generated with Codebuff
Co-Authored-By: Codebuff <noreply@codebuff.com>
* Read the Windows fonts folder in one place in theme.rs
Every script-font list repeated the same WINDIR lookup. A single helper
returns the same path, so the duplicated lines go away without changing
which font files are tried.
🤖 Generated with Codebuff
Co-Authored-By: Codebuff <noreply@codebuff.com>
* Build the light and dark UI palettes from one table in theme.rs
Tokens::light() and Tokens::dark() listed the same 40 colour fields in the
same order, so every change had to be made twice. A single palette
constructor takes the mode and picks each colour's value, so each field is
written once and both modes keep their exact colours.
🤖 Generated with Codebuff
Co-Authored-By: Codebuff <noreply@codebuff.com>
* Share the Noto CJK font list between the CJK script lists in theme.rs
The Japanese, Simplified and Korean lists each repeated the same loop over
the Linux CJK directories with a different locale file name. One helper
builds that loop, and each list keeps its own face index, so the lookup
order is unchanged.
🤖 Generated with Codebuff
Co-Authored-By: Codebuff <noreply@codebuff.com>
* Share Linux Noto directory list across Thai and Hebrew font lookups
Removes the duplicated directory arrays flagged by SonarCloud new-code duplication.
* Reuse in_dirs for Thai and Hebrew Noto font candidates
Removes the remaining duplicated per-directory loops flagged by SonarCloud.
* Share one font-list builder for the Thai and Hebrew faces
The two per-script functions repeated the same Windows, macOS, Linux and
variable-font setup. A single script_fonts helper now builds both lists,
keeping the candidate order unchanged, which clears the Sonar duplication gate.
---------
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-authored-by: Codebuff <noreply@codebuff.com>
Sheet1!A1:SHEET1!B2 is a range whose second end repeats the sheet, as
Sheet1!A1:Sheet1!B2 is: the check uses same_name, Excel's case-insensitive
name comparison.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Found while testing crates/formula/src/parser.rs (reference/sheet-prefix parsing) with crates/formula/src/printer.rs
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
* CI: dev builds keep line tables only
* CI: one integration-test binary per crate
* CI: xtask runs tests with nextest, doctests apart, --keep-going on clippy and wasm
cargo xtask test runs the workspace tests (--lib --bins --tests) with cargo-nextest when it is installed, else cargo test, then the doctests. cargo xtask ci uses the same two steps.
* CI: docs name cargo xtask test
* CI: agent guides name the moved fuzz test
* CI: tests added on main join their crate's test binary
Found while testing crates/formula/src/printer.rs:134
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Found while testing crates/xlsx/src/fmla.rs:101 (with crates/formula/src/printer.rs:181)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
`=1+` reported "unexpected `)`", although no `)` was typed: entry closes
missing parentheses by appending up to eight `)`, and when every retry
failed it reported the last retry's error. It now reports the error of
the formula as typed ("unexpected end of formula").
`gridcraft_formula::parse_closing_parens` holds the convenience and takes
the parse function, so cell entry and the UI's Spanish formula path share
it instead of each having its own loop.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The left end of a flat operator chain is not nesting, so (((...1)))+1 is
accepted whenever (((...1))) is, in Spanish entry and display.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
along: =1+1+... with 4,096 operands (8,192 characters, exactly Excel's
limit, which Excel accepts and calculates) overflowed the stack when
entered, because the derived Clone of Expr recursed once per operand
down the chain's left spine. Saving hit the same in the XLSX writer.
- Expr: Clone, PartialEq, walk and map follow the left spine in a loop
and recurse only into right operands and real nesting.
- XLSX: _xlpm. prefixing and the implicit-intersection scan follow the
spine too. The scan's depth limit of 64 wrote any chain of more than
about 64 operands as a dynamic-array formula; such sums are plain
formulas again (ranges in the chain still get array evaluation).
- Evaluate Formula borrows sub-expressions instead of cloning each
operator's subtree (quadratic memory on a long chain).
- Spanish formula entry and display no longer refuse chains of more
than 256 operands; the left spine does not count as nesting.
Tests run on a 1 MiB stack (the Windows main thread) with Excel's
observed results: formula (clone/eq/walk/map/adjust at 4,096), calc
(401 and 4,096 operands, -, *, /, ^, &, %, comparisons, 1,500 refs),
xlsx (long chains written as scalar formulas), engine (cell.set, fill,
copy/paste, insert rows/columns, undo/redo, Evaluate Formula, XLSX save
and reopen), Spanish entry. Each fails without the fix.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sheet names starting with '.' or reading as R1C1 (R, C, RC, R1C1...) were saved
unquoted, so Excel could not open the file; PivotTable sources and ADDRESS left
cell-like names (A1) bare. The quoting rule, unquoting and sheet splitting now
live once in gridcraft_core::addr and replace the copies in the formula printer,
pivot, ADDRESS, chart, xlsx, Solver, Go To and the Name Box (which now reads ''
as ').
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Lower-casing then upper-casing made dotless ı equal to i (both become I),
which Unicode's default case folding keeps apart; LET(i,1,ı,2,i) could
return the ı binding. same_name now lower-cases and adds the folds lower
case misses (ß/ẞ -> ss, ſ -> s, ς -> σ). Tests cover ı/i and the Greek
final sigma.
- gridcraft_formula::same_name lower-cases then upper-cases each
character, so ß matches ẞ (to_uppercase alone maps ß to SS).
- Formula-bar input uses it too, so a binding such as ſi is not
translated to IF while the evaluator binds it.
- Tests: ISOMITTED with a case variant, ß/ẞ, and the Spanish input case.
A hostile file could abort any process that opens it: a cell formula of a
million `%` (`1%%%…`) or a 200,000-term sum parsed into trees deep enough to
overflow the stack when printed, saved, evaluated or dropped, even in release
builds. Postfix operators (`%`, invoking a result) built their nesting in a
loop that the 256-level limit did not see.
- `parse` refuses formula text longer than Excel's 8,192 characters
(`MAX_FORMULA_LEN`, counted in characters, the leading `=` excluded). This
also bounds the depth of the left-deep trees long operator chains build.
- Each postfix `%` and each call of a result counts towards the nesting limit.
- A file's formula that is refused keeps its text and cached value, as other
unparsable formulas do.
Regression tests: formula (small-stack thread: long `%`, invoke and operator
chains are errors; the limit counts characters, not bytes) and xlsx
(`hostile_formula_text_does_not_overflow_the_stack`: open, save and drop a
workbook with such formulas and a defined name on a 1 MiB stack). Both abort
with a stack overflow without the fix.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
- restore closing braces the line-level merges of side-by-side tests dropped
- new ChartData literals carry the palette field
- the filter-menu shortcut passes the row with the column
- #688's tab tests account for #851's All Sheets button
- Serbian margins for the ruler-margin labels
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
[@Col] is only Excel's display shorthand. Written verbatim into cell formulas and calculatedColumnFormula, it made Excel repair the file and drop the tables.
The parser stripped _xludf. like _xlfn., but the two mean opposite things:
_xludf. marks a name Excel must not treat as a built-in. Keeping it in the
call name makes the call an unknown function (#NAME?, as in Excel) and the
writer emits it unchanged, so _xludf.EOMONTH() no longer saves as an
EOMONTH() call Excel repairs away.