From c26b1dd8576232208c98b926dfc3789cdba8753d Mon Sep 17 00:00:00 2001 From: Wild Karl-Heinz Date: Wed, 9 Sep 2026 23:54:18 +0200 Subject: [PATCH] Fix lexer rejecting non-ASCII letters in identifiers (table/sheet names) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit isWordStart only accepted ASCII A-Z/a-z, so tokenizing any identifier containing an umlaut or other accented letter threw outright. Since a table/sheet name is used unquoted as a formula qualifier whenever it has no spaces, this silently made any such name unreferenceable from elsewhere -- including "Übersicht", the seed document's own demo table: =Übersicht!B2 written in a different table has always thrown, nothing in the existing test suite ever referenced it by name to catch it. Found via the new Name Box (jumpToNameInput): "Übersicht::B4" reported "invalid reference" even though the bare table name alone worked fine (that path never touches the parser). Fixed at the lexer level rather than working around it in the Name Box, since real formulas hit the same gap. isWordStart now accepts any Unicode letter (\p{L}), a strict superset of the old ASCII-only rule. Co-Authored-By: Claude Sonnet 5 --- src/engine/__tests__/lexer.spec.ts | 9 +++++++++ src/engine/__tests__/workbook.spec.ts | 11 +++++++++++ src/engine/lexer.ts | 8 +++++++- 3 files changed, 27 insertions(+), 1 deletion(-) diff --git a/src/engine/__tests__/lexer.spec.ts b/src/engine/__tests__/lexer.spec.ts index a70d420..8ad9da2 100644 --- a/src/engine/__tests__/lexer.spec.ts +++ b/src/engine/__tests__/lexer.spec.ts @@ -62,6 +62,15 @@ describe('lexer', () => { expect(values('$A$1 A$1 $A1')).toEqual(['$A$1', 'A$1', '$A1', '']); }); + it('reads identifiers with non-ASCII letters (umlauts etc.) as a single IDENT', () => { + // a table/sheet name routinely contains "ä"/"ö"/"ü"/"ß" in this + // German-first app -- these must lex as ordinary IDENT characters, not + // throw, so an unquoted qualifier like "Übersicht::A1" actually works. + expect(types('Übersicht::B4')).toEqual(['IDENT', 'DBLCOLON', 'IDENT', 'EOF']); + expect(values('Übersicht')).toEqual(['Übersicht', '']); + expect(values('Größe_Prämie')).toEqual(['Größe_Prämie', '']); + }); + it('skips whitespace', () => { expect(types(' A1\t+\n B2 ')).toEqual(['IDENT', 'PLUS', 'IDENT', 'EOF']); }); diff --git a/src/engine/__tests__/workbook.spec.ts b/src/engine/__tests__/workbook.spec.ts index 9ad1740..44203c1 100644 --- a/src/engine/__tests__/workbook.spec.ts +++ b/src/engine/__tests__/workbook.spec.ts @@ -90,6 +90,17 @@ describe('Workbook cross-sheet', () => { expect(val(wb, 's-kosten', 'A9')).toBe(800); expect(val(wb, 's-kosten', 'B9')).toBe(800); }); + + it('a table name with non-ASCII letters works as a bare, unquoted qualifier', () => { + // regression: "Übersicht" is the fixture's own table name, but nothing + // above ever refers *into* it by name (it only ever refers out) -- so + // this table has silently been unreferenceable from elsewhere the whole + // time (the lexer rejected "Ü" outright; see engine/lexer.ts). + const wb = fixture(); + wb.setCell('s-umsatz', 6, 8, '=Übersicht!B2'); // G9, no quoting needed (no spaces) + expect(val(wb, 's-umsatz', 'G9')).toBe(5970); + expect(isError(val(wb, 's-umsatz', 'G9'))).toBe(false); + }); }); describe('Workbook error values', () => { diff --git a/src/engine/lexer.ts b/src/engine/lexer.ts index c1d51fb..11e181d 100644 --- a/src/engine/lexer.ts +++ b/src/engine/lexer.ts @@ -66,12 +66,18 @@ const SINGLE: Record = { function isDigit(ch: string): boolean { return ch >= '0' && ch <= '9'; } +/** Any Unicode letter, not just ASCII -- a table/sheet name (used unquoted + * as a formula qualifier whenever it has no spaces) routinely contains + * "ä"/"ö"/"ü"/"ß" or other accented letters in this German-first app; + * rejecting those at the lexer level made every such name effectively + * unusable as a bare qualifier (`Übersicht::A1` used to throw outright). */ +const IS_LETTER = /\p{L}/u; function isWordStart(ch: string): boolean { // '$' only ever appears inside a cell reference ("$A1", "A$1", "$A$1"), but // the lexer doesn't distinguish those from any other bare word (see the // module doc) -- it's just part of the word here, and the parser is the one // that validates/interprets it as a locked reference axis. - return (ch >= 'A' && ch <= 'Z') || (ch >= 'a' && ch <= 'z') || ch === '_' || ch === '$'; + return IS_LETTER.test(ch) || ch === '_' || ch === '$'; } function isWordPart(ch: string): boolean { return isWordStart(ch) || isDigit(ch);