Skip to content

Stop splitting a quoted colon-bearing scope that is not a sheet range - #54

Closed
gthb wants to merge 79 commits into
borgar:masterfrom
gthb:quoted-colon-scope-is-not-a-sheet-range
Closed

Stop splitting a quoted colon-bearing scope that is not a sheet range#54
gthb wants to merge 79 commits into
borgar:masterfrom
gthb:quoted-colon-scope-is-not-a-sheet-range

Conversation

@gthb

@gthb gthb commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

What

Stop splitSheetRange dividing a scope that is not two sheet names, by giving it the parsed reference to read rather than the scope alone.

docs/Prefixes.md documents splitSheetRange(scope) ?? [ scope ] as the way to resolve the sheet slot of a reference. Handed 'Jan:Mar'!SomeName, that recipe returns two sheet names for something Excel reads as a single workbook file with no sheet in it at all, so a host following it resolves two sheets that do not exist. 'Alpha:Gamma'!Table1[Col] goes the same way.

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, resolved against the document's own directory: 'Jan:Mar'!SomeName is stored as [1]!SomeName (a workbook index, a bang, and nothing between them, where every other external reference has a sheet name in that position). The identical prefix in front of a cell is a genuine sheet range. The operand is what varies, not the prefix.

Follows #51 and depends on it, so the diff shown here includes it.

How

The scope was being asked a question it cannot answer: whether a colon divides two sheet names turns entirely on what follows the !. #51 covered that gap with a docstring telling callers to allow for it themselves; passing the reference closes it.

So 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. A reference to a name or a table yields undefined whatever its scope holds, which leaves that scope to the lone-scope resolution docs/Prefixes.md already describes (tested as a sheet name first, matched against a workbook second), and a colon-bearing scope can only fail the sheet test. The ordinary order therefore arrives at the workbook by itself, and nothing has to be added to the reference model.

AnyReference is exported for the parameter, a union of the eight reference types already exported. The string form is kept for a caller holding a sheetName from its own storage; it divides unconditionally, and its docstring says that establishing what follows the prefix is then that caller's business.

Alternatives

This is put up as a proposal; a different call is reasonable. Fx has no slot meaning "a workbook, and no sheet", and there were three ways to get one or do without:

  • Resolve the scope as a workbook in the reference model. Needs nothing built in the runtime variant, where docs/Prefixes.md already says a lone scope is tested as a sheet name first and matched against a workbook second. But in the xlsx variant it would mean reporting a workbookName for an unbracketed scope, making those two properties say something other than the bracketing they are read off, which is the whole point of that variant.
  • A flag on the reference marking the scope as a workbook. Round-trips cleanly, but adds a property to eight public reference types to carry what the consumer can already derive.
  • Change what splitSheetRange does. Taken here.

Residue

The xlsx variant still reports sheetName: 'Jan:Mar' for 'Jan:Mar'!SomeName, a sheet no workbook can have. Moving it to workbookName is the first alternative above, with the cost noted there, so it stays as it is and docs/Prefixes.md now says so. splitSheetRange declines to divide it in that variant too, so the recipe is no longer wrong there either; only the property name is.

gthb added 30 commits July 30, 2026 15:09
"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.
gthb added 27 commits July 31, 2026 17:25
The round-trip property took a parser returning "unknown" and a serializer
taking "never", and cast between the two at both call sites. Making the
helper generic in the reference type lets each of the four pairings infer
its own, so a parser handed the wrong serializer stops type-checking rather
than being cast past.
The comments and docs added for 3-D references leaned on a handful of
indirect verbs where an ordinary one says the same thing, and several of
them hid which actor was doing the acting:

- "hold"/"carry" for a container relationship become "contain" or "have":
  a sheet name contains a "."; a file containing "$Jan:$Mar!A1" does not
  open; a path scope may contain a colon of its own.
- "shaped like a cell"/"cell-shaped" become "also a valid cell address",
  and "the cell shapes of each notation" becomes "the cell addresses of
  each notation". The identifiers (isCellShape, CELL_SHAPED, ...) are
  unchanged, as are the test titles.
- "wins the colon"/"loses the colon" become "takes the colon"/"gives the
  colon to the range operator".
- "stands aside" becomes "defers to" or "gives way to".
- "sits" becomes "is", "is in", or "appears".
- "reaches" is kept where it is exact ("reaches a fixpoint") and replaced
  where it was vague: canEndRange never sees a whole sheet range, and the
  half-typed "=A1:'Sheet 2'!B2" ends its range at the colon.
- "a leniency towards other producers" becomes a statement of who is
  being lenient and why: Fx accepts the spelling to be forgiving of other
  producers.

Naming the actor changes the claim for the better in two places. The
fixRanges docstrings said normalizing a sheet range needs sheet names
"which a formula does not carry" — it is Fx that does not know them, and
that is now what it says. The property module said Excel quotes both
shadowing spellings "so neither reaches a reader from a file Excel
wrote", now "so a reader never sees either of them".

docs/API.md is regenerated from the docstrings, not hand-edited.
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.
"Æ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.
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.
@gthb
gthb force-pushed the quoted-colon-scope-is-not-a-sheet-range branch from 5b28d54 to 707e138 Compare August 6, 2026 17:08
@gthb

gthb commented Aug 6, 2026

Copy link
Copy Markdown
Contributor Author

Folded into #51

@gthb gthb closed this Aug 6, 2026
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.

1 participant