Add a sheet qualifier: "Blatt!Tabelle::Zelle", table names unique per sheet
Table names were document-unique; now they're unique per sheet only -- two different sheets may each hold a table called "Umsatz". A plain, unqualified reference (Umsatz!B2 / Umsatz::B2, unchanged from before) resolves to the table on the SAME sheet as the formula referencing it when the name is otherwise ambiguous, falling back to the single document-wide match when it isn't ambiguous at all -- exactly as before for every existing formula. The new explicit 3-part form (Analyse!Umsatz::B2, quoted where a name has spaces) always targets the named sheet's table regardless of where the formula itself lives, for the case that's still genuinely ambiguous. Engine: parser.ts's afterFirstQualifierToken() disambiguates via lookahead -- "X!Y::" (a name, then "::") is the new 3-part sheet+table form; a bare "X!Y" keeps its original single-qualifier meaning, so every formula written before this exists still parses identically. CellNode/RangeNode gain an optional sheetQualifier field. Workbook.resolveTableName(name, sheetName?) implements the resolution policy; TableModel gains sheetName. precedentsOf() now also sets homeTableId (previously only evaluateCell did), so graph edges use the same same-sheet-preference resolution an evaluation would. Store: table name uniqueness checks (addTable, renameObject) are now scoped to the target sheet instead of the whole document. Point-mode's qualifyRef() inserts the full "Blatt!Tabelle::" form only when the clicked table's name is genuinely ambiguous and lives on a different sheet than the editor; otherwise the simple form, unchanged. 200 tests passing (19 new), vue-tsc and eslint clean, verified live in the running app: renamed a Blatt-2 table to the same name as Analyse's seed "Umsatz", confirmed an unqualified reference resolves per-sheet, an explicit sheet-qualified one always targets the named sheet, and the original sheet's own formulas are unaffected. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
+48
-4
@@ -4,8 +4,11 @@ Selbst geschriebene, framework-agnostische Formel-Engine. Kein HyperFormula, kei
|
||||
`formula-parser`, keine Spreadsheet-Library. Reines ES-Modul, in Vitest getestet.
|
||||
|
||||
Die adressierbare Einheit ist eine **Tabelle** – ein Tabellen-Objekt auf einer
|
||||
Numbers-artigen Blattfläche. Tabellennamen sind dokumentweit eindeutig, deshalb
|
||||
löst `Umsatz!B2` (bzw. `Umsatz::B2`) von jedem Blatt aus identisch auf.
|
||||
Numbers-artigen Blattfläche. Tabellennamen sind nur **pro Blatt** eindeutig
|
||||
(zwei Blätter dürfen je eine Tabelle "Umsatz" haben) – `Umsatz!B2` (bzw.
|
||||
`Umsatz::B2`) löst dann zur Tabelle auf dem **gleichen Blatt wie die Formel**
|
||||
auf, sofern eindeutig; die explizite Form `Blatt!Tabelle::B2` trifft immer
|
||||
genau die genannte Tabelle. Siehe "Blatt-Qualifier" weiter unten.
|
||||
|
||||
## Module
|
||||
|
||||
@@ -45,10 +48,18 @@ primary = NUMBER
|
||||
|
||||
funcall = IDENT "(" [ expression { ";" expression } ] ")" ;
|
||||
|
||||
reference = [ qualifier ] CELL [ ":" CELL ] ;
|
||||
reference = [ sheetQualifier ] [ qualifier ] CELL [ ":" CELL ] ;
|
||||
qualifier = (IDENT | QUOTED) ("!" | "::") ; (* Tabellenname; "!" und der
|
||||
Numbers-Stil "::" sind
|
||||
austauschbar *)
|
||||
sheetQualifier = (IDENT | QUOTED) "!" ; (* Blattname -- nur erkannt,
|
||||
wenn direkt gefolgt von
|
||||
vollem "Tabelle::" (die
|
||||
3-teilige Form); ein
|
||||
einzelnes "X!Y" bleibt
|
||||
die alte, rückwärts-
|
||||
kompatible 2-teilige
|
||||
Form (X = Tabelle) *)
|
||||
|
||||
NUMBER = /[0-9]+ ("." [0-9]+)? ([eE] [+-]? [0-9]+)?/ | /"." [0-9]+/ ;
|
||||
STRING = /" ... "/ (Backslash-Escapes) ;
|
||||
@@ -81,6 +92,39 @@ eine Zelle aussieht) zusätzlich geprüft wird — ein Treffer erzeugt statt ein
|
||||
verwendet (`userFunctions.ts`); ohne das Argument ist das Verhalten exakt wie
|
||||
zuvor, jeder bare Bezeichner ohne Zellform bleibt ein `ParseError`.
|
||||
|
||||
## Blatt-Qualifier ("Blatt!Tabelle::Zelle")
|
||||
|
||||
`parser.ts#Parser.afterFirstQualifierToken` entscheidet per Lookahead, ob ein
|
||||
`!` einen (alten) Tabellen-Qualifier einleitet oder einen **Blatt**-Qualifier:
|
||||
nur wenn direkt danach `IDENT/QUOTED "::"` folgt, ist es ein Blattname und der
|
||||
Parser konsumiert drei Segmente (`Blatt`, `Tabelle`, `Zelle`) statt zwei —
|
||||
sonst bleibt die alte, einstufige Bedeutung (`X!Y` = Tabelle `X`, Zelle `Y`)
|
||||
exakt erhalten. `CellNode`/`RangeNode` bekommen dafür ein optionales
|
||||
`sheetQualifier?: string`-Feld neben dem bestehenden `qualifier`.
|
||||
|
||||
Die Auflösung passiert in `workbook.ts#Workbook.resolveTableName(name,
|
||||
sheetName?)` (Teil von `EngineHost`):
|
||||
|
||||
- `sheetName` gegeben (3-teilige Form) → nur eine Tabelle mit diesem Namen
|
||||
**auf diesem Blatt** trifft; sonst `null` (→ `#REF`).
|
||||
- `sheetName` fehlt und der Tabellenname ist **eindeutig** im Dokument → wie
|
||||
bisher, unverändert.
|
||||
- `sheetName` fehlt und der Name kommt auf **mehreren** Blättern vor →
|
||||
bevorzugt die Tabelle auf demselben Blatt wie `this.homeTableId` (dafür
|
||||
setzen sowohl `evaluateCell` als auch `precedentsOf` `homeTableId`, bevor
|
||||
sie referenzieren — Letzteres, damit auch die **Graph-Kanten** beim Parsen
|
||||
schon dieselbe Auflösung wie später die Auswertung verwenden). Bleibt es
|
||||
danach mehrdeutig: `null` (→ `#REF`).
|
||||
|
||||
`TableModel` (workbook.ts) trägt dafür zusätzlich `sheetName: string`. Die
|
||||
Store-Seite (`document.ts#toTableModels`) befüllt es aus dem Blatt, dem die
|
||||
Tabelle gehört; `tableNamesOnSheet(sheetId)`/`objectNamesOnSheet(sheetId)`
|
||||
ersetzen dort die früher dokumentweiten Eindeutigkeits-Prüfungen beim Anlegen/
|
||||
Umbenennen einer Tabelle. `qualifyRef()` (Point-Mode) fügt die volle
|
||||
3-teilige Form nur dann ein, wenn der angeklickte Tabellenname wirklich
|
||||
mehrdeutig **und** auf einem anderen Blatt als der Editor liegt — sonst reicht
|
||||
weiterhin die einfache Form.
|
||||
|
||||
## Benutzerdefinierte Funktionen
|
||||
|
||||
Kein Laufzeit-Closure, keine Änderung an `evaluator.ts`/`graph.ts`: ein Aufruf
|
||||
@@ -132,7 +176,7 @@ rufen `rebuild()` für einen vollständigen – trotzdem billigen – Durchlauf.
|
||||
|
||||
| Wert | Bedeutung |
|
||||
|------------|-----------|
|
||||
| `#REF` | ungültiger/gelöschter Bezug **oder** unbekannter Tabellenname |
|
||||
| `#REF` | ungültiger/gelöschter Bezug, unbekannter Tabellen-/Blattname, **oder** ein unqualifizierter Tabellenname, der auf mehreren Blättern existiert und auch über die "gleiches Blatt wie die Formel"-Regel nicht eindeutig wird |
|
||||
| `#DIV/0!` | Division durch 0 (auch `AVG` ohne Zahlen) |
|
||||
| `#NAME?` | unbekannte Funktion (eingebaut oder benutzerdefiniert) |
|
||||
| `#ERR` | Parse-Fehler (`=A1+`, `=SUM(`); auch Arity-Mismatch oder Expansionstiefen-Limit bei einer benutzerdefinierten Funktion |
|
||||
|
||||
@@ -15,9 +15,9 @@ function sexpr(n: Node): string {
|
||||
case 'func':
|
||||
return `(${n.name} ${n.args.map(sexpr).join(' ')})`;
|
||||
case 'cell':
|
||||
return `${n.qualifier ? n.qualifier + '!' : ''}cell(${n.col},${n.row})`;
|
||||
return `${n.sheetQualifier ? n.sheetQualifier + '!' : ''}${n.qualifier ? n.qualifier + '!' : ''}cell(${n.col},${n.row})`;
|
||||
case 'range':
|
||||
return `${n.qualifier ? n.qualifier + '!' : ''}range(${n.start.col},${n.start.row}:${n.end.col},${n.end.row})`;
|
||||
return `${n.sheetQualifier ? n.sheetQualifier + '!' : ''}${n.qualifier ? n.qualifier + '!' : ''}range(${n.start.col},${n.start.row}:${n.end.col},${n.end.row})`;
|
||||
case 'param':
|
||||
return `param(${n.name})`;
|
||||
}
|
||||
@@ -89,6 +89,41 @@ describe('parser references', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('parser sheet qualifier -- "Blatt!Tabelle::Zelle"', () => {
|
||||
it('parses the explicit 3-part form, bare and quoted', () => {
|
||||
expect(parse('=Analyse!Umsatz::B2')).toBe('Analyse!Umsatz!cell(1,1)');
|
||||
expect(parse("='Meine Analyse'!Umsatz::B2")).toBe('Meine Analyse!Umsatz!cell(1,1)');
|
||||
expect(parse("=Analyse!'Kosten Q3'::B2:E2")).toBe('Analyse!Kosten Q3!range(1,1:4,1)');
|
||||
expect(parse("='Meine Analyse'!'Kosten Q3'::B2")).toBe('Meine Analyse!Kosten Q3!cell(1,1)');
|
||||
});
|
||||
|
||||
it('is unaffected in a range end / function call', () => {
|
||||
expect(parse('=SUM(Analyse!Umsatz::B2:E2; 1)')).toBe('(SUM Analyse!Umsatz!range(1,1:4,1) 1)');
|
||||
});
|
||||
|
||||
it('a bare "X!Y" (no further "::") keeps its original, table-only meaning -- fully backward-compatible', () => {
|
||||
// no "::" ever follows -> "Analyse" is read as the TABLE, "A1" the cell,
|
||||
// exactly as parseFormula() behaved before the sheet qualifier existed.
|
||||
expect(parse('=Analyse!A1')).toBe('Analyse!cell(0,0)');
|
||||
expect(parse('=Umsatz!B2')).toBe(parse('=Umsatz!B2')); // unchanged from the existing suite
|
||||
});
|
||||
|
||||
it('"X::Y" (no leading sheet) is still the plain table qualifier, never a sheet one', () => {
|
||||
expect(parse('=Umsatz::B2')).toBe('Umsatz!cell(1,1)');
|
||||
});
|
||||
|
||||
it('"X!Y!Z" (a second "!" instead of "::") does not form a sheet qualifier -- "!" only introduces a sheet name when followed by NAME "::"', () => {
|
||||
// no "::" anywhere ahead of "Y" -> "X!" is read as an ordinary (old-style)
|
||||
// table qualifier, leaving "Y" to be parsed as the cell -- which it isn't
|
||||
// shaped like (no digits) -> ParseError, same as e.g. "=X!Y" always was.
|
||||
expect(() => parseFormula('=X!Y!Z1')).toThrow(ParseError);
|
||||
});
|
||||
|
||||
it('rejects a malformed 3-part reference (missing cell after "::")', () => {
|
||||
expect(() => parseFormula('=Analyse!Umsatz::')).toThrow(ParseError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseFormula(src, paramNames) -- user-defined function bodies', () => {
|
||||
it('a name in paramNames parses as a param reference, case-insensitively', () => {
|
||||
expect(parse2('=revenue - cost', ['revenue', 'cost'])).toBe('(- param(revenue) param(cost))');
|
||||
|
||||
@@ -10,7 +10,12 @@ import { Workbook, type TableModel } from '../workbook';
|
||||
import { FormulaError, isError, keyOf } from '../types';
|
||||
|
||||
// same small A1 helper used by workbook.spec.ts
|
||||
function sheet(id: string, name: string, cells: Record<string, string>): TableModel {
|
||||
function sheet(
|
||||
id: string,
|
||||
name: string,
|
||||
cells: Record<string, string>,
|
||||
sheetName = 'Blatt1',
|
||||
): TableModel {
|
||||
const inputs = new Map<string, string>();
|
||||
for (const [a1, raw] of Object.entries(cells)) {
|
||||
const m = /^([A-Z]+)([0-9]+)$/.exec(a1)!;
|
||||
@@ -18,7 +23,7 @@ function sheet(id: string, name: string, cells: Record<string, string>): TableMo
|
||||
const row = Number(m[2]) - 1;
|
||||
inputs.set(`${col},${row}`, raw);
|
||||
}
|
||||
return { id, name, cols: 8, rows: 30, inputs };
|
||||
return { id, name, sheetName, cols: 8, rows: 30, inputs };
|
||||
}
|
||||
const val = (wb: Workbook, sheetId: string, a1: string) => {
|
||||
const m = /^([A-Z]+)([0-9]+)$/.exec(a1)!;
|
||||
|
||||
@@ -2,7 +2,12 @@ import { describe, it, expect } from 'vitest';
|
||||
import { Workbook, type TableModel } from '../workbook';
|
||||
import { FormulaError, isError, keyOf } from '../types';
|
||||
|
||||
function sheet(id: string, name: string, cells: Record<string, string>): TableModel {
|
||||
function sheet(
|
||||
id: string,
|
||||
name: string,
|
||||
cells: Record<string, string>,
|
||||
sheetName = 'Blatt1',
|
||||
): TableModel {
|
||||
const inputs = new Map<string, string>();
|
||||
for (const [a1, raw] of Object.entries(cells)) {
|
||||
const m = /^([A-Z]+)([0-9]+)$/.exec(a1)!;
|
||||
@@ -10,7 +15,7 @@ function sheet(id: string, name: string, cells: Record<string, string>): TableMo
|
||||
const row = Number(m[2]) - 1;
|
||||
inputs.set(`${col},${row}`, raw);
|
||||
}
|
||||
return { id, name, cols: 8, rows: 30, inputs };
|
||||
return { id, name, sheetName, cols: 8, rows: 30, inputs };
|
||||
}
|
||||
|
||||
/** Rebuilds the small "Umsatz / Kosten / Übersicht" fixture from the brief. */
|
||||
@@ -144,3 +149,77 @@ describe('Workbook recalculation order', () => {
|
||||
expect(pos(keyOf('s-ueber', 3, 1))).toBeLessThan(pos(keyOf('s-ueber', 4, 1)));
|
||||
});
|
||||
});
|
||||
|
||||
describe('Workbook sheet qualifier ("Blatt!Tabelle::Zelle") -- table names unique per sheet only', () => {
|
||||
/** Two sheets ("Analyse", "Zweitblatt") each hold their own table called
|
||||
* "Umsatz" -- the same table NAME on two different sheets, only possible
|
||||
* now that table-name uniqueness is per-sheet rather than document-wide. */
|
||||
function multiSheetFixture(): Workbook {
|
||||
const analyseUmsatz = sheet('s-a-umsatz', 'Umsatz', { B2: '100' }, 'Analyse');
|
||||
const analyseOther = sheet(
|
||||
's-a-other',
|
||||
'Auswertung',
|
||||
{
|
||||
B2: '=Umsatz!B2', // ambiguous name, home sheet is Analyse -> Analyse's own Umsatz
|
||||
B3: '=Analyse!Umsatz::B2', // explicit sheet qualifier
|
||||
B4: '=Zweitblatt!Umsatz::B2', // explicit sheet qualifier, the OTHER sheet's Umsatz
|
||||
},
|
||||
'Analyse',
|
||||
);
|
||||
const zweitUmsatz = sheet('s-b-umsatz', 'Umsatz', { B2: '999' }, 'Zweitblatt');
|
||||
const zweitOther = sheet(
|
||||
's-b-other',
|
||||
'Check',
|
||||
{ B2: '=Umsatz!B2' }, // ambiguous name, home sheet is Zweitblatt -> Zweitblatt's own Umsatz
|
||||
'Zweitblatt',
|
||||
);
|
||||
const drittOther = sheet(
|
||||
's-c-other',
|
||||
'Fremd',
|
||||
{
|
||||
B2: '=Umsatz!B2', // ambiguous, no same-sheet "Umsatz" on Dritt -> #REF
|
||||
B3: '=Auswertung!B2', // "Auswertung" itself is NOT ambiguous (only on Analyse) -> resolves from anywhere
|
||||
},
|
||||
'Dritt',
|
||||
);
|
||||
return new Workbook([analyseUmsatz, analyseOther, zweitUmsatz, zweitOther, drittOther]);
|
||||
}
|
||||
|
||||
it('an unqualified, otherwise-ambiguous name resolves to the table on the SAME sheet as the formula', () => {
|
||||
const wb = multiSheetFixture();
|
||||
expect(val(wb, 's-a-other', 'B2')).toBe(100);
|
||||
expect(val(wb, 's-b-other', 'B2')).toBe(999);
|
||||
});
|
||||
|
||||
it('an explicit "Blatt!Tabelle::Zelle" always resolves to that sheet\'s table, regardless of home sheet', () => {
|
||||
const wb = multiSheetFixture();
|
||||
expect(val(wb, 's-a-other', 'B3')).toBe(100);
|
||||
expect(val(wb, 's-a-other', 'B4')).toBe(999);
|
||||
});
|
||||
|
||||
it('an unqualified ambiguous name with no same-sheet match is #REF', () => {
|
||||
const wb = multiSheetFixture();
|
||||
expect((val(wb, 's-c-other', 'B2') as FormulaError).code).toBe('#REF');
|
||||
});
|
||||
|
||||
it('a non-ambiguous (single-match) name still resolves without any sheet qualifier, from any sheet', () => {
|
||||
const wb = multiSheetFixture();
|
||||
expect(val(wb, 's-c-other', 'B3')).toBe(100); // Auswertung!B2, transitively Analyse's Umsatz!B2
|
||||
});
|
||||
|
||||
it('recalculates correctly through a same-sheet-preferred resolution when the source cell changes', () => {
|
||||
const wb = multiSheetFixture();
|
||||
wb.setCell('s-a-umsatz', 1, 1, '250'); // Analyse's Umsatz B2: 100 -> 250
|
||||
expect(val(wb, 's-a-other', 'B2')).toBe(250);
|
||||
expect(val(wb, 's-a-other', 'B3')).toBe(250); // Auswertung!B2 chained through
|
||||
expect(val(wb, 's-b-other', 'B2')).toBe(999); // Zweitblatt's own Umsatz, unaffected
|
||||
});
|
||||
|
||||
it('an explicit sheet qualifier for an unknown sheet or table is #REF', () => {
|
||||
const wb2 = new Workbook([
|
||||
sheet('s-x', 'X', { B2: '=Nirgendwo!Umsatz::A1' }, 'Analyse'),
|
||||
sheet('s-a-umsatz', 'Umsatz', { A1: '5' }, 'Analyse'),
|
||||
]);
|
||||
expect((val(wb2, 's-x', 'B2') as FormulaError).code).toBe('#REF');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -67,7 +67,9 @@ function evalScalar(node: Node, host: EngineHost): CellValue {
|
||||
|
||||
case 'cell': {
|
||||
const tableId =
|
||||
node.qualifier == null ? host.homeTableId : host.resolveTableName(node.qualifier);
|
||||
node.qualifier == null
|
||||
? host.homeTableId
|
||||
: host.resolveTableName(node.qualifier, node.sheetQualifier ?? null);
|
||||
if (!tableId) return new FormulaError('#REF', `unknown table '${node.qualifier}'`);
|
||||
return host.getComputed(tableId, node.col, node.row);
|
||||
}
|
||||
@@ -104,7 +106,9 @@ function evalScalar(node: Node, host: EngineHost): CellValue {
|
||||
function evalArg(node: Node, host: EngineHost): FormulaError | CellValue[] {
|
||||
if (node.kind === 'range') {
|
||||
const tableId =
|
||||
node.qualifier == null ? host.homeTableId : host.resolveTableName(node.qualifier);
|
||||
node.qualifier == null
|
||||
? host.homeTableId
|
||||
: host.resolveTableName(node.qualifier, node.sheetQualifier ?? null);
|
||||
if (!tableId) return new FormulaError('#REF', `unknown table '${node.qualifier}'`);
|
||||
return expandRange(node.start, node.end).map((c) => host.getComputed(tableId, c.col, c.row));
|
||||
}
|
||||
|
||||
+67
-21
@@ -22,10 +22,22 @@
|
||||
*
|
||||
* funcall = IDENT "(" [ expression { ";" expression } ] ")" ;
|
||||
*
|
||||
* reference = [ qualifier ] CELL [ ":" CELL ] ;
|
||||
* reference = [ sheetQualifier ] [ qualifier ] CELL [ ":" CELL ] ;
|
||||
* qualifier = (IDENT | QUOTED) ("!" | "::") ; (* table name; "!" and the
|
||||
* Numbers-style "::" are
|
||||
* interchangeable *)
|
||||
* sheetQualifier = (IDENT | QUOTED) "!" ; (* sheet name -- only
|
||||
* recognised when it is
|
||||
* immediately followed by
|
||||
* a full "table::" --
|
||||
* i.e. only in the 3-part
|
||||
* form "Blatt!Tabelle::A1".
|
||||
* A bare "X!Y" with no
|
||||
* "::" after Y keeps its
|
||||
* original meaning: X is
|
||||
* the TABLE qualifier
|
||||
* (backward-compatible --
|
||||
* see reference() below). *)
|
||||
* CELL = /\$?[A-Za-z]{1,3}\$?[0-9]{1,7}/ ;
|
||||
*
|
||||
* Precedence, lowest to highest: + - < * / < unary +/- < ^
|
||||
@@ -82,6 +94,11 @@ export interface CellNode {
|
||||
kind: 'cell';
|
||||
/** raw table name as written ("Umsatz"), or null for "this table" */
|
||||
qualifier: string | null;
|
||||
/** raw sheet name as written ("Analyse") -- only set for the explicit
|
||||
* "Blatt!Tabelle::Zelle" 3-part form; absent for every plain
|
||||
* "Tabelle!Zelle" / "Tabelle::Zelle" reference (resolved without regard
|
||||
* to sheet, same as before this existed). */
|
||||
sheetQualifier?: string;
|
||||
col: number;
|
||||
row: number;
|
||||
}
|
||||
@@ -89,6 +106,8 @@ export interface CellNode {
|
||||
export interface RangeNode {
|
||||
kind: 'range';
|
||||
qualifier: string | null;
|
||||
/** see {@link CellNode.sheetQualifier}. */
|
||||
sheetQualifier?: string;
|
||||
start: { col: number; row: number };
|
||||
end: { col: number; row: number };
|
||||
}
|
||||
@@ -190,10 +209,11 @@ class Parser {
|
||||
return inner;
|
||||
}
|
||||
if (t.type === 'QUOTED') {
|
||||
// must be a table-qualified reference: 'Kosten Q3'!B2 or 'Kosten Q3'::B2
|
||||
// must be a qualified reference -- afterFirstQualifierToken() figures
|
||||
// out whether it's a table qualifier ('Kosten Q3'!B2) or a sheet
|
||||
// qualifier starting a 3-part ('Analyse'!Umsatz::B2) reference.
|
||||
this.next();
|
||||
this.expectQualifierSep();
|
||||
return this.reference(t.value);
|
||||
return this.afterFirstQualifierToken(t.value);
|
||||
}
|
||||
if (t.type === 'IDENT') {
|
||||
this.next();
|
||||
@@ -211,23 +231,58 @@ class Parser {
|
||||
this.expect('RPAREN');
|
||||
return { kind: 'func', name: t.value, args };
|
||||
}
|
||||
// table-qualified reference? IDENT ("!" | "::") ...
|
||||
// qualified reference? IDENT ("!" | "::") ...
|
||||
if (this.at('BANG') || this.at('DBLCOLON')) {
|
||||
this.next();
|
||||
return this.reference(t.value);
|
||||
return this.afterFirstQualifierToken(t.value);
|
||||
}
|
||||
// otherwise it must be a local cell reference
|
||||
return this.reference(null, t);
|
||||
return this.reference(null, null, t);
|
||||
}
|
||||
|
||||
throw new ParseError(`Unexpected token ${t.type} at ${t.pos}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Called right after consuming the first IDENT/QUOTED of what is *some*
|
||||
* kind of qualified reference (current token is known to be "!" or "::").
|
||||
* Disambiguates the two qualifier grammars:
|
||||
* - "X::..." or a bare "X!..." (not followed by another name + "::") is
|
||||
* the original, single-level TABLE qualifier -- unchanged from before
|
||||
* this existed, so every formula written before the sheet qualifier
|
||||
* was added still parses exactly the same.
|
||||
* - "X!Y::..." -- when "!" is immediately followed by another
|
||||
* IDENT/QUOTED and *that* is followed by "::" -- is the 3-part form:
|
||||
* X is the SHEET, Y is the TABLE.
|
||||
*/
|
||||
private afterFirstQualifierToken(first: string): Node {
|
||||
if (this.at('BANG') && this.looksLikeSheetThenTable()) {
|
||||
this.next(); // consume "!"
|
||||
const tableTok = this.next(); // IDENT or QUOTED, guaranteed by the lookahead above
|
||||
this.next(); // consume "::"
|
||||
return this.reference(tableTok.value, first);
|
||||
}
|
||||
if (this.at('BANG') || this.at('DBLCOLON')) {
|
||||
this.next();
|
||||
return this.reference(first, null);
|
||||
}
|
||||
throw new ParseError(`Expected '!' or '::' but got ${this.peek().type} at ${this.peek().pos}`);
|
||||
}
|
||||
|
||||
/** True iff, with the current token being "!", the shape ahead is
|
||||
* IDENT/QUOTED "::" -- i.e. this "!" really introduces a SHEET name
|
||||
* followed by a table qualifier, not a bare (old-style) table qualifier
|
||||
* of its own. Pure lookahead, consumes nothing. */
|
||||
private looksLikeSheetThenTable(): boolean {
|
||||
const n1 = this.toks[this.i + 1];
|
||||
const n2 = this.toks[this.i + 2];
|
||||
return !!n1 && (n1.type === 'IDENT' || n1.type === 'QUOTED') && !!n2 && n2.type === 'DBLCOLON';
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the cell/range part of a reference. `firstTok` (when given) is an
|
||||
* IDENT already consumed by the caller for the *local* case.
|
||||
*/
|
||||
private reference(qualifier: string | null, firstTok?: Token): Node {
|
||||
private reference(qualifier: string | null, sheetQualifier: string | null, firstTok?: Token): Node {
|
||||
const a = firstTok ?? this.expectCellIdent();
|
||||
if (!firstTok && a.type !== 'IDENT') {
|
||||
throw new ParseError(`Expected cell reference at ${a.pos}`);
|
||||
@@ -243,6 +298,7 @@ class Parser {
|
||||
throw new ParseError(`'${a.value}' is not a valid cell reference (pos ${a.pos})`);
|
||||
}
|
||||
const start = parseA1(a.value);
|
||||
const sheetField = sheetQualifier === null ? {} : { sheetQualifier };
|
||||
|
||||
if (this.at('COLON')) {
|
||||
this.next();
|
||||
@@ -251,19 +307,9 @@ class Parser {
|
||||
throw new ParseError(`'${b.value}' is not a valid cell reference (pos ${b.pos})`);
|
||||
}
|
||||
const end = parseA1(b.value);
|
||||
return { kind: 'range', qualifier, start, end };
|
||||
return { kind: 'range', qualifier, ...sheetField, start, end };
|
||||
}
|
||||
return { kind: 'cell', qualifier, col: start.col, row: start.row };
|
||||
}
|
||||
|
||||
private expectQualifierSep(): void {
|
||||
if (this.at('BANG') || this.at('DBLCOLON')) {
|
||||
this.next();
|
||||
return;
|
||||
}
|
||||
throw new ParseError(
|
||||
`Expected '!' or '::' but got ${this.peek().type} at ${this.peek().pos}`,
|
||||
);
|
||||
return { kind: 'cell', qualifier, ...sheetField, col: start.col, row: start.row };
|
||||
}
|
||||
|
||||
private expectCellIdent(): Token {
|
||||
|
||||
+21
-7
@@ -70,19 +70,33 @@ export function parseKey(key: string): CellRef {
|
||||
* The document as seen by the engine. Implemented by the Vue store.
|
||||
*
|
||||
* The addressable unit is a **table** (a table object on a Numbers-style
|
||||
* canvas). Table names are unique across the whole document, so a qualified
|
||||
* reference like `Umsatz!B2` / `Umsatz::B2` resolves the same no matter which
|
||||
* sheet the formula or the target table sits on.
|
||||
* canvas). Table names are only unique *per sheet* -- two different sheets
|
||||
* may each hold a table called "Umsatz". An unqualified table reference
|
||||
* (`Umsatz!B2` / `Umsatz::B2`, no sheet given) resolves to whichever table
|
||||
* named "Umsatz" lives on the *same sheet* as the formula doing the
|
||||
* referencing, falling back to the single document-wide match when the name
|
||||
* isn't ambiguous there. The explicit 3-part form (`Analyse!Umsatz::B2`)
|
||||
* disambiguates when it would otherwise be genuinely ambiguous (name used on
|
||||
* two *other* sheets, neither of them the formula's own).
|
||||
*/
|
||||
export interface EngineHost {
|
||||
/** Id of the table the formula currently being evaluated lives on. */
|
||||
homeTableId: string;
|
||||
/**
|
||||
* Resolve a table *name* (as written in a formula) to its id. Matching is
|
||||
* case-insensitive and tolerant of surrounding whitespace. Returns `null`
|
||||
* for an unknown / deleted table.
|
||||
* Resolve a table *name* (as written in a formula) to its id, optionally
|
||||
* narrowed to a specific *sheet* name (the "Blatt!" part of a 3-part
|
||||
* `Blatt!Tabelle::Zelle` reference). Matching is case-insensitive and
|
||||
* tolerant of surrounding whitespace.
|
||||
*
|
||||
* - `sheetName` given: only a table with that name **on that sheet**
|
||||
* matches; otherwise `null` (including when the sheet itself is unknown).
|
||||
* - `sheetName` omitted/`null`: matches a table by name anywhere in the
|
||||
* document. Exactly one match resolves as before. Multiple matches (the
|
||||
* name exists on more than one sheet) prefer a table on the same sheet
|
||||
* as {@link homeTableId}; if that still doesn't narrow it to one, `null`
|
||||
* (`#REF` -- the formula needs an explicit sheet qualifier).
|
||||
*/
|
||||
resolveTableName(name: string): string | null;
|
||||
resolveTableName(name: string, sheetName?: string | null): string | null;
|
||||
/** Dimensions of a table, used for range clamping and #REF detection. */
|
||||
tableSize(tableId: string): { cols: number; rows: number } | null;
|
||||
/**
|
||||
|
||||
+30
-5
@@ -35,8 +35,12 @@ import { evaluate } from './evaluator';
|
||||
|
||||
export interface TableModel {
|
||||
id: string;
|
||||
/** document-unique table name, used as the qualifier in formulas */
|
||||
/** table name, used as the qualifier in formulas -- unique *per sheet*
|
||||
* only; the same name may occur on more than one sheet (see
|
||||
* `sheetName` and {@link EngineHost.resolveTableName}). */
|
||||
name: string;
|
||||
/** name of the sheet this table lives on. */
|
||||
sheetName: string;
|
||||
cols: number;
|
||||
rows: number;
|
||||
/** key `${col},${row}` -> raw user input. Absent = empty cell. */
|
||||
@@ -78,10 +82,26 @@ export class Workbook implements EngineHost {
|
||||
|
||||
// ---- EngineHost ---------------------------------------------------------
|
||||
|
||||
resolveTableName(name: string): string | null {
|
||||
resolveTableName(name: string, sheetName?: string | null): string | null {
|
||||
const needle = name.trim().toLowerCase();
|
||||
const hit = this.tables.find((t) => t.name.trim().toLowerCase() === needle);
|
||||
return hit ? hit.id : null;
|
||||
const candidates = this.tables.filter((t) => t.name.trim().toLowerCase() === needle);
|
||||
if (candidates.length === 0) return null;
|
||||
|
||||
if (sheetName != null) {
|
||||
const sheetNeedle = sheetName.trim().toLowerCase();
|
||||
const hit = candidates.find((t) => t.sheetName.trim().toLowerCase() === sheetNeedle);
|
||||
return hit ? hit.id : null;
|
||||
}
|
||||
if (candidates.length === 1) return candidates[0].id;
|
||||
|
||||
// ambiguous (same name on more than one sheet) -- prefer a table on the
|
||||
// same sheet as the formula doing the referencing, if there is one
|
||||
const home = this.tableById(this.homeTableId);
|
||||
if (home) {
|
||||
const sameSheet = candidates.find((t) => t.sheetName === home.sheetName);
|
||||
if (sameSheet) return sameSheet.id;
|
||||
}
|
||||
return null; // still ambiguous -> #REF, needs an explicit sheet qualifier
|
||||
}
|
||||
|
||||
tableSize(tableId: string): { cols: number; rows: number } | null {
|
||||
@@ -167,9 +187,14 @@ export class Workbook implements EngineHost {
|
||||
|
||||
/** Resolve every reference in `ast` to concrete cell keys (home = tableId). */
|
||||
private precedentsOf(ast: Node, tableId: string): string[] {
|
||||
// resolveTableName()'s "prefer the home sheet" ambiguity fallback reads
|
||||
// this.homeTableId -- set it here too (not just in evaluateCell) so graph
|
||||
// edges are wired using the *same* resolution an evaluation would use.
|
||||
this.homeTableId = tableId;
|
||||
const out: string[] = [];
|
||||
for (const ref of collectRefs(ast)) {
|
||||
const target = ref.qualifier == null ? tableId : this.resolveTableName(ref.qualifier);
|
||||
const target =
|
||||
ref.qualifier == null ? tableId : this.resolveTableName(ref.qualifier, ref.sheetQualifier ?? null);
|
||||
if (!target) continue; // unknown table -> no edge; evaluator emits #REF
|
||||
if (ref.kind === 'cell') {
|
||||
out.push(keyOf(target, ref.col, ref.row));
|
||||
|
||||
Reference in New Issue
Block a user