Skip to content

XLSX: formulas into linked workbooks calculate from the values saved with the link - #464

Open
neilcawse wants to merge 11 commits into
storytold:mainfrom
neilcawse:fix/external-link-values
Open

neilcawse wants to merge 11 commits into
storytold:mainfrom
neilcawse:fix/external-link-values

Conversation

@neilcawse

@neilcawse neilcawse commented Oct 12, 2026 •

Copy link
Copy Markdown
Contributor

A formula that reads another workbook (=[1]Prices!B2, =SUM('[2]P&L Prices'!B1:B9), =VLOOKUP(A7,Gas,4,FALSE) where the name Gas is [1]Bogey!$A$3:$B$33) recalculated to #REF! in GridCraft. #NAME? showed up in a few cases too. Excel calculates these from the copy of the linked cells it saves in the file (xl/externalLinks/externalLinkN.xml, sheetDataSet), as it does whenever the source workbook is closed. The pass-through from #330 kept those parts on save, but nothing read them.

This was the largest root cause in a new corpus of 4,224 real-world workbooks. Over the Excel-saved files it accounted for about 21,500 mismatching cells directly ([n] in the formula) and most of another ~11,000 counted under VLOOKUP/MONTH, through names that point into linked workbooks.

What changed

  • Model. Workbook::external_books: Arc<Vec<ExternalBook>>, where [n] in a formula is external_books[n-1]. Each book holds its sheets with their cached cells, a refresh_error flag per sheet, and the linked workbook's defined names (ExternalName). The cache is behind an Arc, like sheets and preserved parts, so the workbook clone each edit makes and the undo history share it instead of copying it (the largest corpus file has 157,198 cached link cells). It is only set when a file is read, and it isn't saved in GridCraft JSON, which doesn't carry the link parts either: a JSON copy has no links, so a typed [1] is refused there rather than saved into an .xlsx with no link for it.
  • Read (crates/xlsx/src/external.rs, new). Follows <externalReferences> in workbook.xml to each externalLink part and reads sheetNames, sheetDataSet (numbers, strings, booleans, errors) and definedNames. A missing or unreadable part leaves an empty book, and references into it stay #REF!.
  • Formulas. The lexer reads unquoted [1]Prices!A1, [1]!Name and the sheet range [1]Jan:Dec!A1 (MS-XLSX sheet-range = [workbook-index] sheet-name ":" sheet-name) as sheets in a linked workbook (quoted '[1]P&L'!A1 already lexed). The printer writes [1]Prices!A1 and [1]Jan:Dec!A1 unquoted when the sheet names allow it, as Excel does, so saved formulas keep their original text. A sheet range is quoted when either sheet name needs it ('[1]Jan:My Dec'!A1). References, including these sheet ranges, print as text that parses back to the same formula. So do sheet-qualified names: '[1]My Sheet'!Name prints quoted, and a linked workbook's own name [1]!Rate unquoted. [1]Data!#REF!, a reference Excel found deleted in the linked workbook, keeps its link when printed and saved (it was saved as a bare #REF!). It is its own expression node (Expr::LinkedRefError, the sheet it was in), which evaluates to #REF!, so nothing that looks up defined names mistakes it for one. A cell reference after a bare [1]!, where only a defined name belongs, prints back from a file as written ([1]!A1), never as '[1]'!A1, a sheet called [1].
  • Sheet rename and delete. A sheet range into a linked workbook ([1]Jan:Dec!A1) names that workbook's sheets at both ends. Renaming or deleting this workbook's Dec leaves it alone (adjust::rename_sheet, adjust::delete_sheet), while Dec!A1 is renamed or becomes #REF! as before.
  • Calculation (crates/calc/src/eval.rs).
    • A reference into a linked workbook stays a reference (an Area whose sheet number, from LINKED_SHEETS up, stands for the linked workbook and its sheet). Its cells are read from the cached values only when the reference is dereferenced (Evaluator::cell, Evaluator::block_values). So @ and implicit intersection take the cell on the formula's row or column (=@[1]Data!B:B in row 4 is [1]Data!B4), and ROWS, COLUMNS and INDEX see the size written: ROWS([1]Data!A:A) is 1048576 and INDEX([1]Data!A:A,10) past the last cached row is an empty cell. Reading values trims whole columns to the last cached cell, as references into this workbook trim to the used range. On a sheet whose last refresh failed it keeps one row and column past the cached cells, so the cells it doesn't hold still give #REF! (below).
    • INDIRECT into a linked workbook is #REF!: INDIRECT reads another workbook only while it is open.
    • OFFSET with a base in a linked workbook is #VALUE!. The corpus shows Excel errors there but not which error: the Enron West NatGas Prices 1031.xlsx cells PowerPrices!D9:D18 wrap OFFSET('[21]Curve Summary'!$D$6,2,0,25,1) in IF(ISERROR(...),backup,...), and Excel cached the backup in every cell even though the link cache holds every cell OFFSET would read. I picked #VALUE! because a closed workbook hands a function values rather than a reference, and a reference-only parameter given values answers #VALUE!. That is the answer the corpus pins for the *IF(S) range parameters above. The choice is unverified: no source or cached cell shows OFFSET's own error. ISREF, AREAS, ROWS/COLUMNS, INDEX and SHEET take linked references as before. FORMULATEXT gives #N/A and ISFORMULA FALSE, because no formulas are saved for a linked workbook.
    • A book or sheet the file doesn't hold is #REF!.
    • A sheet whose last refresh failed (refreshError="1") keeps the cells it had saved. Any other cell in it is #REF!, alone or inside a block (=SUM([2]Failed!A1:B1) is #REF!), and a whole column agrees with the block inside it (=SUM([2]Failed!A:A) is #REF!, like A1:A2). This matches the cached results in the Enron files that have such sheets.
    • [1]!Name and [1]Sheet!Name evaluate the linked workbook's name over its cached values. Names used inside that name are also looked up in the linked workbook, sheet scope first. A name the link doesn't define is #REF!. A name whose definition can't be calculated from the saved data (saved as #REF! or with no definition, as for DDE/OLE links, or holding a sheet range Jan:Dec!B2, a sheetless reference in a workbook-level name, a table reference, or sheets and names the link didn't save) never reads this workbook's cells: the cell keeps the value Excel saved for it (a spill or legacy array range whole), since linked values only change when the link is updated. With no saved value it is #REF!. The reader keeps names saved without refersTo for this.
    • [0] is the workbook itself: [0]!Name is its name, [0]Sheet1!A1 its Sheet1!A1. The parser reads [0]Sheet1!A1 as Sheet1!A1 and [0]!Name as Name, so a formula read from a file is a plain local reference: inserting rows, renaming or deleting the sheet adjust it like any other, and it saves as Sheet1!A1.
    • SUMIF/COUNTIF/SUMIFS/AVERAGEIF(S)/MAXIFS/MINIFS/COUNTBLANK range arguments refuse two kinds of argument: references into a linked workbook, which give #VALUE! (or the cell's own error), and a written error such as #REF! left by a deleted range, which gives that error. Excel answers this way when the source workbook is closed, and the corpus caches show it, for example SUMIF('[4]Statements'!$A$5:$A$1305,...) = #VALUE! and SUMIF(#REF!,">0") = #REF!. Arguments from anywhere else are left alone, so MAP/BYROW/REDUCE LAMBDA parameters still work there (=BYROW(A1:C3,LAMBDA(r,COUNTIF(r,">0")))).

