Fix lexer rejecting non-ASCII letters in identifiers (table/sheet names)

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 <noreply@anthropic.com>
This commit is contained in:
wkh
2026-09-09 23:54:18 +02:00
co-authored by Claude Sonnet 5
parent 06fea34f82
commit c26b1dd857
3 changed files with 27 additions and 1 deletions
+9
View File
@@ -62,6 +62,15 @@ describe('lexer', () => {
expect(values('$A$1 A$1 $A1')).toEqual(['$A$1', 'A$1', '$A1', '']); 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', () => { it('skips whitespace', () => {
expect(types(' A1\t+\n B2 ')).toEqual(['IDENT', 'PLUS', 'IDENT', 'EOF']); expect(types(' A1\t+\n B2 ')).toEqual(['IDENT', 'PLUS', 'IDENT', 'EOF']);
}); });
+11
View File
@@ -90,6 +90,17 @@ describe('Workbook cross-sheet', () => {
expect(val(wb, 's-kosten', 'A9')).toBe(800); expect(val(wb, 's-kosten', 'A9')).toBe(800);
expect(val(wb, 's-kosten', 'B9')).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', () => { describe('Workbook error values', () => {
+7 -1
View File
@@ -66,12 +66,18 @@ const SINGLE: Record<string, TokenType> = {
function isDigit(ch: string): boolean { function isDigit(ch: string): boolean {
return ch >= '0' && ch <= '9'; 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 { function isWordStart(ch: string): boolean {
// '$' only ever appears inside a cell reference ("$A1", "A$1", "$A$1"), but // '$' 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 // 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 // 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. // 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 { function isWordPart(ch: string): boolean {
return isWordStart(ch) || isDigit(ch); return isWordStart(ch) || isDigit(ch);