diff --git a/lib/fixRanges.spec.ts b/lib/fixRanges.spec.ts index e5c01c6..81e533d 100644 --- a/lib/fixRanges.spec.ts +++ b/lib/fixRanges.spec.ts @@ -73,6 +73,7 @@ describe('fixRanges prefixes', () => { isFixed('=[Foo]Ab12!B2', "='[Foo]Ab12'!B2"); isFixed('=ABC123!B2', "='ABC123'!B2"); isFixed('=abc123!B2', "='abc123'!B2"); + isFixed('=2020plan!B2', "='2020plan'!B2"); isFixed('=C!B2', "='C'!B2"); isFixed('=R!B2', "='R'!B2"); isFixed('=RC!B2', "='RC'!B2"); @@ -94,6 +95,7 @@ describe('fixRanges prefixes', () => { isFixed('=SUM(B:8!A1)', "=SUM('B:8'!A1)"); isFixed('=SUM(8:D!A1)', "=SUM('8:D'!A1)"); isFixed('=SUM(10:23!A1)', "=SUM('10:23'!A1)"); + isFixed('=SUM(2020plan:Mar!A1)', "=SUM('2020plan:Mar'!A1)"); isFixed('=A:B!A1', '=A:B!A1'); isFixed('=SUM(AA:AB!A1)', '=SUM(AA:AB!A1)'); isFixed('=SUM(A:AB!A1)', '=SUM(A:AB!A1)'); diff --git a/lib/lexers/lexContext.ts b/lib/lexers/lexContext.ts index 35a074e..3b3d9da 100644 --- a/lib/lexers/lexContext.ts +++ b/lib/lexers/lexContext.ts @@ -8,6 +8,24 @@ const EXCL = 33; // ! const COLON = 58; // : const PERIOD = 46; // . +// [0-9A-Za-z._¡¤§¨ª\u00ad¯-\uffff] +export function isUnquotedSheetNameChar (c: number): boolean { + return ( + (c >= 65 && c <= 90) || // A-Z + (c >= 97 && c <= 122) || // a-z + (c >= 48 && c <= 57) || // 0-9 + (c === 46) || // . + (c === 95) || // _ + (c === 161) || // ¡ + (c === 164) || // ¤ + (c === 167) || // § + (c === 168) || // ¨ + (c === 170) || // ª + (c === 173) || // \u00ad + (c >= 175) // ¯-\uffff + ); +} + // xlsx xml uses a variant of the syntax that has external references in // bracets. Any of: [1]Sheet1!A1, '[1]Sheet one'!A1, [1]!named export function lexContextQuoted (str: string, pos: number, options: { xlsx: boolean }): Token | undefined { @@ -87,27 +105,9 @@ export function lexContextUnquoted (str: string, pos: number, options: { xlsx: b } return undefined; } - else if ( - (br1 == null || br2 != null) && - // [0-9A-Za-z._¡¤§¨ª\u00ad¯-\uffff] - !( - (c >= 65 && c <= 90) || // A-Z - (c >= 97 && c <= 122) || // a-z - (c >= 48 && c <= 57) || // 0-9 - (c === 46) || // . - (c === 95) || // _ - (c === 161) || // ¡ - (c === 164) || // ¤ - (c === 167) || // § - (c === 168) || // ¨ - (c === 170) || // ª - (c === 173) || // \u00ad - (c >= 175) // ¯-\uffff - ) - ) { + else if ((br1 == null || br2 != null) && !isUnquotedSheetNameChar(c)) { return; } - // 0-9A-Za-z._¡¤§¨ª\u00ad¯-\uffff pos++; } } diff --git a/lib/lexers/lexNumber.ts b/lib/lexers/lexNumber.ts index b4490a6..7b9b858 100644 --- a/lib/lexers/lexNumber.ts +++ b/lib/lexers/lexNumber.ts @@ -1,5 +1,6 @@ import { NUMBER } from '../constants.ts'; import type { Token } from '../types.ts'; +import { isUnquotedSheetNameChar } from './lexContext.ts'; const EXCL = 33; // ! const COLON = 58; // : @@ -54,5 +55,19 @@ export function lexNumber (str: string, pos: number): Token | undefined { return; } + // A sheet name may begin with digits, so digits that run on into name characters and then + // reach a "!" or ":" are the start of a prefix rather than a number: the "2020plan" of + // 2020plan!A1, or of Jan:2020plan!A1. + if (!frac && isUnquotedSheetNameChar(tail)) { + let end = pos; + while (end < str.length && isUnquotedSheetNameChar(str.charCodeAt(end))) { + end++; + } + const after = str.charCodeAt(end); + if (after === EXCL || after === COLON) { + return; + } + } + return { type: NUMBER, value: str.slice(start, pos) }; } diff --git a/lib/tokenize.spec.ts b/lib/tokenize.spec.ts index bd825c3..a921ddd 100644 --- a/lib/tokenize.spec.ts +++ b/lib/tokenize.spec.ts @@ -558,6 +558,36 @@ describe('lexer', () => { } }); + test('a sheet name that begins with digits is one prefix, not a number and a name', () => { + // 2020plan is a sheet name, not the number 2020 and the name plan: Excel stores 2020plan!A1 + // quoted, as '2020plan'!A1. + isTokens('=2020plan!A1', [ + { type: FX_PREFIX, value: '=' }, + { type: REF_RANGE, value: '2020plan!A1' } + ]); + isTokens('=2020plan:Mar!A1', [ + { type: FX_PREFIX, value: '=' }, + { type: REF_RANGE, value: '2020plan:Mar!A1' } + ]); + // as the second name too; which side of the colon it lands on is the range operator's rule + expect(tokenize('=SUM(Jan:2020plan!A1)').filter(t => t.type === NUMBER)).toEqual([]); + // a number followed by a name, with no ! or : after it, is a number and a name + isTokens('=2020plan', [ + { type: FX_PREFIX, value: '=' }, + { type: NUMBER, value: '2020' }, + { type: REF_NAMED, value: 'plan' } + ]); + isTokens('=UNIQUE(1stLevel!H2:H)', [ + { type: FX_PREFIX, value: '=' }, + { type: FUNCTION, value: 'UNIQUE' }, + { type: OPERATOR, value: '(' }, + { type: REF_RANGE, value: '1stLevel!H2' }, + { type: OPERATOR, value: ':' }, + { type: REF_NAMED, value: 'H' }, + { type: OPERATOR, value: ')' } + ]); + }); + test('decimals with no fraction part', () => { isTokens('=1.*2', [ { type: FX_PREFIX, value: '=' },