Parse 3-D references (sheet ranges) - #51
Open
gthb wants to merge 80 commits into
Open
Conversation
"Jan:Dec!A1" is a 3-D reference to sheets "Jan" through "Dec", but JAN and DEC are also valid column letters, so the A1 lexer claimed "Jan:Dec" as a column beam and left a bare "!" behind — which parse() then rejected as an unknown operator. Same for "A:C!A1", and for "C1:C5!R1C1" in R1C1 mode where C1 and C5 are valid column parts. Excel forbids ":" in sheet names, so a colon ahead of the "!" can only separate two sheet names. Reject "!" as a beam terminator (canEndBeam, mirroring the canEndPartialRange rule for ternary ranges), and in R1C1 mode also bail out of the range lexer entirely when a complete "X:Y" pair is followed by "!", so the context lexer sees the whole prefix. The context lexer does not yet accept the colon, so for now these inputs lex as a name, a ":" operator and a reference. That is what "fool:bard!A1:B2" already did, and no longer throws.
"Sheet1:Sheet2!A1" refers to cell A1 on every sheet from Sheet1 to Sheet2. The quoted spelling already worked, because the quoted-context lexer accepts any character between the quotes; the unquoted one stopped at the colon, so "fool:bard!A1:B2" came out as a name, a ":" operator and a reference, and parseA1Ref rejected it. Admit a single colon into an unquoted context, in both the standalone context lexer and the combined name/function/context lexer. It may not lead the context and must have a name on either side of it, and only one is allowed, since Excel forbids ":" in sheet names and a sheet range has exactly two endpoints. A colon inside brackets stays part of the workbook name, as before. The colon is the first character that can end the name run while the context run carries on, so the name/function/context lexer now remembers where the name ended and falls back to it when the context turns out not to be one: "foo:B2" still lexes as a name, a ":" operator and a reference. The sheet range lands in the sheet slot as a single compound name — context: [ 'Sheet1:Sheet2' ], or sheetName: 'Sheet1:Sheet2' in xlsx mode — which is the shape the quoted spelling already produced. Serialization needs no change: ":" is a banned character, so the prefix is quoted on output. Sheet order is left alone. Whether "Sheet2:Sheet1" should read "Sheet1:Sheet2" depends on the order the sheets appear in the workbook, which is not knowable from a formula string.
Cover both the context and the xlsx variants of tokenize, parseA1Ref,
stringifyA1Ref, parseR1C1Ref, stringifyR1C1Ref, parseStructRef,
stringifyStructRef, fixFormulaRanges and the A1/R1C1 translations, plus
addTokenMeta grouping.
Includes regression guards for the constructs a sheet range is easily
confused with: cross-sheet ranges ("B!F2:B!F20", "Sheet1!A1:Sheet2!B2")
stay two references, "foo:B2" stays a range operator between a name and
a cell, and "Jan:Dec" without a "!" stays a column beam.
Excel writes a 3-D prefix bare when neither of its sheet names needs
quoting — its own documented example is =SUM(Sales:Marketing!B3), and
that is what both the formula bar and the stored formula show. Quoting
it regardless is legal but loses fidelity: a consumer that reads
SUM(Jan:Dec!A1) and writes it back would emit SUM('Jan:Dec'!A1).
The colon separates two names rather than belonging to either, so
needQuotesSheet splits the sheet scope on it and applies the existing
rules to each half, quoting the whole prefix as one unit if either half
calls for it. That is the same whole-prefix convention stringifyPrefix
already uses for '[My File.xlsx]Sheet1'!A1.
Jan:Dec!A1 bare
Sales:Marketing!B3 bare
[Book.xlsx]Sheet1:Sheet2!A1 bare
'Sheet1:Sheet 2'!A1 one half has a space
'1:5'!A1 digit-leading halves
'A1:B2'!A1 range-like halves
'A:C'!A1 "C" alone reads as an R1C1 column
'[1]Sheet1:Sheet2'!A1 the workbook name forces it
Only the sheet slot is split. A colon can reach a path scope on its own
as a Windows drive letter without dividing it into two names, and a
workbook name is not a sheet name either, so both keep using needQuotes.
Excel resolves "=SUM(A1:B2!C3)" as cell A1 joined to 'B2'!C3 — it stores
the formula that way and the result is #VALUE!. A column-shaped left
side does not lose out like that: "=SUM(A:C!A1)" and "=SUM(Jan:Mar!A1)"
are both sheet ranges. To reference sheets named A1 and B2 the prefix
must be quoted: "=SUM('A1:B2'!C3)".
The A1 lexer claimed "A1:B2" whole and left a bare "!" behind, which
parse() rejected as an unknown operator. Fold the "!" rejection into
canEndRange, which no range may end at, so the range ends at "A1" and
"B2!" is left to prefix the reference on the right. The column-shaped
spellings are unaffected: they end at the column, which is not a range
by itself, so the whole sheet range falls to the context lexer.
This subsumes canEndBeam, added a few commits ago for the same reason,
and the standalone "!" check in the single-cell branch.
Also pin the one spelling Excel refuses outright: "$" on an unquoted
sheet name ("=SUM($Jan:$Mar!A1)" is rejected on entry, and a file
holding one does not open at all). fx already declines to read it as a
context, "$" not being a context character; keep it that way. "$" is
legal in a sheet name, so "'$Jan:$Mar'!A1" is a valid sheet range.
Excel accepts "=SUM([Book.xlsx]S1:S3!A1)" on entry but normalizes it to
"=SUM('[Book.xlsx]S1:S3'!A1)", adding quotes that neither sheet name
calls for. It goes the other way for a single sheet, where needless
quotes are instead removed: "'[Book.xlsx]Sheet1'!A1" becomes
"[Book.xlsx]Sheet1!A1".
So the per-endpoint rule governs only an unqualified sheet range
("Jan:Dec!A1" bare, "'Sheet 1:Sheet 2'!A1" quoted). Once a workbook or
path scope is present, quote the whole prefix regardless.
In a saved xlsx the prefix holds the external-link index rather than a
path, "'[1]S1:S3'!A1", which the same rule covers.
"=foo:'bar'!A1" lexed as the beam "foo:" followed by a separate reference "'bar'!A1", and normalized to "=A:FOObar!A1" — silent corruption of the kind this branch exists to remove. "='foo':'bar'!A1" fared no better, and "='foo bar':'baz'!A1" shredded into unknown tokens. The beam guard added earlier only recognized an unquoted sheet prefix. A quote opens one just as much as a name followed by "!", so canEndRange now rejects it too; the open-ended beam was the tell, the lexer having bailed at the quote with nothing past the colon. That alone would leave these as a name, a ":" operator and a reference, so the context lexers now read the far end of a sheet range whether it is quoted or bare, and the near end likewise. Unquoting moves from the whole prefix to each quoted run within it, which leaves a wholly quoted or wholly bare prefix exactly as it was. foo:'bar'!A1 → foo:bar!A1 'foo':bar!A1 → foo:bar!A1 'foo':'bar'!A1 → foo:bar!A1 'foo':'bar baz'!A1 → 'foo:bar baz'!A1 'foo bar':'baz'!A1 → 'foo bar:baz'!A1 '[Book.xlsx]foo':'bar'!A1 → '[Book.xlsx]foo:bar'!A1 Excel normalizes all of these away on entry, so they never occur in a file it wrote. This is input tolerance for hand-written and third-party formulas, not round-trip fidelity, and the output settles on the spelling Excel would have written. A trailing colon with nothing after it stays an open-ended beam, so "SUM(foo:)" is untouched.
A 3-D reference puts both sheet names in the sheet slot, "Jan:Dec" where an ordinary reference has "Sheet1", and nothing but the colon inside the name tells them apart. Code that resolves a sheet name against a workbook and is handed the slot whole matches no sheet at all — and since a lookup finding nothing usually reads as "no such sheet" rather than as an error, it does so silently. The shape is the right one and changing it is a major-version matter, so give the hazard a supported way out instead: splitSheetRange returns the two sheet names, or undefined for an ordinary single-sheet scope. It was already there, private, doing this job for the quoting rules. Moved out of stringifyPrefix.ts, whose subject it no longer is, into its own module, and documented — including that it takes the sheet scope alone, a path scope being free to hold a colon of its own in a Windows drive letter. The parseA1Ref docs point at it, that being where a consumer lands.
"Only the former is a single reference" pointed ambiguously at one of two things named in the previous sentence, and was wrong either way: a plain A1:B2 is one REF_RANGE token and one ReferenceIdentifier too, so being a single reference is not what separates a 3-D reference from a cross-sheet range. isReferenceNode is strictly node.type === REFERENCE and agrees. Name both sides and give the observable difference: Jan:Dec!A1 is one token parsing to one ReferenceIdentifier, which parseA1Ref resolves; Sheet1!A1:Sheet2!B2 is three tokens parsing to a BinaryExpression over two ReferenceIdentifier nodes, which parseA1Ref returns undefined for. Verified against the code. Also warn, in the section a reader of this would be in, that the sheet slot may hold two names and that resolving one against a workbook needs splitSheetRange first — with the failure mode stated, since it is a silent one. Say outright that Excel normalizes a sheet range on entry and how: it orders the two ends, collapses a degenerate range, and corrects the case of each end to the sheet's own. fx does none of the three because each needs the workbook's list of sheets, which formula text does not carry. Saying so turns an apparent gap into a stated boundary, and settles that the un-normalized spellings are valid input. Drive-by: "endpoint" throughout the section is now "end", the ends of a sheet range not being the corners of a range; and a duplicated note about colons in path scopes is gone.
What Fx lacks is the workbook's sheet names in order, not its sheets: ordering the two ends, spotting a degenerate range, and correcting each end's case are all name comparisons.
The dash there had been written as a literal \u2014 escape rather than the character it stood for.
The note claimed a colon inside a context can only separate a sheet range, which is not true of a context generally: a quoted one may hold a Windows path, whose drive letter carries a colon of its own. It is true of this table, which lexes unquoted contexts, where a path cannot appear at all.
`parseA1Ref('A1:B2!C3')` read the prefix as a sheet range and answered
`'A1:B2'!C3`, a reference into sheets named `A1` and `B2`. Excel, the
tokenizer and the parser all read the same text as cell `A1` joined to
`'B2'!C3`, so the answer silently changed the formula's meaning; before
sheet ranges were parsed at all, it was `undefined`.
The formula lexers settle this by running `lexRange` ahead of the context
lexers at each position, so a cell-shaped run wins before a context can
claim it. The reference lexer set runs `lexContextUnquoted` first, and it
had no equivalent test, so `lexContextUnquoted` now asks `lexRange`
whether the run up to the colon is a whole range and declines the colon
if it is.
The check sits behind the "only one colon, not leading" test, and the
formula lexers reach `lexContextUnquoted` only for a context starting
with a digit or a dot, which can never be cell-shaped, so tokenizing is
unaffected.
A quote never ends a beam: "canEndRange" rejects it outright, so a beam that runs into one is not lexed as a beam at all, whether or not a sheet prefix follows. What the cases guard is that the beams and quoted prefixes that have nothing to do with sheet ranges still lex as before.
That the range of a 3-D reference is normalized but its sheet range never is, is behaviour a caller needs to know, and a free-floating file comment never reaches the generated docs. Move the statement of it into the fixTokenRanges docstring, beside the enumeration of what does get normalized, and leave the file comment holding the three Excel normalizations it stands in for.
`parseA1Ref` takes only `allowNamed` and `allowTernary`, so both snippets
in Prefixes.md passing `{ xlsx: true }` were teaching an option that does
not exist: a reader copying either gets the context variant back, with
the option silently ignored. The xlsx counterparts live behind the
`@borgar/fx/xlsx` entry point, so say that once and drop the option.
The `[1]!A1` snippet predates 3-D references and was already wrong; it is
fixed alongside the new one rather than left as the odd one out.
"canEndRange" refuses "'" whether or not a sheet prefix follows, so an unfinished "=A1'" now lexes as one UNKNOWN token where it used to be a range and a stray quote. That is a deliberate choice rather than a consequence nobody looked at, but nothing said so or held it in place, so a later reader would have taken it for an overreach and narrowed it. Note the reasoning where the rejection lives, and cover both the widened spellings and "=A1:'Sheet 2'!B2", the half-typed formula that would actually suffer from a narrower rule and does not, since it reaches its range through the colon.
Returning early for "foo:'bar'!A1" leaves the loop before the mask bug can swallow the string, so a sheet range whose far end is quoted now lexes even when its near end starts above U+00B4, while the same range unquoted and even a plain reference off such a sheet still do not. An unexplained improvement in one spelling out of three invites reading the other two as the intended behaviour, so say where it comes from and that the mask is fixed elsewhere.
The guard that hands "C1:C5!R1C1" to the context lexer fired on any second R1C1 part, so "R1C1:R2C2!R3C3" and "RC:R2C2!R3C3" became sheet ranges as well. Both have a complete cell on the left, which the A1 lexer gives to the range operator, and which master gave to it here too. Exempt a left side that has both a row and a column part, so the two notations read such a pair the same way and only the quoted spelling, "'R1C1:R2C2'!R3C3", is a sheet range.
The colon of a sheet range had to be preceded by something, but not by a sheet name, so "[Book.xlsx]:Sheet2!A1" read as a reference to a sheet called ":Sheet2" — a name Excel cannot produce, and one master rejected outright. Measure the near end from the workbook brackets rather than from the start of the whole prefix, which is where the sheet name begins. The tests for this rule went with what they name only when a beam or an operator settled the spelling first; they now also cover names that are not column letters and so reach the context lexer.
Nothing failed when advQuotedSheetName stopped refusing brackets, and the spelling it guards is one Excel writes: when the far end of a sheet range names no sheet, Excel manufactures an external link for that name and stores "Jan:'[1]Nope'!A1", which is a reference to another workbook rather than a sheet range.
Excel does not normalize "plain:'has space'!A1" into a sheet range; it keeps the text as typed and evaluates it to #NAME?, so a separately-quoted pair is never a sheet range to Excel and the shape does not reach fx by way of Excel's normalizer. Accepting it is a leniency towards other producers. Excel does write the shape, but for a different thing: a sheet range that lost an end keeps the colon and quotes whatever survives, "Nope:'Mar'!A1" for a missing first end and "Jan:'[1]Nope'!A1", with a manufactured external link, for a missing second. Prefixes.md now says so, since a caller reading "foo:bar" out of the first one should know what it is looking at.
A colon ahead of the "!" is unambiguous only inside the sheet scope. The rest of the prefix can hold one that means something else — a Windows drive letter in a path, or a colon in a workbook file name — which is what the paragraph about passing splitSheetRange only the sheet scope is about.
Its @returns line offered "a single sheet name" as the only alternative to a sheet range, but ":x", "x:" and "a:b:c" are also undefined and are none of those. Drive-by: a sentence in fixRanges that read "normalizes one three ways".
The rule was known only from "A:C" gaining quotes while "A:AB" stayed bare, and fx read that as per-end quoting without saying so. Measured in Excel on a workbook with sheets A, B, C, D, AA and AB, the trigger is the name: "B:C" and "C:D" are quoted, "A:B", "AA:AB" and "A:AB" are not, so neither the length of the names nor the number of sheets spanned matters, and "C" forces the quotes from either end. That is what needQuotes already yields, being applied per end over a pattern covering R, C, RC and cell-shaped names. Pin the two ends of the measured set that were untested, "B:C" and "AA:AB", and say the rule in Prefixes.md and at needQuotesSheet as a rule about names.
"A1:B2!R3C3" in R1C1 mode is the sheet range A1:B2, where A1 notation reads "A1:B2!C3" as a range operator joining A1 to 'B2'!C3. The asymmetry looks like a bug and is Excel's: with the R1C1 reference style on, "=SUM(A1:B2!R3C3)" sums R3C3 across sheets A1 through B2 while the A1-notation spelling is #VALUE!. Which names are cell-shaped is decided in the notation the cell part uses, and the stored <f> is A1 notation either way, so the same two sheet names read oppositely depending on how the formula is written. fx already does this. Pin it beside the A1-mode case and say so in Prefixes.md, where a reader meeting the A1 rule is likeliest to assume the notation makes no difference.
lexContext already imports lexRange, so a range lexer reaching back for advSheetName would close an import cycle. Move the two scanners and the character class they share to a leaf module instead. No behaviour change.
"C1:C5!R1C1" was a sheet range only because "C5" happens to lex as an R1C1 column part; "C1:Dec!R1C1", "C1:'Dec'!R1C1", "C:D!R1C1" and "R1:Total!R1C1" all lost their far end and left the near one standing alone as a beam. The far end of a sheet range is a sheet name, so whether it also looks like an R1C1 part has nothing to do with it, and the A1 lexer already reads the counterpart spellings as sheet ranges. The fallout was not confined to reading R1C1: translating "=SUM(C:D!A1)" to R1C1 carries the sheet range through verbatim, so translating the result back yielded "=SUM(C:C:'D'!A1)" — a different reference. Look ahead over a sheet name from the range operator, as the context lexer does, alongside the existing check on a far end that did lex as an R1C1 part. Both are needed: an R1C1 part may run past what a sheet name may hold, as the brackets of "C1:R[1]C[1]" do.
The note about "$" and the one about the guard below it were folded into one paragraph, where the first two lines describe a rule the code under them does not enforce.
It hands its options straight to lexContextUnquoted, which now reads
r1c1, mergeRefs and allowTernary off them to ask lexRange whether a
sheet range is a range instead. The declared "{ xlsx: boolean }" hid
that, and structural typing let it through because the three are
optional; getTokens passes one shared object, so it worked by luck.
The docs said to pass "only the sheet scope" but not in what form, so a
caller could not tell whether to hand over the raw, possibly-quoted sheet
prefix or an already-unquoted scope, nor whether the function unquotes
the names itself. Getting it wrong fails silently.
State the contract in all three places that describe it:
- splitSheetRange expects the unquoted scope, does no unquoting of its
own, and returns two names that need no further processing.
- parseA1Ref and parseR1C1Ref return that unquoted scope already, with
the surrounding quotes stripped and doubled apostrophes collapsed, so
every spelling of a prefix converges on one value: 'Sheet 1:Sheet 3'!A1,
foo:'bar baz'!A1 and 'It''s:Fine'!A1 yield the scopes Sheet 1:Sheet 3,
foo:bar baz and It's:Fine.
- Passing the raw quoted prefix is named as the trap it is:
splitSheetRange("'Sheet 1:Sheet 3'") returns two names with stray
quotes, with no error and no undefined to signal it, and a caller
matching those against a workbook's sheets matches nothing.
The @returns line also now says when undefined comes back: no colon, more
than one, or an empty half.
docs/API.md is regenerated from the docstrings, not hand-edited.
gthb
marked this pull request as ready for review
July 31, 2026 18:31
"Ærið:Ärger!A1" is one sheet range here and two endpoints in borgar#52, which carries the same mask fix without sheet ranges. Both assertions meet when borgar#52 lands and master merges in, and git reports no conflict because they sit in different parts of the file, so the failure would otherwise read as broken sheet-range lexing. The comment says which one survives.
That pass rewrote the comments and docs but left the titles, which are string literals, so a title could still say "a cell-shaped left side" beside a comment saying "also a valid cell address". The titles now use the same words as the prose around them: - "cell-shaped left side" -> "left side that is also a cell address" - "sheet names shaped like R1C1 parts" -> "that are also R1C1 parts" - "shaped like a cell in the other notation" -> "that are cell addresses in the other notation" - "a sheet name holding a ." -> "containing a ." - "carried through untouched" -> "passed through untouched" Names only: no assertion, input or expected value changes.
The claim that a colon in the sheet scope "can only be" separating two sheet names holds in front of a cell reference and nowhere else. Measured in Excel, a colon-bearing scope in front of a defined name or a structured reference is either the range operator or a workbook file name, and never a sheet range. Scope the claim and the splitSheetRange recipe accordingly, and record the five measured readings alongside the limitation they expose: Fx reads all four non-cell spellings as sheet ranges and splits them.
splitSheetRange and both parseA1Ref variants hand out a recipe for resolving the sheet slot without saying which references it holds for. Measured in Excel, it holds in front of a cell reference only: a colon-bearing scope in front of a defined name or a structured reference is the range operator when bare and a workbook file name when quoted. Scope the recipe and name the limitation, since Fx reads those spellings as sheet ranges and splits them regardless. docs/API.md regenerated.
A sheet range stands in front of a cell reference and nowhere else, so `Alpha:Gamma!SomeName` and `Alpha:Gamma!Table1[Col]` were being read as one reference where Excel reads two operands joined by the range operator. Measured in Excel, the discriminator is a rename. Renaming the sheet `Alpha` leaves `Alpha:Gamma!SomeName` untouched while both controls move — an ordinary `Alpha!SomeName` becomes `Bee!SomeName`, and a span over a cell becomes `Bee:Mar!A1` — so the `Alpha` in the subject is an ordinary name and not a sheet reference at all. In front of a table Excel goes further and rewrites `Alpha:Gamma!Table1[Col]` to `Alpha:Table1[Col]`, discarding the `Gamma!` as it discards any sheet prefix on a table; a span is not something that could be discarded from there. Add `spanTakesOperand`, which probes the range lexers at the operand, and consult it wherever a colon may join two sheet names: the sheet-range branch of `startsSheetPrefix`, both context lexers, and `lexNameFuncCntx`. The lone-name branch is left alone, so `Jan!SomeName` is untouched — only a colon raises the question. The serializers follow, or the round trip breaks: a sheet range is written per endpoint only in front of a cell reference, and in front of a name or a table the colon is quoted as the ordinary banned character it is. Without that half `stringifyA1Ref` would emit `a:b!Name`, which no longer parses back. The quoted spellings are deliberately left as they are. Excel reads `'Alpha:Gamma'!SomeName` as a workbook file name with no sheet component, and there is no way to represent that here.
Ten assertions pinned the belief that a span stands in front of an operand reached by name. That belief is wrong, so the assertions are what changes: each moves to the measured reading, and each says which of the two readings it is now asserting. The bare spellings become the range operation Excel reads them as — three tokens, a `BinaryExpression`, and `undefined` from the reference parsers — and the quoted spellings take over the sheet-range cases, those still being read as one scope. The serializer cases gain the quotes the colon now needs. No cell, range, beam or ternary assertion moves.
The note said Fx reads all four non-cell spellings as sheet ranges and splits each into two sheet names. That is now false for the bare ones, which read as Excel reads them, and no scope reaches `splitSheetRange` from there at all. What remains true is the quoted half: `'Alpha:Gamma'!SomeName` and `'Alpha:Gamma'!Table1[Col]` are still read as sheet ranges, Excel reading them as a workbook file name with no sheet component and there being no way to represent that here. Say that, and say why it is left for later. `docs/API.md` is regenerated from the docstrings.
"span" appears nowhere else in the codebase as a name for a sheet range, and spanTakesOperand reads as though the span took an operand, where what it answers is whether the operand admits a sheet range. Likewise the "shadowing" of SHADOWING_TYPES, and the near/far pair for two sheet names that are plainly the first and the second. - spanTakesOperand -> operandAllowsSheetRange - SHADOWING_TYPES -> LITERAL_TYPES - near/far locals -> first/second No behaviour change.
The scope-quoting chain had needQuotes(scope, quote) in both the "not the sheet" and the "sheet, but no sheet range possible" branches. One condition covers what the other two shared.
Comments, docstrings and docs/Prefixes.md said the same things several times over and at more length than the reader needs. Cut the repetition and the flourishes, keeping the Excel measurements, the concrete examples and the "otherwise this breaks" reasons. - docs/Prefixes.md: the 3-D section is split into subsections, so the "only in front of a cell reference" rule, the quoting rules and the notation-dependent readings are each stated once instead of restated under every heading they touch - the Excel evidence for what an operand does to the colon lives in Prefixes.md; the lexer comments point at it rather than repeating it - "span" as a noun for a sheet range, and "near/far end" for its two sheet names, are gone in favour of plain wording - rewrapped to the project's 100-column comment width - docs/API.md regenerated The $-refusal note at the colon branch of lexContextUnquoted is dropped: $ never reaches that branch, isContextChar having already turned it away.
`{@link splitSheetRange}` expands to a 47-character markdown link, so
jsdoc lines wrapped to 100 in source came out at 116-121 in docs/API.md,
against a file whose prose otherwise stays inside 100.
Each parseA1Ref docstring keeps one inline link, on a line short enough
to absorb the expansion; the repeat mentions are plain backticks, the
`@see` block still linking the function.
The three "A yields B" examples were parseA1Ref/parseR1C1Ref behaviour, stated in splitSheetRange's own docs and given as formula strings where this function takes a scope, so they read as claims about it. One example carries the only point a caller needs, that the parsers have already unquoted the scope and this function will not. Dropped with the other two is `foo:'bar baz'!A1`, which Excel reads as the name `foo` joined to `'bar baz'!A1` rather than as a sheet range at all: a spelling _Fx_ tolerates for other producers, introduced here well ahead of the section that explains it. docs/Prefixes.md loses the same example.
gthb
added a commit
to gthb/fx
that referenced
this pull request
Aug 4, 2026
…ange Picks up the review pass on borgar#51: the spanTakesOperand -> operandAllowsSheetRange rename, the folded needQuotes branch in stringifyPrefix, the plain-language pass over the sheet-range prose, and the 100-column wrapping of the generated API docs. Conflicts, all of them prose this branch had already rewritten on top of the text borgar#51 then tightened. In each case the claim is this branch's and the wording is borgar#51's: - lib/lexers/advSheetName.ts: one paragraph arrived twice, since borgar#51 tightened the copy above the conflict. Kept the tightened one, and this branch's closing paragraph (a quoted prefix is one reference, and declining the sheet-range reading belongs to splitSheetRange), under the new function name. - lib/parseA1Ref.ts, four hunks: this branch's text throughout — hand splitSheetRange the whole reference, and a quoted prefix parses to one reference holding one scope. Rewrapped so that the inline {@link splitSheetRange} stays inside 100 columns once typedoc expands it to a markdown link, and the second mention in each docstring dropped to plain backticks, as borgar#51 did for the same reason. The trailing "See {@link splitSheetRange}" goes with it; the @see block already links it. - lib/splitSheetRange.ts: structural, this branch having split the function into splitScope plus a reference-reading wrapper while borgar#51 rewrote the docstring around the old shape. Kept this branch's structure and its docstring, minus the repetitions and the `foo:'bar baz'!A1` example borgar#51 removed — Excel reads that as the name `foo` joined to `'bar baz'!A1`, so it illustrates nothing about sheet ranges. - docs/Prefixes.md, five hunks: same resolution, this branch's claims in borgar#51's wording, with borgar#51's subsection structure kept around them. - docs/API.md: generated, so regenerated rather than resolved. Three comment lines landed at 101 columns and were rewrapped.
This reverts commit d2afcc4. docs/API.md is generated, so its line lengths do not matter. Wrapping for them contorted a file people do edit: jsdoc lines cut short at 76 columns to leave room for the markdown link `{@link splitSheetRange}` expands to, and two of the four inline links downgraded to plain backticks to avoid the expansion at all. Line-width conventions govern the sources, not what a generator emits from them.
- "silently" said what the surrounding sentence already said, and
dramatized it: a lookup that "matches no sheet at all" is quiet by
construction, and "no `undefined` to signal it" is the whole point of
the raw-quoted-prefix warning. Dropped, or replaced with what actually
happens ("does not fail at all").
- "carries" for plain possession, in the borgar#52 cross-reference.
- "spelling" where it was not doing the work it does elsewhere in this
branch, which is to distinguish two ways of writing one reference. In
the generated-input module the subject is cases and forms, not
spellings.
Also unpicked one garden-path sentence in docs/Prefixes.md: "a name Excel
quotes standing alone quotes the whole range" now reads "a name that Excel
quotes when it stands alone forces quotes on the whole range".
"shape" is a word this codebase does not otherwise use, and isCellShape's own docstring already opens "Is this sheet name also a valid cell address in the given notation?" — so isCellAddress.
The quoting was explained as ":" being "a banned character in a name", which reads as a claim that Excel forbids it there. It is not that. reBannedChars is the set of characters an unquoted scope may not hold, which is a quoting rule and not a legality one — a space is in it too, and spaces are legal in sheet names. It also applies to every scope, and a path scope holds a colon legitimately, in a Windows drive letter. The reason for the quotes is the round trip: bare, the colon goes to the range operator, so the scope has to be quoted to read back as the one scope it was written from.
Measured in Excel (excel-test three-d-references, A9): an external sheet range is quoted exactly when one of its endpoint names needs quotes, not for being workbook-qualified. Bare [ExtSrc3.xlsx]Alpha:Gamma!A1 stays bare on entry and the quoted form has its needless quotes removed, the same as a single external sheet; [Book.xlsx]S1:S3!A1 gains quotes only because S1 and S3 are cell addresses. The always-quote rule here was inferred from that S1:S3 case before the follow-up probe separated the two, so it over-quoted, writing a form Excel strips on the next save. Drop the rule from stringifyPrefix and stringifyPrefixXlsx, and restate the tests and docs/Prefixes.md accordingly. The [1]Sheet1:Sheet2 cases keep their quotes, which come from the digit-leading workbook name.
Excel treats the two ends differently (excel-test three-d-references, E and A11): a quoted first name is read as the sheet range and normalized to whole-prefix quotes, while a quoted second name is the range operator, never corrected, and evaluates where the left name resolves. The forms Excel writes itself arise on entry of a sheet range whose end names no sheet — not from a deleted sheet, which contracts the range instead. The prose here claimed Excel reads no separately quoted form as a sheet range and traced the written forms to deletion; both are corrected, in docs/Prefixes.md and the test comments. Also correct the unquoteParts comment, which presented separately quoted sheet names as plain prefix syntax.
- say a sheet name would read as a cell address, not that it is one - tighten the Prefixes.md section, cutting restatements and asides - sweep em-dashes (hyphen lookalikes in monospace) and tic words out of the comments - harmonize the two parseA1Ref docstring paragraphs - bullet the list of normalizations fixRanges leaves alone
The module holds the sheet-prefix helpers (startsSheetPrefix, operandAllowsSheetRange, isCellAddress, isContextChar) besides the advSheetName scanner it was named after; only that one function is an advancer in the advRangeOp sense. The function names are unchanged.
lib/sheetRangeProperties.ts looked like package source but was a test harness: nothing outside its spec and the propertySweep script imported it, and it sat in lib/ unexported. It now lives in the spec file, with every export made private. The standalone script (and with it the scripts/ directory) is replaced by scaling the same tests through the environment: SWEEP_COUNT=200000 npx vitest run lib/sheetRangeProperties.spec.ts with SWEEP_SEED to pick a single seed. The default run is the same 4000-case, three-seed slice as before.
- state Excel behaviour directly, dropping the measured-in-Excel and probe-workbook citations - spell out "when unquoted" where a lone "bare" stood for it - write "is not a", not "is no" - shrink the two translate prefix-quoting comments to one line
splitSheetRange now takes a parsed reference as well as a bare scope,
and divides the sheet slot only where a cell reference follows the
prefix. In Excel, a quoted colon-bearing prefix in front of a defined
name or a structured reference is a workbook file name, colon and all
('Jan:Mar'!SomeName is stored as [1]!SomeName), so dividing that scope
yields two sheets Excel never read there. A reference to a name or a
table now yields undefined whatever its scope holds, leaving the scope
to the lone-scope resolution docs/Prefixes.md describes. AnyReference
is exported for the parameter; the string form is kept and divides
unconditionally.
Squash of the pre-borgar#51-rebase branch, preserved in
quoted-colon-scope-is-not-a-sheet-range-before-rebasing.
splitSheetRange(parseA1Ref(x)) is the documented composition, and parseA1Ref returns undefined for an invalid reference; the object branch then threw on the in operator where the string form had always returned undefined. Return undefined for a missing reference instead.
Cut the Excel readings the splitSheetRange docstring repeated from Prefixes.md down to a pointer, and lead with the absence where a sentence described quotes that are not there.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
Parse 3-D references, where a prefix names a range of sheets rather than one sheet, as in
Sheet1:Sheet2!A1.Only the quoted form worked before, and by accident:
lexContextQuotedtakes whatever sits between the quotes without inspecting it, so'Sheet1:Sheet2'!A1already yielded the scopeSheet1:Sheet2, read as one sheet name (which no sheet can have, since Excel forbids:in sheet names). Unquoted,fool:bard!A1parsed as aBinaryExpression, andJan:Dec!A1threwUnknown operator !becauseJANandDECare valid column IDs, soJan:Declexed as a beam and stranded the!.Both sheet names now go into the sheet slot as one compound name (
context: [ 'Jan:Dec' ], orsheetName: 'Jan:Dec'in the xlsx variant), so the reference types are unchanged. That puts a new obligation on consumers: code resolving the sheet slot of a cell reference must now split it first, in case it is a 3-D reference (contains a colon).splitSheetRangeis exported for that. It takes the parsed reference (AnyReference) and divides the slot only in front of a cell reference, the one position where a colon in the sheet slot separates two sheet names. A bare scope is accepted too; that form cannot see what follows the prefix, so it splits any colon-bearing scope, and establishing that a cell reference follows is the caller's business.docs/Prefixes.mdgains a section covering the syntax, the quoting rules, and the Excel behaviour behind them (Excel for Mac 16.113). Two behaviours matter to the API:SUM(A1:B2!C3)is stored asSUM(A1:'B2'!C3)and evaluates to#VALUE!, whileSUM(A:C!A1)andSUM(Jan:Mar!A1)are sheet ranges. Which names count depends on the notation the formula is read in, soA1:B2!R3C3in R1C1 style is a sheet range andR1C1:R2C2!R3C3is not.Alpha:Gamma!SomeNameis the range operator joining a nameAlphatoGamma!SomeName(a rename of the sheetAlphaproves it, leaving that formula untouched while rewriting bothAlpha!SomeNameandAlpha:Gamma!A1), soparseA1Refreturnsundefinedfor it, as for any expression that is not a single reference. Quoted,'Alpha:Gamma'!SomeNameis a workbook file name to Excel, stored[n]!SomeNamewith no sheet at all, which the reference model has no slot for. The quoted prefix still lexes and parses as one reference holding the single scopeAlpha:Gamma, andsplitSheetRangedeclines to divide that scope in front of a name or a table.fixFormulaRangesnormalizes the range of a 3-D reference but never its sheet range: Excel normalizes the order, the degenerateJan:Janand the case of the two sheet names, and all three need the workbook's list of sheets, which formula text alone does not give.Design note on splitSheetRange
To Excel,
'Jan:Mar'!SomeNamenames a workbook file rather than two sheets, so the resolution recipe indocs/Prefixes.mdmust not divide that scope. Three places could stop it, and the choice here is the last of them:workbookNamefor an unbracketed scope in the xlsx variant, whose two properties otherwise mirror the bracketing exactly.splitSheetRangeread the whole reference, so that it sees the name behind the prefix.So the xlsx variant still reports
sheetName: 'Jan:Mar'for'Jan:Mar'!SomeName, a sheet no workbook can have; only the property name is off, andsplitSheetRangedeclines to divide it there too.Generated inputs
lib/sheetRangeProperties.spec.tsgenerates prefixes and formulas from a grammar and checks that the two lexer sets agree on the prefix, that parse → stringify → parse is a fixpoint keeping both sheet names, and that tokens rejoin to their input. It found six defects, each pinned as a test on its shrunk case. Two overlap with sibling PRs:Ærið!A1was one name token. Unrelated to sheet ranges, so it is also Fix non-ASCII characters breaking unquoted names and sheet names #52 on its own. When that lands,Ærið:Ärger!A1will have two conflicting assertions intokenize.spec.ts, in different describe blocks, with no git conflict to flag it; this branch's is the one that survives, and a comment beside it says so.'False:Jan'!A1was written back with a boolean-reading sheet name unquoted. That reaches beyond sheet ranges (onmaster,fixFormulaRanges("=SUM('TRUE'!A1)")returns=SUM(TRUE!A1), which Excel then refuses to open), so it is also Fix formula normalization stripping the quotes a boolean-named sheet needs #53 on its own.Performance
startsSheetPrefixruns whereverlexRangeis offered a position, costing a scan of the name run ahead of it. Over threenpm run benchmarkruns each way the tokenizer loses about 5% andparseA1Refabout 13%, with the parser unchanged.Drive-by
The
parseA1Refexample of xlsx-mode parsing indocs/Prefixes.mdpassed a{ xlsx: true }option thatparseA1Refdoes not take. It now calls the@borgar/fx/xlsxentry point, which is where the xlsx counterparts live.Notes
foo:'bar'!A1) as a sheet range, to be forgiving of other producers. Excel agrees about a quoted first name and reads a quoted second name as the range operator instead;docs/Prefixes.mdstates the asymmetry.fixRangesandtranslateToA1disagree about quoting the prefix to the right of a range operator, the latter adding quotes the former strips.