From f154a05140a2eb540bc1e4c0ededbe72c4ba9ed4 Mon Sep 17 00:00:00 2001 From: Gunnlaugur Thor Briem Date: Thu, 3 Sep 2026 14:58:23 +0000 Subject: [PATCH 1/6] fix: a quoted scope in an operand position does not parse `'Alpha':[Book.xlsx]Gamma!A1` and `'Alpha:Beta':Gamma!A1` fail to parse: "Unexpected context_quote token". Their first name is a quoted scope with no `!` after it, so it is not a scope but an operand, and `parse` has no production for one there. The lexer types it as a scope on the chance that a second sheet name follows. Where nothing merges it, `mergeRefTokens` now corrects it to `ref_named`, which is what Excel reads there and what the unquoted `a` of `a:'b'!A1` already gets, so the two ways of writing the operation agree. A scope that does have its `!` keeps the scope type whether or not it merged, since a scope containing a `:` never merges with the name or table after it, as in `'Sheet1:Sheet2'!name`. Untouched: an unquoted scope in the same position, `1` in `1:'dec'!B11`, which the lexer leaves as `context` and which still does not parse. A number is not a name, so it wants a different answer. --- lib/mergeRefTokens.ts | 11 +++++++++++ lib/tokenize-3d.spec.ts | 10 ++++++---- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/lib/mergeRefTokens.ts b/lib/mergeRefTokens.ts index bd211db..7d71ee8 100644 --- a/lib/mergeRefTokens.ts +++ b/lib/mergeRefTokens.ts @@ -193,6 +193,11 @@ const matcher = ( return best; }; +function isBangAfter (tokenlist: Token[], i: number): boolean { + const next = tokenlist[i + 1]; + return !!next && next.type === OPERATOR && next.value === '!'; +} + function commonMergeRefTokens (tokenlist: Token[], xlsx: boolean): Token[] { const finalTokens = []; // this seeks backwards because it's really the range part @@ -217,6 +222,12 @@ function commonMergeRefTokens (tokenlist: Token[], xlsx: boolean): Token[] { i -= valid - 1; } } + // A quoted scope with no `!` after it is a name, not a scope; the lexer types it as a scope + // on the chance that a second sheet name follows. One that does have its `!` keeps the scope + // type even unmerged, since a scope containing a `:` never merges with the name after it. + if (token.type === CONTEXT_QUOTE && !isBangAfter(tokenlist, i)) { + token = { ...token, type: REF_NAMED }; + } finalTokens[finalTokens.length] = token; } return finalTokens.reverse(); diff --git a/lib/tokenize-3d.spec.ts b/lib/tokenize-3d.spec.ts index 51bbdf6..8b03bd0 100644 --- a/lib/tokenize-3d.spec.ts +++ b/lib/tokenize-3d.spec.ts @@ -132,7 +132,7 @@ describe('lexer: 3d ranges', () => { { type: REF_RANGE, value: 'A1' } ]); expect(tokenize("'Alpha:Beta':Gamma!A1")).toEqual([ - { type: CONTEXT_QUOTE, value: "'Alpha:Beta'" }, + { type: REF_NAMED, value: "'Alpha:Beta'" }, { type: OPERATOR, value: ':' }, { type: REF_RANGE, value: 'Gamma!A1' } ]); @@ -200,7 +200,7 @@ describe('lexer: 3d ranges', () => { expect(tokenize("Alpha:'Beta:Gamma':Delta!A1")).toEqual([ { type: REF_NAMED, value: 'Alpha' }, { type: OPERATOR, value: ':' }, - { type: CONTEXT_QUOTE, value: "'Beta:Gamma'" }, + { type: REF_NAMED, value: "'Beta:Gamma'" }, { type: OPERATOR, value: ':' }, { type: REF_RANGE, value: 'Delta!A1' } ]); @@ -317,8 +317,10 @@ describe('lexer: 3d ranges', () => { { type: OPERATOR, value: '!' }, { type: REF_RANGE, value: 'A1' } ]); + // Nothing merges the first scope, because a workbook specifier is not allowed in the + // second. What is left has no `!` after it, so it is the operand Excel reads as a name. expect(tokenize("'Alpha':[Book.xlsx]Gamma!A1")).toEqual([ - { type: CONTEXT_QUOTE, value: "'Alpha'" }, + { type: REF_NAMED, value: "'Alpha'" }, { type: OPERATOR, value: ':' }, { type: REF_RANGE, value: '[Book.xlsx]Gamma!A1' } ]); @@ -380,7 +382,7 @@ describe('lexer: 3d ranges', () => { { type: REF_RANGE, value: 'A1' } ]); expect(tokenize("'Alpha':'[Book.xlsx]Gamma'!A1")).toEqual([ - { type: CONTEXT_QUOTE, value: "'Alpha'" }, + { type: REF_NAMED, value: "'Alpha'" }, { type: OPERATOR, value: ':' }, { type: REF_RANGE, value: "'[Book.xlsx]Gamma'!A1" } ]); From f022b88b3515bc1da20e1f9dd331fd6ac91131e6 Mon Sep 17 00:00:00 2001 From: Gunnlaugur Thor Briem Date: Thu, 3 Sep 2026 15:08:04 +0000 Subject: [PATCH 2/6] Pin that the corrected type parses The fix is a token type, and only the lexer specs covered it. These assert the outcome that motivated it: both formulas reach an AST, with the quoted scope as the name operand of the range operator. --- lib/parse.spec.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/lib/parse.spec.ts b/lib/parse.spec.ts index d9deaa6..850d0eb 100644 --- a/lib/parse.spec.ts +++ b/lib/parse.spec.ts @@ -137,6 +137,25 @@ describe('parser', () => { isParsed("'A1:B2'!C3", { type: 'ReferenceIdentifier', value: "'A1:B2'!C3", kind: 'range' }); }); + test('a quoted scope with no bang after it is a name operand', () => { + isParsed("'Alpha':[Book.xlsx]Gamma!A1", { + type: 'BinaryExpression', + operator: ':', + arguments: [ + { type: 'ReferenceIdentifier', value: "'Alpha'", kind: 'name' }, + { type: 'ReferenceIdentifier', value: '[Book.xlsx]Gamma!A1', kind: 'range' } + ] + }); + isParsed("'Alpha:Beta':Gamma!A1", { + type: 'BinaryExpression', + operator: ':', + arguments: [ + { type: 'ReferenceIdentifier', value: "'Alpha:Beta'", kind: 'name' }, + { type: 'ReferenceIdentifier', value: 'Gamma!A1', kind: 'range' } + ] + }); + }); + test('"$" is not allowed on an unquoted sheet name', () => { isInvalidExpr('=SUM($Jan:$Mar!A1)'); isParsed("'$Jan:$Mar'!A1", { type: 'ReferenceIdentifier', value: "'$Jan:$Mar'!A1", kind: 'range' }); From a7e5d7f7308702a8ff391b6a9ae074b8f35fedcd Mon Sep 17 00:00:00 2001 From: Gunnlaugur Thor Briem Date: Thu, 3 Sep 2026 15:24:10 +0000 Subject: [PATCH 3/6] Say that the parse test pins fx's reading, not Excel's The test asserted `'Alpha:Beta':Gamma!A1` as the name `Alpha:Beta` joined to `Gamma!A1`, under a name that read as a claim about Excel. Excel's reading of that form is unmeasured, and for the nearest measured neighbour, a quoted first name followed by an unquoted second, Excel reads the sheet range instead. What the test is really for is that the formula reaches an AST at all, where the quoted first name used to stop the parser. Renamed to say it parses that way, with the scope of the claim in a comment. --- lib/parse.spec.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/lib/parse.spec.ts b/lib/parse.spec.ts index 850d0eb..b07a900 100644 --- a/lib/parse.spec.ts +++ b/lib/parse.spec.ts @@ -137,7 +137,11 @@ describe('parser', () => { isParsed("'A1:B2'!C3", { type: 'ReferenceIdentifier', value: "'A1:B2'!C3", kind: 'range' }); }); - test('a quoted scope with no bang after it is a name operand', () => { + // These pin fx's reading, not Excel's, which is unmeasured for both forms. Excel reads a + // quoted first name followed by an *unquoted* second as the sheet range, so its answer here + // may well differ; what matters below is that the formula reaches an AST at all, since the + // quoted first name used to arrive as a scope and stop the parser. + test('a quoted scope with no bang after it parses as a name operand', () => { isParsed("'Alpha':[Book.xlsx]Gamma!A1", { type: 'BinaryExpression', operator: ':', From 92fc49d97e46152e79c5988172b8eb83e74f5beb Mon Sep 17 00:00:00 2001 From: Gunnlaugur Thor Briem Date: Thu, 3 Sep 2026 16:07:28 +0000 Subject: [PATCH 4/6] Excel reads these forms as the range operator as well The test name and its comment left the reading as fx's own. Excel takes both formulas the same way and stores them with the second name quoted, `'Alpha:Beta':'Gamma'!A1` and `Alpha:'[1]Gamma'!A1`. Neither resolves: a defined name cannot contain a colon, and `Alpha` is not a name in that workbook, so both are #NAME?. In the second form the workbook bracket is what rules out a sheet range; the quote on the first name is redundant and Excel drops it. --- lib/parse.spec.ts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/lib/parse.spec.ts b/lib/parse.spec.ts index b07a900..d0b335c 100644 --- a/lib/parse.spec.ts +++ b/lib/parse.spec.ts @@ -137,11 +137,10 @@ describe('parser', () => { isParsed("'A1:B2'!C3", { type: 'ReferenceIdentifier', value: "'A1:B2'!C3", kind: 'range' }); }); - // These pin fx's reading, not Excel's, which is unmeasured for both forms. Excel reads a - // quoted first name followed by an *unquoted* second as the sheet range, so its answer here - // may well differ; what matters below is that the formula reaches an AST at all, since the - // quoted first name used to arrive as a scope and stop the parser. - test('a quoted scope with no bang after it parses as a name operand', () => { + // Excel reads both as the range operator over the quoted name, and stores them with the + // second name quoted: `'Alpha:Beta':'Gamma'!A1` and `Alpha:'[1]Gamma'!A1`. In the second it + // is the workbook bracket that rules out a sheet range, not the quote, which Excel drops. + test('a quoted scope with no bang after it is a name operand', () => { isParsed("'Alpha':[Book.xlsx]Gamma!A1", { type: 'BinaryExpression', operator: ':', From 265280e23d3e5ef30a4334765f2bf066c879b70f Mon Sep 17 00:00:00 2001 From: Gunnlaugur Thor Briem Date: Thu, 3 Sep 2026 16:25:48 +0000 Subject: [PATCH 5/6] Trim the retyping comment to the rule it implements Two clauses went: the lexer's speculative typing, which the description already gives as the cause, and a justification for the `!` exclusion that was wrong. It said a scope containing a `:` never merges with the name after it, which holds under `xlsx` only; in the default mode `'Sheet1:Sheet2'!name` merges into one reference. What is left is the rule the guard implements. --- lib/mergeRefTokens.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/lib/mergeRefTokens.ts b/lib/mergeRefTokens.ts index 7d71ee8..49c4e25 100644 --- a/lib/mergeRefTokens.ts +++ b/lib/mergeRefTokens.ts @@ -222,9 +222,8 @@ function commonMergeRefTokens (tokenlist: Token[], xlsx: boolean): Token[] { i -= valid - 1; } } - // A quoted scope with no `!` after it is a name, not a scope; the lexer types it as a scope - // on the chance that a second sheet name follows. One that does have its `!` keeps the scope - // type even unmerged, since a scope containing a `:` never merges with the name after it. + // A quoted scope with no `!` after it is a name, not a scope. One that has its `!` stays a + // scope, merged or not. if (token.type === CONTEXT_QUOTE && !isBangAfter(tokenlist, i)) { token = { ...token, type: REF_NAMED }; } From cbb6d53f372a1194139fc57fdffa4263de353a22 Mon Sep 17 00:00:00 2001 From: Gunnlaugur Thor Briem Date: Thu, 3 Sep 2026 14:59:14 +0000 Subject: [PATCH 6/6] fix: a sheet range whose both ends are quoted is a range operation `'a':'b'!A1` was lexed and parsed as the sheet range `a:b`. A quote on the second name makes the colon a range operator, whatever stands to its left, so it is the name `a` joined to `'b'!A1` -- a different formula over different sheets. Excel leaves the form as written on the next save, so nothing downstream corrects it. fx already had the rule for an unquoted first name, `a:'b'!A1`. It was the quoted-first case that kept the sheet-range reading, in both lexing paths: - `mergeRefTokens` had a pattern group for `'a':b!A1` and `'a':'b'!A1` together. Narrowing its second name to unquoted makes it a subset of the group below it, which already covers `'a':b!A1`, so the group goes and the surviving one carries the rule. - `pExtendedContext` accepted a quoted second name when the first was quoted too, and now accepts an unquoted one only. Four spec files asserted the old reading and now assert the new one. Two were already at odds with themselves: `fixRanges` asserted the sheet range under the title "a quote around the second end is the range operator, not a 3D reference", and a lexer comment said Excel corrects `'[Book.xlsx]Alpha':'Gamma'!A1` into a span, which is the reading this removes. That comment is gone, along with an `XXX` asking that fixRanges deal with the form, which it now does. One grouping changes with it. `addTokenMeta` gives references that resolve to the same cells one group id, so the `'dec'!B11` of `'jan':'dec'!B11` now shares a group with a lone `Dec!B11` instead of with the sheet ranges. --- lib/addTokenMeta.spec.ts | 20 +++++++++++++------- lib/fixRanges.spec.ts | 4 ++-- lib/mergeRefTokens.ts | 13 +++---------- lib/parseA1Ref.spec.ts | 8 +++++--- lib/parseRef.ts | 13 ++++++------- lib/tokenize-3d.spec.ts | 16 ++++++++++------ 6 files changed, 39 insertions(+), 35 deletions(-) diff --git a/lib/addTokenMeta.spec.ts b/lib/addTokenMeta.spec.ts index bfb2bec..4a0ef0b 100644 --- a/lib/addTokenMeta.spec.ts +++ b/lib/addTokenMeta.spec.ts @@ -1,5 +1,5 @@ import { describe, test, expect } from 'vitest'; -import { FX_PREFIX, OPERATOR, NUMBER, REF_RANGE, REF_BEAM, FUNCTION, WHITESPACE, REF_STRUCT } from './constants.ts'; +import { FX_PREFIX, OPERATOR, NUMBER, REF_RANGE, REF_BEAM, FUNCTION, WHITESPACE, REF_STRUCT, REF_NAMED } from './constants.ts'; import { addTokenMeta } from './addTokenMeta.ts'; import { tokenize } from './tokenize.ts'; @@ -85,15 +85,21 @@ describe('add extra meta to operators', () => { { index: 2, depth: 0, type: OPERATOR, value: ',' }, { index: 3, depth: 0, type: REF_RANGE, value: "'jan:dec'!B11", groupId: 'fxg1' }, { index: 4, depth: 0, type: OPERATOR, value: ',' }, - { index: 5, depth: 0, type: REF_RANGE, value: "'jan':'dec'!B11", groupId: 'fxg1' }, - { index: 6, depth: 0, type: OPERATOR, value: ',' }, - { index: 7, depth: 0, type: REF_RANGE, value: "'jan':dec!B11", groupId: 'fxg1' }, + // `'jan':'dec'!B11` is not a sheet range: the quote on the second name makes the colon a + // range operator, so the reference in it is `'dec'!B11` alone. References share a group + // when they resolve to the same sheet and cells, wherever they sit in the formula, so it + // shares `Dec!B11`'s group and not the sheet ranges'. + { index: 5, depth: 0, type: REF_NAMED, value: "'jan'" }, + { index: 6, depth: 0, type: OPERATOR, value: ':' }, + { index: 7, depth: 0, type: REF_RANGE, value: "'dec'!B11", groupId: 'fxg2' }, { index: 8, depth: 0, type: OPERATOR, value: ',' }, - { index: 9, depth: 0, type: REF_RANGE, value: 'JAN:dEc!B11', groupId: 'fxg1' }, + { index: 9, depth: 0, type: REF_RANGE, value: "'jan':dec!B11", groupId: 'fxg1' }, { index: 10, depth: 0, type: OPERATOR, value: ',' }, - { index: 11, depth: 0, type: REF_RANGE, value: 'Jan!B11', groupId: 'fxg2' }, + { index: 11, depth: 0, type: REF_RANGE, value: 'JAN:dEc!B11', groupId: 'fxg1' }, { index: 12, depth: 0, type: OPERATOR, value: ',' }, - { index: 13, depth: 0, type: REF_RANGE, value: 'Dec!B11', groupId: 'fxg3' } + { index: 13, depth: 0, type: REF_RANGE, value: 'Jan!B11', groupId: 'fxg3' }, + { index: 14, depth: 0, type: OPERATOR, value: ',' }, + { index: 15, depth: 0, type: REF_RANGE, value: 'Dec!B11', groupId: 'fxg2' } ], { sheetName: 'Sheet1', workbookName: 'foo' }); }); diff --git a/lib/fixRanges.spec.ts b/lib/fixRanges.spec.ts index e5c01c6..4dc420a 100644 --- a/lib/fixRanges.spec.ts +++ b/lib/fixRanges.spec.ts @@ -119,8 +119,8 @@ describe('fixRanges prefixes', () => { test('a quote around the second end is the range operator, not a 3D reference', () => { isFixed("=foo:'bar baz'!A1", "=foo:'bar baz'!A1"); - // Excel fixes this to `=foo:'bar baz'!A1` - isFixed("='foo':'bar baz'!A1", "='foo:bar baz'!A1"); + // Excel leaves this one as written on the next save; its formula bar drops the second quote + isFixed("='foo':'bar baz'!A1", "='foo':'bar baz'!A1"); isFixed("=foo:'bar baz'!A1", "=foo:'bar baz'!A1", { xlsx: true }); isFixed("=Jan:'[1]Nope'!A1", '=Jan:[1]Nope!A1'); }); diff --git a/lib/mergeRefTokens.ts b/lib/mergeRefTokens.ts index 49c4e25..aab8fb8 100644 --- a/lib/mergeRefTokens.ts +++ b/lib/mergeRefTokens.ts @@ -27,17 +27,10 @@ const validRunsMerge = [ [ [ CONTEXT, CONTEXT_QUOTE ], '!', REF_BEAM ], [ [ CONTEXT, CONTEXT_QUOTE ], '!', REF_TERNARY ], - // 'Sheet1':Sheet2!A1 | 'Sheet1':'Sheet2'!A1 - [ CONTEXT_QUOTE, ':', [ CONTEXT, CONTEXT_QUOTE ], '!', REF_CELL, ':', REF_CELL ], - [ CONTEXT_QUOTE, ':', [ CONTEXT, CONTEXT_QUOTE ], '!', REF_CELL, '.:', REF_CELL ], - [ CONTEXT_QUOTE, ':', [ CONTEXT, CONTEXT_QUOTE ], '!', REF_CELL, ':.', REF_CELL ], - [ CONTEXT_QUOTE, ':', [ CONTEXT, CONTEXT_QUOTE ], '!', REF_CELL, '.:.', REF_CELL ], - [ CONTEXT_QUOTE, ':', [ CONTEXT, CONTEXT_QUOTE ], '!', REF_CELL ], - [ CONTEXT_QUOTE, ':', [ CONTEXT, CONTEXT_QUOTE ], '!', REF_RANGE ], - [ CONTEXT_QUOTE, ':', [ CONTEXT, CONTEXT_QUOTE ], '!', REF_BEAM ], - [ CONTEXT_QUOTE, ':', [ CONTEXT, CONTEXT_QUOTE ], '!', REF_TERNARY ], - // Sheet1:Sheet2!A1 | 'Sheet1':Sheet2!A1 + // + // The second name must be unquoted. A quote on it makes the colon a range operator rather than a + // sheet-range marker, so `a:'b'!A1` and `'a':'b'!A1` are two references and are not merged. [ [ REF_NAMED, CONTEXT, CONTEXT_QUOTE ], ':', CONTEXT, '!', REF_CELL, ':', REF_CELL ], [ [ REF_NAMED, CONTEXT, CONTEXT_QUOTE ], ':', CONTEXT, '!', REF_CELL, '.:', REF_CELL ], [ [ REF_NAMED, CONTEXT, CONTEXT_QUOTE ], ':', CONTEXT, '!', REF_CELL, ':.', REF_CELL ], diff --git a/lib/parseA1Ref.spec.ts b/lib/parseA1Ref.spec.ts index 75fde98..1ce2d5c 100644 --- a/lib/parseA1Ref.spec.ts +++ b/lib/parseA1Ref.spec.ts @@ -133,9 +133,6 @@ describe('parse A1 references', () => { isA1Equal("'[Book.xlsx]foo':bar!A1", { context: [ 'Book.xlsx', 'foo:bar' ], range }); isA1Equal("'foo':bar!A1", { sheetName: 'foo:bar', range }, { xlsx: true }); isA1Equal("'[1]foo':bar!A1", { workbookName: '1', sheetName: 'foo:bar', range }, { xlsx: true }); - isA1Equal("'foo':'bar'!A1", { sheetName: 'foo:bar', range }, { xlsx: true }); - isA1Equal("'[1]foo':'bar'!A1", { workbookName: '1', sheetName: 'foo:bar', range }, { xlsx: true }); - isA1Equal("'foo bar':'baz'!A1", { sheetName: 'foo bar:baz', range }, { xlsx: true }); }); test('a quote around the second end is the range operator, not a sheet range', () => { @@ -147,6 +144,11 @@ describe('parse A1 references', () => { isA1Equal("foo:'bar baz'!A1", undefined, { xlsx: true }); isA1Equal("1:'Dec'!A1", undefined, { xlsx: true }); isA1Equal("5:'a b'!A1", undefined, { xlsx: true }); + isA1Equal("'foo':'bar'!A1", undefined); + isA1Equal("'foo bar':'baz'!A1", undefined); + isA1Equal("'foo':'bar'!A1", undefined, { xlsx: true }); + isA1Equal("'[1]foo':'bar'!A1", undefined, { xlsx: true }); + isA1Equal("'foo bar':'baz'!A1", undefined, { xlsx: true }); }); test('a workbook specifier is not allowed in the second section of a sheet-range', () => { diff --git a/lib/parseRef.ts b/lib/parseRef.ts index 9b4fb54..97af303 100644 --- a/lib/parseRef.ts +++ b/lib/parseRef.ts @@ -202,14 +202,13 @@ const pContextNames: RefParserPart = (t, data, xlsx) => { } } }; -const pExtendedContext: RefParserPart = (t, data, xlsx, r1c1, tokens) => { - const type = t?.type; - // We don't allow quoted sheet ranges if the prev context was unquoted: - // ✅ a:b ✅ 'a':'b' ✅ 'a':b ⛔️ a:'b' - if (type === CONTEXT || (type === CONTEXT_QUOTE && tokens[0].type === CONTEXT_QUOTE)) { +const pExtendedContext: RefParserPart = (t, data, xlsx) => { + // The second name must be unquoted. A quote on it makes the colon a range operator rather than + // a sheet-range marker, whatever stands to its left: + // ✅ a:b ✅ 'a':b ⛔️ a:'b' ⛔️ 'a':'b' + if (t?.type === CONTEXT) { const d: Partial = {}; - const value = type === CONTEXT_QUOTE ? unquotePrefix(t.value) : t.value; - splitContext(value, d, xlsx); + splitContext(t.value, d, xlsx); if (xlsx && d.sheetName && !d.workbookName) { data.sheetName += ':' + d.sheetName; return 1; diff --git a/lib/tokenize-3d.spec.ts b/lib/tokenize-3d.spec.ts index 8b03bd0..f6446a1 100644 --- a/lib/tokenize-3d.spec.ts +++ b/lib/tokenize-3d.spec.ts @@ -98,7 +98,7 @@ describe('lexer: 3d ranges', () => { { type: REF_RANGE, value: "'Gamma'!A1" } ]); }); - test('Both sections quoted independently', () => { + test('a quote on the second section leaves two references', () => { expect(tokenize("'Alpha':'Gamma'!A1", { mergeRefs: false })).toEqual([ { type: CONTEXT_QUOTE, value: "'Alpha'" }, { type: OPERATOR, value: ':' }, @@ -106,8 +106,12 @@ describe('lexer: 3d ranges', () => { { type: OPERATOR, value: '!' }, { type: REF_RANGE, value: 'A1' } ]); + // The quote on the second name makes the colon a range operator, so these are two + // references and are not merged. expect(tokenize("'Alpha':'Gamma'!A1")).toEqual([ - { type: REF_RANGE, value: "'Alpha':'Gamma'!A1" } + { type: REF_NAMED, value: "'Alpha'" }, + { type: OPERATOR, value: ':' }, + { type: REF_RANGE, value: "'Gamma'!A1" } ]); }); test('Both sections quoted together', () => { @@ -262,9 +266,7 @@ describe('lexer: 3d ranges', () => { ]); }); - test('both sections quoted, but independently', () => { - // XXX: ensure fixranges deals with this - // Excel will correct this to `'[Book.xlsx]Alpha:Gamma'!A1` + test('a quote on the second section leaves two references, with a workbook', () => { expect(tokenize("'[Book.xlsx]Alpha':'Gamma'!A1", { mergeRefs: false })).toEqual([ { type: CONTEXT_QUOTE, value: "'[Book.xlsx]Alpha'" }, { type: OPERATOR, value: ':' }, @@ -273,7 +275,9 @@ describe('lexer: 3d ranges', () => { { type: REF_RANGE, value: 'A1' } ]); expect(tokenize("'[Book.xlsx]Alpha':'Gamma'!A1")).toEqual([ - { type: REF_RANGE, value: "'[Book.xlsx]Alpha':'Gamma'!A1" } + { type: REF_NAMED, value: "'[Book.xlsx]Alpha'" }, + { type: OPERATOR, value: ':' }, + { type: REF_RANGE, value: "'Gamma'!A1" } ]); });