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 bd211db..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 ], @@ -193,6 +186,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 +215,11 @@ function commonMergeRefTokens (tokenlist: Token[], xlsx: boolean): Token[] { i -= valid - 1; } } + // 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 }; + } finalTokens[finalTokens.length] = token; } return finalTokens.reverse(); diff --git a/lib/parse.spec.ts b/lib/parse.spec.ts index d9deaa6..d0b335c 100644 --- a/lib/parse.spec.ts +++ b/lib/parse.spec.ts @@ -137,6 +137,28 @@ describe('parser', () => { isParsed("'A1:B2'!C3", { type: 'ReferenceIdentifier', value: "'A1:B2'!C3", kind: 'range' }); }); + // 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: ':', + 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' }); 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 51bbdf6..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', () => { @@ -132,7 +136,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 +204,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' } ]); @@ -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" } ]); }); @@ -317,8 +321,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 +386,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" } ]);