Measured

gridcraft-cli check on each of the 4,224 files (one process per file, 60 s timeout), with release builds of main (84acf62) and this branch (8aa525d):

before after
mismatching formulas, all files 68,964 31,283 (-37,681, -55%)
files with mismatches 254 176
matched formulas 1,360,058 1,396,462
Excel-saved files only (3,170): mismatching formulas 55,176 20,828 (-62%)
Excel-saved files only: files with mismatches 162 93
round-trip differing files unchanged unchanged (no file differs more)

85 files have fewer mismatches. 2 files have more, 12 cells in total:

  • external-ref-wholecol-xlookup.xlsx (+11): =[1]Data!B:B and =XLOOKUP([1]Data!$J:$J,…) in single legacy cells now return the column and spill. Excel applies implicit intersection there, which is the implicit-intersection PR's area (XLSX: legacy formulas (no dynamic-array cm) open with Excel's implicit @ and calculate like Excel #417). Saving writes these cells back as the legacy formulas they were, as main does: a range into a linked workbook doesn't turn a legacy cell into a dynamic array. Test-merged with XLSX: legacy formulas (no dynamic-array cm) open with Excel's implicit @ and calculate like Excel #417, those 10 cells read with the implied @ like local ranges, match Excel's cached 10..14 and save as legacy <f> without cm; the file is left with one mismatch, B8, a dynamic-array XLOOKUP over whole linked columns that gives #SPILL! where Excel cached #VALUE! (main showed the cached value because it didn't calculate the formula). The intersection itself works on references into linked workbooks: with an explicit @, =@[1]Data!B:B in D2..D6 gives 10..14, the values Excel cached in those cells.
  • tdf85617.xlsx (+1): the cache says #N/A where the linked values give 4.5. Written by LibreOffice tests; probably a stale cache.

