From 9f4995b9c8497a44c9b7a5731ffde6d8e9572567 Mon Sep 17 00:00:00 2001 From: Gunnlaugur Thor Briem Date: Wed, 2 Sep 2026 15:46:36 +0000 Subject: [PATCH 1/3] fix: a digit-leading sheet name is lexed as a number and a name 2020plan!A1 lexed as the number 2020 followed by plan!A1, so a sheet whose name begins with digits was not read as a prefix at all. The number lexer already declines when a "!" or ":" follows the digits directly, which is what makes 1!A1 and 1:5!A1 work; it did not look past the name characters between them. Digits that run on into name characters and then reach a "!" or ":" are now left to the name and prefix lexers. A number followed by a name that is not a prefix is unaffected, so 2020plan on its own is still the number 2020 and the name plan. --- lib/fixRanges.spec.ts | 2 ++ lib/lexers/lexNumber.ts | 23 +++++++++++++++++++++++ lib/tokenize.spec.ts | 21 +++++++++++++++++++++ 3 files changed, 46 insertions(+) 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/lexNumber.ts b/lib/lexers/lexNumber.ts index b4490a6..8d403e0 100644 --- a/lib/lexers/lexNumber.ts +++ b/lib/lexers/lexNumber.ts @@ -16,6 +16,15 @@ function advDigits (str: string, pos: number): number { return pos - start; } +// [0-9A-Za-z._\u00a1\u00a4\u00a7\u00a8\u00aa\u00ad\u00af-\uffff], the characters that a sheet name may contain +function isNameChar (c: number): boolean { + return ( + (c >= 65 && c <= 90) || (c >= 97 && c <= 122) || (c >= 48 && c <= 57) || + c === 46 || c === 95 || c === 161 || c === 164 || c === 167 || c === 168 || + c === 170 || c === 173 || c >= 175 + ); +} + // (?:\d+(\.\d*)?|\.\d+)(?:[eE][+-]?\d+)? export function lexNumber (str: string, pos: number): Token | undefined { const start = pos; @@ -54,5 +63,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 && isNameChar(tail)) { + let end = pos; + while (end < str.length && isNameChar(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..7a87ae3 100644 --- a/lib/tokenize.spec.ts +++ b/lib/tokenize.spec.ts @@ -558,6 +558,27 @@ 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' } + ]); + }); + test('decimals with no fraction part', () => { isTokens('=1.*2', [ { type: FX_PREFIX, value: '=' }, From 4c2e14eeced5e5764687756d46253c639d3bb6a1 Mon Sep 17 00:00:00 2001 From: Gunnlaugur Thor Briem Date: Thu, 3 Sep 2026 10:25:17 +0000 Subject: [PATCH 2/3] Share the unquoted sheet-name character test between lexers lexContextUnquoted already tested the set of characters an unquoted sheet name may contain, inline. The number lexer's new check transcribed that set into its own isNameChar. Extract the test into isUnquotedSheetNameChar in lexContext.ts and use it from both, so the set is defined once. The name says sheet name rather than context because the test only ever applies to that part of a prefix: the bracketed workbook name is exempt from it, and a path cannot appear unquoted. --- lib/lexers/lexContext.ts | 38 +++++++++++++++++++------------------- lib/lexers/lexNumber.ts | 14 +++----------- 2 files changed, 22 insertions(+), 30 deletions(-) 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 8d403e0..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; // : @@ -16,15 +17,6 @@ function advDigits (str: string, pos: number): number { return pos - start; } -// [0-9A-Za-z._\u00a1\u00a4\u00a7\u00a8\u00aa\u00ad\u00af-\uffff], the characters that a sheet name may contain -function isNameChar (c: number): boolean { - return ( - (c >= 65 && c <= 90) || (c >= 97 && c <= 122) || (c >= 48 && c <= 57) || - c === 46 || c === 95 || c === 161 || c === 164 || c === 167 || c === 168 || - c === 170 || c === 173 || c >= 175 - ); -} - // (?:\d+(\.\d*)?|\.\d+)(?:[eE][+-]?\d+)? export function lexNumber (str: string, pos: number): Token | undefined { const start = pos; @@ -66,9 +58,9 @@ export function lexNumber (str: string, pos: number): Token | undefined { // 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 && isNameChar(tail)) { + if (!frac && isUnquotedSheetNameChar(tail)) { let end = pos; - while (end < str.length && isNameChar(str.charCodeAt(end))) { + while (end < str.length && isUnquotedSheetNameChar(str.charCodeAt(end))) { end++; } const after = str.charCodeAt(end); From ef9e8bdc8aaac24671856a949d331a555b64d03f Mon Sep 17 00:00:00 2001 From: Gunnlaugur Thor Briem Date: Mon, 7 Sep 2026 16:27:07 +0000 Subject: [PATCH 3/3] test: add real-world case from Google Sheets --- lib/tokenize.spec.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/lib/tokenize.spec.ts b/lib/tokenize.spec.ts index 7a87ae3..a921ddd 100644 --- a/lib/tokenize.spec.ts +++ b/lib/tokenize.spec.ts @@ -577,6 +577,15 @@ describe('lexer', () => { { 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', () => {