The number of formulas checked dropped from 1,429,022 to 1,427,745. Formulas skipped as volatile rose by 6,376 and no-oracle formulas fell by 5,099. My guess, not traced: formulas that used to stop at #REF! now evaluate far enough to reach a volatile call.

Earlier rounds, measured on b83d2e9 and its follow-ups: after the first review fixes (references into linked workbooks kept as references, sheet ranges), on the 276 corpus files that have external links: 20,030 mismatching formulas before, 20,029 after. The one change is CORE!P18 =A2:[2]MISSING!A2 in formulas/test__test_files__test.xlsx, now #VALUE! as cached instead of #REF!. No formula newly mismatches among the ones check compares, and the round-trip result is unchanged (6 of 276 files differ, as before). check skips OFFSET formulas as volatile, so it couldn't see that 2133e84 let OFFSET read linked values. The second review fixes that: with a release build, the West NatGas cells PowerPrices!D9:D15 and D18 now evaluate to Excel's cached backups (39, 44.25, 44, 39, 39, 37, 38, 59.8). 2133e84 gave 37, 37.5, 36.5, 21.44, 35.25, 34, 35, 57. The 11 report files in dist/web/files give the same check output as at 2133e84 (2 mismatching formulas out of 261,528, 0 round-trip differences).

Speed, gridcraft-cli check, release builds of b83d2e9 and this branch on the same machine: 3,000 VLOOKUP(An,[1]Data!A:B,2,FALSE) over a 20,000-row linked table took 5.6 s before and 0.7 s after (the same lookups over a local Data!A:B take 1.6 s on both). mike_grigsby__27684__PG&E.xlsx went from 0.80 s to 0.27 s. mike_grigsby__27922__HS_WESTminusPA.xlsx takes 18.7 s on both. Blocks in a linked sheet are now built from its saved cells in one pass over the rows they cover (ExternalSheet::cells_in), and the extent of a linked sheet is remembered for the recalculation pass like the used range of a local sheet (used_range_of, PassHost::used_range).

Verification

  • references_into_linked_workbooks_read_the_saved_values (crates/calc/src/tests.rs, new) covers cells, blocks, names over linked ranges, missing books and sheets, refresh-error sheets (single cells and blocks), [0]!, linked names (nested names, sheet ranges, sheetless references), SUMIF/COUNTIF on linked values and on #REF!, and printing. It fails on 95c8776, where every reference is #REF!.
  • if_family_ranges_inside_lambdas_stay_references (new): BYROW+COUNTIF and MAP+SUMIF over a range of this workbook give 2, 2 and 1, 0. It guards against the reference check rejecting LAMBDA parameters.
  • unmodelled_parts_survive_a_round_trip (crates/xlsx/src/tests/passthrough_tests.rs) now also asserts the link's cached value is read. The formulas [1]Sheet1!$A$1 and SUM([1]Sheet1!A1:A3)*2 are still written exactly as read.
  • references_into_linked_workbooks_keep_their_position_and_size (crates/calc/src/tests.rs, new): =@[1]Data!B:B in D2..D6 gives 10..14, =@[1]Data!B3:B6 gives 12 in row 4 and #VALUE! in row 9, =@[1]Data!A1:B1 in column B gives Price; ROWS([1]Data!A:A) = 1048576, ROWS([1]Data!A1:A100000) = 100000, COLUMNS([1]Data!1:1) = 16384, INDEX([1]Data!A:A,10) is an empty cell; SUM and VLOOKUP over whole linked columns; ISREF([1]Data!B2) is TRUE; INDIRECT("[1]Data!B2") is #REF!. It fails on b83d2e9 (@ gave the header, Price).
  • linked_workbook_sheet_ranges_round_trip (crates/formula/src/tests.rs, new): SUM([1]Jan:Dec!A1), SUM('[1]Jan:Dec'!A1), SUM('[1]Jan:My Dec'!A1), SUM('Jan:[1]Dec'!A1) and [2]Prices!B2 parse to the expected sheets, and what the printer writes parses back to the same formula. It fails on b83d2e9 (the unquoted form didn't parse, and the quoted form printed unquoted).
  • offset_refuses_a_base_in_a_linked_workbook (crates/calc/src/tests.rs, new): OFFSET([1]Curve!$D$6,2,0,3,1) is #VALUE! though the link cache holds D8:D10, and the West NatGas IF(ISERROR(AVERAGE(OFFSET(...))),[1]Backup!$D$38,0) gives the backup, 39. OFFSET on a local base still works. It fails on 2133e84.
  • renaming_or_deleting_a_sheet_leaves_linked_spans_alone (crates/formula/src/tests.rs, new): renaming local Dec to December turns SUM([1]Jan:Dec!A1)+Dec!A1 (unquoted and quoted) into SUM([1]Jan:Dec!A1)+December!A1, and deleting it gives SUM([1]Jan:Dec!A1)+#REF!. The rename fails on 2133e84 ([1]Jan:December).
  • names_of_linked_workbooks_print_as_excel_writes_them (crates/formula/src/tests.rs, new): [1]!Rate and SUM([1]!Rate,1) print unquoted, as Excel writes them; [1]Prices!Rate and 'My Sheet'!Total are unchanged. After the rebase onto 84acf62, main's quoting of sheet-qualified names printed '[1]'!Rate (in the formula bar and in saved files, where it names a sheet called [1]); the test fails on 80a583c. Same printer change as in Workbook Links: update linked workbooks from their files, change source, break links, startup prompt (builds on #464 and #829) #1200, so that PR rebases cleanly.
  • book_only_references_print_back_without_quotes, deleted_references_into_linked_workbooks_keep_the_link and workbook_zero_is_this_workbook (crates/formula/src/tests.rs, new): [1]!A1 and SUM([1]!A1:B2) print as written, [1]Data!#REF!+1 and SUM([1]Jan:Dec!#REF!) keep their link while Data!#REF! stays #REF!, and [0]Data!B2, '[0]My Data'!B2, [0]Jan:Dec, [0]!Rate parse to the local forms and follow a sheet rename. They fail on 784a585^ ('[1]'!A1, #REF!+1, [0]Data).
  • formulas_into_linked_workbooks_save_back_as_written (crates/xlsx/src/tests/roundtrip.rs, new): legacy <f>[1]Data!$B:$B</f> and [1]Data!$B$2:$B$4*2 save as legacy cells (no cm, no t="array"), [1]Data!#REF!+1, [1]!A1 and SUM('[1]My Data'!B2:B4) save as read, and [0]Data!B2 saves as Data!B2. It fails on 784a585^.
  • references_into_linked_workbooks_read_the_saved_values now also checks SUM([2]Failed!A:A) and SUM([2]Failed!A:Z) are #REF! like A1:A2 (5 on 784a585^), and that [2]Rates!#REF! is #REF!.
  • linked_names_that_cannot_be_calculated_keep_their_saved_value (crates/calc/src/tests.rs, new): [1]!Broken (#REF!), [1]!Blank (no definition) and a name over a sheet range keep their saved values through recalculations, while a name the link doesn't define and a formula with no saved value are #REF!. It fails on 9c68486. names_saved_without_a_definition_are_kept (crates/xlsx/src/external.rs, new) reads <definedName name="HelloWorld"/> as a name with an empty definition. Over the corpus this fixes =[4]!Formula_Name in EPPlus ExternalReferences/dde.xlsx and =[2]!HelloWorld in empty_ext_defined_name.xlsx.
  • Second review round: cargo fmt, cargo clippy --all-targets -- -D warnings and cargo test for gridcraft-formula, gridcraft-model, gridcraft-calc, gridcraft-engine and gridcraft-xlsx. A test merge with XLSX: legacy formulas (no dynamic-array cm) open with Excel's implicit @ and calculate like Excel #417 (8f9d588) conflicts only in crates/xlsx/src/array_formula.rs and the file-format-parity header and revision rows. Resolve by taking XLSX: legacy formulas (no dynamic-array cm) open with Excel's implicit @ and calculate like Excel #417's side of array_formula.rs (its legacy analysis replaces the scan this PR changed, and it treats a linked range like a local one) and keeping both revision rows. Merged that way, legacy <f>[1]Data!$B:$B</f> in row 4 reads as @[1]Data!$B:$B and gives 12, [1]Data!$B$2:$B$4*2 in row 5 gives #VALUE!, and both save back as legacy <f> without cm; the merged formula, calc, engine and xlsx tests pass.
  • cargo xtask ci: all 8 steps passed on ca519cf (rebased on 84acf62).
  • cargo xtask ci: all 8 steps passed on b83d2e9. For the review fixes: cargo fmt --all, cargo clippy --all-targets -- -D warnings and cargo test for gridcraft-formula, gridcraft-model, gridcraft-calc, gridcraft-engine and gridcraft-xlsx, and cargo check --workspace --all-targets.

Typed [n] that isn't a link

Reported by @iluebbe (Excel 365): the lexer change let a typed =[2]Sheet1!A1 in a workbook with one link (or =[1]Sheet1!A1 in one with none) enter and be saved as typed, and Excel 365 then can't open the file at all, even with repair. Before this PR the lexer rejected [n] on entry. Now formula entry checks it: check_linked_books in crates/engine/src/cmd/edit.rs, called from input_to_cell, the one place user text becomes a cell formula (cell.set and so the formula bar, range.setValues, range.fill, array entry, Text to Columns, Replace, paste of formula text, What-If), and from formulas.defineName. An index that isn't one of Workbook::external_books refuses the formula with "There's a problem with this formula: [2] isn't a linked workbook." and leaves the cell unchanged, as an unparsable formula did before (paste and Replace keep such text as text, as before). Formulas read from files aren't checked, so they still load.

=[0]Sheet1!A1 was #REF! on this branch; Excel 365 reads [0] as this workbook. It is now entered as the plain local reference (=Sheet1!A1, [0]!Rates as Rates), and a [0]Sheet1 read from a file is read as the local reference.

Only a defined name can follow a bare [n]! ([1]!Rates). A cell reference there (=[1]!A1, =[1]!$B$2, =SUM([1]!A1:B2), =[0]!A1) would be to a sheet called [1], and is refused on every route above, with CF and DV formulas too. Main refused these in the lexer.

Review follow-ups, same commit series: either end of a sheet range is checked (='Sheet1:[5]Dec'!A1 is refused), and so are bracketed prefixes that aren't an index ('[-1]Sheet1', '[Book2.xlsx]Sheet1': a sheet name can't contain [). A [0] defined name is stored as the local reference, so it follows a sheet rename. Conditional-format and data-validation formulas, data-bar formula thresholds included, refuse references to other workbooks, as Excel's dialogs do. A formula pasted into another workbook keeps a [n] reference only where the target holds the same link at that index (same linked sheets, saved values and names; a View ▸ New Window of the workbook shares them, so pasting between its windows keeps [1]S!A1 live); any other [n] becomes #REF!, since the target doesn't have that link (Excel would copy the link over; GridCraft can't create links yet).

Tests: typed_references_to_workbooks_that_are_not_linked_are_refused_on_entry (crates/engine/src/tests.rs, new) refuses [1]… in a workbook with no links (cells and a defined name) and [2]… with one link, keeps the cell, accepts [1] with one link, enters [0] references and names as local ones with local values, and covers the follow-ups above (sheet-range ends, non-index prefixes, CF/DV and data-bar thresholds, [0] names across a rename, paste into a New Window and into other workbooks), refuses [n]! followed by a cell reference, and checks a JSON round trip comes back with no links. references_into_linked_workbooks_read_the_saved_values now also reads [0]Sheet1!A1 and [0]Sheet1:Sheet1!A1 from formula text. Both fail without the fix.

Not done

  • Updating links: GridCraft doesn't open the source files. The cached values are what references read, as in Excel's "Don't update" mode. Cached values aren't rewritten on save. The original externalLink parts are passed through unchanged.
  • .xls (BIFF8) external references (SUPBOOK/EXTERNNAME/CRN records) are still kept as values.
  • DDE/OLE links ([4]!Formula_Name, whose name the link saved as #REF!) aren't followed: the cell keeps the value Excel saved for it.
  • A 3-D reference into a linked workbook (SUM([1]Jan:Dec!A1)) now reads and saves correctly but still evaluates to #REF!.

@xmaks82

xmaks82 commented Oct 12, 2026

Copy link
Copy Markdown

Tested this branch (b83d2e9) with gridcraft-cli check on a private corpus of 253 real-world workbooks (Russian-locale business files, 169,296 formula cells).

One workbook pulls colour codes from a linked workbook, e.g. =VLOOKUP(C2,'[1]красный '!$C:$D,2,0) (sheet names with Cyrillic and a trailing space). On main every such lookup returned #REF!: 1,980 cached-value mismatches including dependents. With this PR all of them match the values Excel saved with the link (1,980 → 0), and none of the other 252 workbooks changed.

The files can't be shared, but I'm happy to rerun the corpus on later revisions.

@neilcawse

Copy link
Copy Markdown
Contributor Author

Thanks for running this. Linked sheet names with Cyrillic and a trailing space were exactly the case I couldn't test locally, so 1,980 → 0 with no other workbook changing is really useful.

@xmaks82

xmaks82 commented Oct 13, 2026

Copy link
Copy Markdown

Rechecked the rebased head 44d0e30 on our 256 workbooks: 2,860 → 880 (the same 1,980 linked-workbook #REF!s gone), no new mismatches, no round-trip differences.

@iluebbe

iluebbe commented Oct 13, 2026

Copy link
Copy Markdown

Tested 44d0e30 with Excel 365 on Windows. A workbook made in Excel that links to Source.xlsx ([1]Sheet1!A1, SUM([1]Sheet1!A1:A3), '[1]P&L Data'!A1, [1]!Rate, a VLOOKUP and a name over the link): all 10 linked formulas match the values Excel saved (main: 8 of 10), and the round trip is equal.

One problem: a typed [n] that isn't one of the workbook's links is accepted and saved as typed, and Excel 365 can't open the file afterwards. Workbooks.Open fails, also with CorruptLoad:=xlRepairFile.

typed in GridCraft saved
=[2]Sheet1!A1 a workbook with one link #REF! <f>[2]Sheet1!A1</f>, one externalLink part
=[1]Sheet1!A1 a new workbook (no links) #REF! <f>[1]Sheet1!A1</f>, no externalLink part

Main refuses both ("There's a problem with this formula: unexpected Sheet("Sheet1", None)"), so this is new with this PR. The formula bar shows linked cells as =[1]Sheet1!A1, so copying that text into another workbook is an easy way to get here. A command (cell.set) is another.

Suggested fix: on entry, refuse a [n] that doesn't name one of the workbook's links, as main does today. Test: cell.set with =[2]Sheet1!A1 in a workbook with one link, and with =[1]Sheet1!A1 in one with none, returns an error and leaves the cell unchanged.

Smaller: =[0]Sheet1!A1 is #REF! here. Excel 365 opens the saved file and reads it as this workbook's Sheet1!A1 (it shows 1).

#829 builds on this branch and does the same at 435b6a9. Its typed_book leaves [digits] alone, so the fix belongs here.

neilcawse and others added 4 commits October 13, 2026 13:12
…with the link

A formula reading another workbook ([1]Prices!B2, '[2]P&L'!A1:B9, a name over
such a range) recalculated to #REF!: the externalLink parts were passed through
on save but never read. Excel calculates these from the cached copy of the
linked cells it saves in the file, as it does whenever the source is closed.
On a 4,224-file real-world corpus this was the largest root cause of
calculation mismatches.

The reader now loads each link's sheet names, cached cells, refresh-error
flags and defined names into Workbook::external_books; the lexer reads
unquoted [1]Sheet!A1 and [1]!Name; references, linked names and [0]!Name
evaluate from that data; the *IF(S) range arguments refuse
linked-workbook values and written errors, as Excel does with a closed
source; and linked sheets print unquoted when the sheet
name allows, so saved formulas keep their text.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… prints back

References into a linked workbook became value arrays when evaluated, so `@`
and implicit intersection took the top-left value (`=@[1]Data!B:B` in row 4
gave the header), ROWS/COLUMNS/INDEX saw the block trimmed to the last cached
cell (`ROWS([1]Data!A:A)` = 3), and every lookup rebuilt the whole block.
They are now Areas whose sheet number (from LINKED_SHEETS up) stands for the
linked workbook and sheet; cells are read from the saved values only when the
reference is dereferenced (Evaluator::cell, Evaluator::block_values), and a
linked sheet's extent is remembered per pass like a local used range.

The printer dropped the quotes from '[1]Jan:Dec'!A1 and the lexer couldn't
read [1]Jan:Dec!A1. The lexer now accepts the unquoted MS-XLSX sheet-range
form, and the printer quotes a range when either sheet name needs it.
…]Jan:Dec

OFFSET took a reference into a linked workbook as its base and read the
saved link values. Excel errors there while the source is closed: the Enron
West NatGas Prices cells wrap OFFSET('[21]Curve Summary'!...) in ISERROR and
cached the backup value. OFFSET now answers #VALUE! for a linked base, the
answer of the other reference-only parameters given a closed workbook's values.

Renaming a local sheet rewrote the second end of [1]Jan:Dec!A1, which names a
sheet of the linked workbook; rename_sheet and delete_sheet now skip spans
whose first end is in a linked workbook.

Workbook::external_books is behind an Arc, like sheets and preserved parts,
so each edit's workbook clone and the undo history share the link cache.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@neilcawse
neilcawse force-pushed the fix/external-link-values branch from 44d0e30 to 36befbd Compare October 13, 2026 18:46
…ixes; [0] names stored local; CF/DV refuse other workbooks; paste into another workbook drops [n]
@neilcawse

Copy link
Copy Markdown
Contributor Author

Thanks, that was a real regression: I could only check Excel's side through your report, and a file Excel refuses to open is the worst outcome. Fixed in 36befbd and two follow-ups, the latest being 80a583c (rebased on main 84acf62):

  • Typed [n] that isn't a link is refused on entry, as main did: "There's a problem with this formula: [n] isn't a linked workbook.", and the cell stays unchanged. This covers every route that turns typed text into a formula (cell entry, formula bar, fill, array entry, paste of formula text, Replace, Text to Columns, What-If), defined names, both ends of a sheet range ('Sheet1:[5]Dec'!A1), bracketed prefixes that aren't an index ('[Book2.xlsx]Sheet1'!A1), and conditional-format and data-validation formulas. Formulas read from files are never refused.
  • Pasting a linked cell into another workbook keeps an [n] only where the target has that same link; anything else becomes #REF!. Excel carries the link over instead, but GridCraft can't create links yet, and keeping the [n] would save the same unopenable file. Pasting between New Windows of one workbook keeps the links.
  • [0] means this workbook, as you saw in Excel: =[0]Sheet1!A1 is stored as =Sheet1!A1 (and [0]!Name as Name), and a [0] read from a file calculates against the local sheet.

Tests: typed_references_to_workbooks_that_are_not_linked_are_refused_on_entry (engine) covers your two cases, [0], names and the paste cases; references_into_linked_workbooks_read_the_saved_values (calc) gained the [0] sheet and range cases. Both fail without the fix. cargo xtask ci passes.

Since #829 builds on this branch, it gets the check from here.

Main now quotes the sheet part of a sheet-qualified name, so after the rebase a name of a
linked workbook printed as '[1]'!Rate, in the formula bar and in saved files, where it names
a sheet called [1] in this workbook. Excel writes [1]!Rate. Same change as in storytold#1200.

Co-authored-by: Herr-Enklo <168219178+Herr-Enklo@users.noreply.github.com>
Local contributor added 4 commits October 14, 2026 09:14
…rs survive whole columns, legacy cells stay legacy

Typed =[1]!A1, =[1]!$B$2, =SUM([1]!A1:B2) and =[0]!A1 were accepted and saved as a
reference to a sheet called [1] or [0] ('[1]'!A1), which no sheet can be. Only a defined
name can follow a bare [n]!, so a cell reference there is now refused on entry in cells,
defined names, conditional formats and data validation. Read from a file, the same text
prints back as written ([1]!A1), never quoted.

[0] is this workbook. The parser now reads [0]Sheet1!A1 as Sheet1!A1 and [0]!Rate as Rate,
so formulas read from files are local references that follow inserted rows and sheet
renames and deletions; before, adjusting left [0]Data!B2 alone while evaluation read Data.
The special cases for [0] in entry, sheet lookup and name evaluation are gone.

Sheets whose last link refresh failed are #REF! outside their saved cells, but trimming a
large reference to those cells dropped the errors: [1]Data!A:A summed to 5 where
[1]Data!A1:A2 was #REF!. Trimming now keeps one row and column past the saved cells on such
sheets, so any reference reaching an uncached cell is #REF!.

The link cache was saved in GridCraft JSON while the link parts were not, so a JSON round
trip kept the values and a typed [1] was then saved into a file with no externalLink part.
The cache is no longer serialised; reopened, the workbook has no links and [1] is refused.

Legacy cells holding linked ranges (<f>[1]Data!$B:$B</f>) were promoted to dynamic arrays
on save. Linked ranges no longer make a formula dynamic, so they save as legacy formulas as
on main. [1]Data!#REF! was read as a plain #REF! and lost its link on save; it is now kept
(as a name that evaluates to #REF!) and printed with its link.

Data-bar formula thresholds now get the same refusal of other workbooks as the rules'
formulas.
…ssion, not a name

[1]Data!#REF! was carried as a defined name called "[1]Data!#REF!" so that it
printed back with its link. Anything that looks up or walks defined names
(name evaluation, the entry checks, name lists) could take it for one. It is
now Expr::LinkedRefError holding the sheet it was in: it evaluates to #REF!,
prints and saves as [1]Data!#REF! (or [1]Jan:Dec!#REF!), and the entry
checks read its sheet like a reference's, so [1]!#REF! and an unlinked index
are still refused.
…ll's saved value

A cell such as =[4]!Formula_Name, where the link saved the name as
refersTo="#REF!", or =[2]!HelloWorld, where it saved no definition at all,
now showed #REF! instead of the value Excel saved for it. Linked values only
change when the link is updated, so Excel keeps showing that value. Names
saved without refersTo were also dropped on read, which made them look
undefined.

The reader now keeps such names with an empty definition. When a formula
uses a linked name whose definition can't be read from the link's saved
data (no definition, #REF!, a sheet range, a sheetless reference, a table,
or sheets and names the link didn't save), the evaluator tells the host, and
the recalculation keeps the cell's saved value (a spill or legacy array
range whole). A name the link doesn't define, a refresh-error sheet's
unsaved cells, and a formula with no saved value are still #REF!.
…es of uncalculable names

The revision row now says that a linked name the saved data can't calculate
(saved as #REF!, without a definition, or a DDE/OLE link) keeps the cell's
saved value, and gives the corpus numbers measured against main 84acf62 with
this branch at 8aa525d.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants