diff --git a/README.md b/README.md index 75f07bf..da2bfc2 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ Eine browserbasierte Tabellenkalkulation nach dem Vorbild von **Apple Numbers**. ```bash npm install npm run dev # Vite-Devserver -npm run test # Vitest-Suite (Engine + Store + IO), 553 Tests +npm run test # Vitest-Suite (Engine + Store + IO), 566 Tests npm run build # Typecheck + Produktionsbuild npm run lint # ESLint (flat config) ``` @@ -744,7 +744,11 @@ namedref = IDENT ; (* ein unqualifizierter, außerhalb eines Funktionskörpers *) -reference = [ sheetQualifier ] [ qualifier ] CELL [ ":" CELL ] ; +reference = [ sheetQualifier ] [ qualifier ] + ( CELL [ ":" CELL ] | COLPART ":" COLPART | ROWPART ":" ROWPART ) ; + (* B:B / A:C = ganze + Spalten, 1:1 / 2:5 = + ganze Zeilen, s. u. *) qualifier = (IDENT | QUOTED) ("!" | "::") ; (* Tabellenname *) sheetQualifier = (IDENT | QUOTED) "!" ; (* Blattname -- nur erkannt, wenn direkt gefolgt von @@ -758,6 +762,8 @@ sheetQualifier = (IDENT | QUOTED) "!" ; (* Blattname -- nur erkannt, IDENT = /[A-Za-z_][A-Za-z0-9_]*/ ; QUOTED = /' ( [^'] | '' )* '/ ; (* Name mit Leerzeichen *) CELL = /\$?[A-Za-z]{1,3}\$?[0-9]{1,7}/ ; +COLPART = /\$?[A-Za-z]{1,3}/ ; +ROWPART = /\$?[0-9]{1,7}/ ; (* 1-basiert, ab 1 *) ERRORLIT = "#REF" | "#DIV/0!" | "#NAME?" | "#ERR" | "#CIRC" | "#VALUE!" | "#N/A" ; (* ein Fehlercode, direkt getippt oder von einem @@ -915,6 +921,20 @@ genau eines. Dieselbe Wahrheits-Semantik wie überall sonst: jede Zahl außer Funktioniert genauso in einer „Mit Schleife"-`IF`-Bedingung (s. o.), z. B. `IF AND(x > 0; x < schwelle) THEN … END`. +**Ganze Spalten und Zeilen**: `B:B` (Spalte B), `A:C` (Spalten A bis C), +`1:1` (Zeile 1) und `2:5` (Zeilen 2 bis 5) — auch mit `$` (`$B:$B`) und mit +Tabellen-/Blattnamen (`Umsatz!B:B`, `Umsatz::1:1`, `'Blatt 2'!Kosten::A:C`). +So ein Bezug reicht immer bis zum aktuellen Ende der Tabelle: wächst die +Tabelle (neue Zeilen per Größenänderung oder durch Eintippen unterhalb), +zählt `=SUM(B:B)` die neuen Zellen automatisch mit. Einfügen/Löschen von +Spalten verschiebt `B:B` wie einen Bereich (Spalte vor B eingefügt → `C:C`, +Spalte B gelöscht → `#REF`), Zeilen-Änderungen lassen eine ganze Spalte +unberührt — für ganze Zeilen umgekehrt. Beim Kopieren verschiebt sich +`B:B` nur seitlich, `1:1` nur nach oben/unten, `$`-gesperrte Teile gar +nicht. Steht die Formel selbst in der summierten Spalte/Zeile, ist das ein +Zirkelbezug (`#CIRC`), wie in Excel — das Ergebnis also in eine andere +Spalte bzw. Zeile oder Tabelle schreiben. + **Datum & Zeit**: ein Datum ist — wie in jeder Tabellenkalkulation — intern einfach eine **Zahl** (Tage seit dem 30.12.1899, Uhrzeit als Bruchteil eines Tages), keine eigene Zellart. Deshalb funktioniert Datums-Arithmetik ganz @@ -1112,7 +1132,7 @@ Grundrechenarten: `Übersicht!B2 = =SUM(Umsatz!B2:E2)`, `D2 = =B2-C2`, ## Tests -`npm run test` — Vitest, 553 Tests: +`npm run test` — Vitest, 566 Tests: - **lexer / parser** — Präzedenz & Assoziativität (`^` vor `*/` vor `+-`, `^` rechts, `-2^2 = -(2^2)`), lokale/qualifizierte/quoted Bezüge, `!` **und** `::`, diff --git a/src/engine/__tests__/parser.spec.ts b/src/engine/__tests__/parser.spec.ts index de007c5..d17b28f 100644 --- a/src/engine/__tests__/parser.spec.ts +++ b/src/engine/__tests__/parser.spec.ts @@ -152,6 +152,41 @@ describe('parser references', () => { }); }); +describe('parser whole-column / whole-row references', () => { + const range = (src: string) => parseFormula(src) as Extract; + + it('B:B, A:C and $B:$B are whole columns', () => { + expect(range('B:B')).toMatchObject({ kind: 'range', whole: 'col', qualifier: null, start: { col: 1, row: 0 } }); + expect(range('A:C')).toMatchObject({ whole: 'col', start: { col: 0 }, end: { col: 2 } }); + expect(range('$B:$B')).toMatchObject({ whole: 'col', start: { col: 1 }, end: { col: 1 } }); + expect(range('aa:ab')).toMatchObject({ whole: 'col', start: { col: 26 }, end: { col: 27 } }); + }); + + it('1:1, 2:5 and $3:$3 are whole rows (1-based in the text)', () => { + expect(range('1:1')).toMatchObject({ kind: 'range', whole: 'row', start: { col: 0, row: 0 }, end: { row: 0 } }); + expect(range('2:5')).toMatchObject({ whole: 'row', start: { row: 1 }, end: { row: 4 } }); + expect(range('$3:$3')).toMatchObject({ whole: 'row', start: { row: 2 }, end: { row: 2 } }); + }); + + it('take table and sheet qualifiers', () => { + expect(range('Umsatz!B:B')).toMatchObject({ qualifier: 'Umsatz', whole: 'col' }); + expect(range('Umsatz::1:1')).toMatchObject({ qualifier: 'Umsatz', whole: 'row' }); + expect(range("'Blatt 2'!Kosten::A:C")).toMatchObject({ sheetQualifier: 'Blatt 2', qualifier: 'Kosten', whole: 'col' }); + }); + + it('work as function arguments and are collected as refs', () => { + const ast = parseFormula('SUM(B:B; 2:3) + 1'); + expect(collectRefs(ast).map((r) => (r.kind === 'range' ? r.whole : r.kind))).toEqual(['col', 'row']); + }); + + it('reject mixed or invalid shapes', () => { + expect(() => parseFormula('A:1')).toThrow(); + expect(() => parseFormula('0:0')).toThrow(); + // a plain number still is one + expect(parseFormula('12')).toEqual({ kind: 'number', value: 12 }); + }); +}); + 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)'); diff --git a/src/engine/evaluator.ts b/src/engine/evaluator.ts index 2ad0dc9..ce0ff01 100644 --- a/src/engine/evaluator.ts +++ b/src/engine/evaluator.ts @@ -41,7 +41,7 @@ import type { ActionNode, BlockCallNode, BlockNode, CellNode, FuncNode, JsonLiteralNode, NamedRefNode, Node, RangeNode, Statement } from './parser'; import { getFunction, isTruthy } from './functions'; -import { expandRange } from './refs'; +import { rangeCells } from './refs'; import { substitute } from './userFunctions'; import { FormulaError, @@ -876,7 +876,7 @@ function resolveWriteTarget(node: Node, host: EngineHost): CellRef[] { const tableId = host.resolveTableName(n.qualifier, n.sheetQualifier ?? null); if (!tableId) throw new Error(`SETVALUE: unbekannte Tabelle '${n.qualifier}'`); if (n.kind === 'cell') return [{ tableId, col: n.col, row: n.row }]; - return expandRange(n.start, n.end).map((c) => ({ tableId, col: c.col, row: c.row })); + return rangeCells(n, host.tableSize(tableId)).map((c) => ({ tableId, col: c.col, row: c.row })); } throw new Error('SETVALUE: das erste Argument muss ein Zell- oder Bereichsbezug sein'); } @@ -899,7 +899,17 @@ function rangeOrCellToCellNodes(node: Node, loopVarName: string, host: EngineHos } if (node.kind === 'cell') return [node]; if (node.kind === 'range') { - return expandRange(node.start, node.end).map( + const range = node; + // a whole column/row needs its table's size to know where it ends + const tableId = range.whole + ? range.qualifier == null + ? host.homeTableId + : host.resolveTableName(range.qualifier, range.sheetQualifier ?? null) + : null; + if (range.whole && !tableId) { + throw new Error(`FOR ${loopVarName} IN ...: unbekannte Tabelle '${range.qualifier}'`); + } + return rangeCells(range, tableId ? host.tableSize(tableId) : null).map( (c): CellNode => ({ kind: 'cell', qualifier: node.qualifier, @@ -925,7 +935,7 @@ function evalArg(node: Node, host: EngineHost): FormulaError | CellValue[] { ? 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)); + return rangeCells(node, host.tableSize(tableId)).map((c) => host.getComputed(tableId, c.col, c.row)); } const v = evalScalar(node, host); return [v]; diff --git a/src/engine/index.ts b/src/engine/index.ts index c41c999..476baff 100644 --- a/src/engine/index.ts +++ b/src/engine/index.ts @@ -30,6 +30,11 @@ export { toA1, looksLikeCell, expandRange, + rangeCells, + wholeLineAt, + parseLinePart, + formatLinePart, + WHOLE_LINE_END, cycleReferenceLocks, currentIdentifierAtCaret, } from './refs'; diff --git a/src/engine/parser.ts b/src/engine/parser.ts index 3de2e61..0626980 100644 --- a/src/engine/parser.ts +++ b/src/engine/parser.ts @@ -90,7 +90,7 @@ */ import { tokenize, type Token, type TokenType, LexError } from './lexer'; -import { looksLikeCell, parseA1 } from './refs'; +import { looksLikeCell, parseA1, wholeLineAt, WHOLE_LINE_END } from './refs'; import type { ErrorCode } from './types'; export type Node = @@ -163,6 +163,10 @@ export interface RangeNode { sheetQualifier?: string; start: { col: number; row: number }; end: { col: number; row: number }; + /** A whole-column (`B:B`, `A:C`) or whole-row (`1:1`, `2:5`) range: the + * other axis runs from 0 to `WHOLE_LINE_END` (refs.ts), clamped to the + * table's size wherever cells are read (`rangeCells`). */ + whole?: 'col' | 'row'; } /** A bare, unqualified name that isn't a param and doesn't look like a cell * -- a candidate reference to a document-wide *named range* (`Umsatz_Q1` @@ -568,6 +572,8 @@ class Parser { const t = this.peek(); if (t.type === 'NUMBER') { + // `1:1` / `2:5` -- a whole-row reference, not a number + if (wholeLineAt(this.toks, this.i)) return this.reference(null, null); this.next(); return { kind: 'number', value: Number(t.value) }; } @@ -653,6 +659,32 @@ class Parser { * IDENT already consumed by the caller for the *local* case. */ private reference(qualifier: string | null, sheetQualifier: string | null, firstTok?: Token): Node { + // whole column `B:B` / whole row `1:1` -- checked first: `B` alone would + // otherwise be a named-range candidate and `1` a number + const line = wholeLineAt(this.toks, firstTok ? this.i - 1 : this.i); + if (line) { + if (!firstTok) this.next(); + this.next(); // ":" + this.next(); // second part + const sheetField = sheetQualifier === null ? {} : { sheetQualifier }; + return line.axis === 'col' + ? { + kind: 'range', + qualifier, + ...sheetField, + start: { col: line.start, row: 0 }, + end: { col: line.end, row: WHOLE_LINE_END }, + whole: 'col', + } + : { + kind: 'range', + qualifier, + ...sheetField, + start: { col: 0, row: line.start }, + end: { col: WHOLE_LINE_END, row: line.end }, + whole: 'row', + }; + } const a = firstTok ?? this.expectCellIdent(); if (!firstTok && a.type !== 'IDENT') { throw new ParseError(`Expected cell reference at ${a.pos}`); diff --git a/src/engine/refs.ts b/src/engine/refs.ts index 71eafea..bdae6c7 100644 --- a/src/engine/refs.ts +++ b/src/engine/refs.ts @@ -174,6 +174,70 @@ export function currentIdentifierAtCaret( return { text: last.value, start: last.pos + 1 }; } +// ---- whole-column / whole-row references (`B:B`, `A:C`, `1:1`, `$2:$5`) ---- + +/** Stand-in for "the last row/column" in a whole-column/whole-row range's + * open end. Every consumer clamps it to the actual table size (see + * {@link rangeCells}); a consumer that merely clamps to its table (the + * Name Box selection) gets the right answer for free. */ +export const WHOLE_LINE_END = 9_999_998; + +export type LineAxis = 'col' | 'row'; + +const COL_PART_RE = /^(\$?)([A-Za-z]{1,3})$/; +const ROW_PART_RE = /^(\$?)([0-9]{1,7})$/; + +/** One side of a whole-line reference: `"B"`/`"$B"` (a column) or + * `"3"`/`"$3"` (a 1-based row) -> its 0-based index, or `null`. */ +export function parseLinePart(text: string): { axis: LineAxis; index: number; abs: boolean } | null { + let m = COL_PART_RE.exec(text); + if (m) return { axis: 'col', index: colToNumber(m[2]), abs: m[1] === '$' }; + m = ROW_PART_RE.exec(text); + if (m && Number(m[2]) >= 1) return { axis: 'row', index: Number(m[2]) - 1, abs: m[1] === '$' }; + return null; +} + +export function formatLinePart(axis: LineAxis, index: number, abs: boolean): string { + return (abs ? '$' : '') + (axis === 'col' ? numberToCol(index) : String(index + 1)); +} + +/** If `toks[i]`, `toks[i+1]`, `toks[i+2]` spell a whole-line range (`B:B`, + * `A:C`, `1:1`, `$2:$5` -- both sides the same axis), return its parts. + * Shared by the parser and every token-level formula rewriter (copy, + * row/column insert/delete, table rename), so they all agree on the shape. */ +export function wholeLineAt( + toks: Token[], + i: number, +): { axis: LineAxis; a: Token; b: Token; start: number; end: number; startAbs: boolean; endAbs: boolean } | null { + const a = toks[i]; + const colon = toks[i + 1]; + const b = toks[i + 2]; + if (!a || !colon || !b || colon.type !== 'COLON') return null; + if ((a.type !== 'IDENT' && a.type !== 'NUMBER') || (b.type !== 'IDENT' && b.type !== 'NUMBER')) return null; + const pa = parseLinePart(a.value); + const pb = parseLinePart(b.value); + if (!pa || !pb || pa.axis !== pb.axis) return null; + return { axis: pa.axis, a, b, start: pa.index, end: pb.index, startAbs: pa.abs, endAbs: pb.abs }; +} + +/** The cells a range covers, with a whole-column/whole-row range's open + * end clamped to its table's current size (`size` null = unknown table -> + * no cells). An ordinary range is just {@link expandRange}. */ +export function rangeCells( + r: { start: { col: number; row: number }; end: { col: number; row: number }; whole?: LineAxis }, + size: { cols: number; rows: number } | null, +): Array<{ col: number; row: number }> { + if (!r.whole) return expandRange(r.start, r.end); + if (!size) return []; + const clamp = (v: number, n: number) => Math.min(v, n - 1); + const start = { col: Math.min(r.start.col, r.end.col), row: Math.min(r.start.row, r.end.row) }; + const end = { col: Math.max(r.start.col, r.end.col), row: Math.max(r.start.row, r.end.row) }; + // a whole line entirely beyond the table covers nothing + if (r.whole === 'col' && start.col >= size.cols) return []; + if (r.whole === 'row' && start.row >= size.rows) return []; + return expandRange(start, { col: clamp(end.col, size.cols), row: clamp(end.row, size.rows) }); +} + /** * Expand a rectangular range (given by two corners, any order) into the list of * cell coordinates it covers, row-major. diff --git a/src/engine/workbook.ts b/src/engine/workbook.ts index e8c75e2..b909e64 100644 --- a/src/engine/workbook.ts +++ b/src/engine/workbook.ts @@ -21,7 +21,7 @@ import { DependencyGraph } from './graph'; import { collectRefs, parseFormula, type Node } from './parser'; -import { expandRange } from './refs'; +import { rangeCells } from './refs'; import { parseDateTimeLiteral } from './dates'; import { buildFunctionIndex, expandUserFunctions, type FunctionDef } from './userFunctions'; import { @@ -249,7 +249,7 @@ export class Workbook implements EngineHost { if ('col' in resolved) { out.push(keyOf(target, resolved.col, resolved.row)); } else { - for (const c of expandRange(resolved.start, resolved.end)) { + for (const c of rangeCells(resolved, this.tableSize(target))) { out.push(keyOf(target, c.col, c.row)); } } diff --git a/src/store/__tests__/document.spec.ts b/src/store/__tests__/document.spec.ts index 3ddfe3d..d7b1093 100644 --- a/src/store/__tests__/document.spec.ts +++ b/src/store/__tests__/document.spec.ts @@ -2921,3 +2921,98 @@ describe('v1 -> v2 migration', () => { ]); }); }); + +describe('whole-column / whole-row references', () => { + /** A blank document's "Tabelle 1" (12 x 6) with B2:B4 = 1, 2, 3 and row 2 = 10, 1, 20. */ + function setup() { + const s = fresh(); + s.newDocument(); + const t = tableId(s, 'Tabelle 1'); + setCell(s, 'Tabelle 1', 'B2', '1'); + setCell(s, 'Tabelle 1', 'B3', '2'); + setCell(s, 'Tabelle 1', 'B4', '3'); + setCell(s, 'Tabelle 1', 'A2', '10'); + setCell(s, 'Tabelle 1', 'C2', '20'); + return { s, t }; + } + const input = (s: DocumentStore, t: string, a1: string) => { + const { col, row } = coords(a1); + return s.cellInput(t, col, row); + }; + + it('SUM over a whole column and a whole row', () => { + const { s } = setup(); + setCell(s, 'Tabelle 1', 'E1', '=SUM(B:B)'); + setCell(s, 'Tabelle 1', 'F1', '=SUM(2:2)'); + setCell(s, 'Tabelle 1', 'E3', '=COUNT(A:C)'); + expect(val(s, 'Tabelle 1', 'E1')).toBe(6); + expect(val(s, 'Tabelle 1', 'F1')).toBe(31); + expect(val(s, 'Tabelle 1', 'E3')).toBe(5); + }); + + it('a formula inside the column it sums is a circular reference', () => { + const { s } = setup(); + setCell(s, 'Tabelle 1', 'B8', '=SUM(B:B)'); + const v = val(s, 'Tabelle 1', 'B8'); + expect(isError(v) && v.code).toBe('#CIRC'); + }); + + it('recalculates when a cell in the column changes, and picks up rows typed past the end', () => { + const { s } = setup(); + setCell(s, 'Tabelle 1', 'E1', '=SUM(B:B)'); + setCell(s, 'Tabelle 1', 'B3', '12'); + expect(val(s, 'Tabelle 1', 'E1')).toBe(16); + setCell(s, 'Tabelle 1', 'B20', '100'); // beyond the 12 rows -> the table grows + expect(val(s, 'Tabelle 1', 'E1')).toBe(116); + }); + + it('works qualified from another table', () => { + const { s } = setup(); + const other = s.addTable(s.doc.activeSheetId); + const name = s.tableById(other)!.name; + setCell(s, name, 'A1', '=SUM(Tabelle 1!B:B)'.replace('Tabelle 1', "'Tabelle 1'")); + expect(val(s, name, 'A1')).toBe(6); + }); + + it('inserting/deleting columns moves and shrinks B:B, rows leave it alone', () => { + const { s, t } = setup(); + setCell(s, 'Tabelle 1', 'F1', '=SUM(B:B)'); + s.insertColumn(t, 0); // before A: B -> C, and the formula itself moves F -> G + expect(input(s, t, 'G1')).toBe('=SUM(C:C)'); + expect(val(s, 'Tabelle 1', 'G1')).toBe(6); + s.insertRow(t, 0); + expect(input(s, t, 'G2')).toBe('=SUM(C:C)'); + s.deleteColumn(t, 2); // delete column C itself -> #REF + expect(input(s, t, 'F2')).toBe('=SUM(#REF)'); + }); + + it('inserting a row moves a whole-row reference', () => { + const { s, t } = setup(); + setCell(s, 'Tabelle 1', 'F1', '=SUM($2:$2)'); + s.insertRow(t, 1); // before row 2 + expect(input(s, t, 'F1')).toBe('=SUM($3:$3)'); + expect(val(s, 'Tabelle 1', 'F1')).toBe(31); + }); + + it('copying shifts a relative whole column but not a $-locked one', () => { + const { s, t } = setup(); + setCell(s, 'Tabelle 1', 'E1', '=SUM(B:B)+SUM($B:$B)+SUM(2:2)'); + s.setSelection({ tableId: t, anchor: coords('E1'), focus: coords('E1') }); + s.copySelection(); + s.setSelection({ tableId: t, anchor: coords('F2'), focus: coords('F2') }); + s.pasteClipboard(); + expect(input(s, t, 'F2')).toBe('=SUM(C:C)+SUM($B:$B)+SUM(3:3)'); + }); + + it('renaming the table rewrites a qualified whole-column reference', () => { + const { s, t } = setup(); + const other = s.addTable(s.doc.activeSheetId); + const name = s.tableById(other)!.name; + setCell(s, name, 'A1', "=SUM('Tabelle 1'!B:B)"); + setCell(s, name, 'A2', "='Tabelle 1'!B2*2"); + s.renameObject(t, 'Daten'); + expect(s.cellInput(other, 0, 0)).toBe('=SUM(Daten!B:B)'); + expect(s.cellInput(other, 0, 1)).toBe('=Daten!B2*2'); // quoted name: whole token replaced + expect(val(s, name, 'A1')).toBe(6); + }); +}); diff --git a/src/store/document.ts b/src/store/document.ts index 37aa7bd..a12cf04 100644 --- a/src/store/document.ts +++ b/src/store/document.ts @@ -25,6 +25,8 @@ import { tokenize, looksLikeCell, parseA1, + wholeLineAt, + formatLinePart, parseFormula, parseFunctionBlock, parseMacroBlock, @@ -197,6 +199,24 @@ function shiftFormula(raw: string, dCol: number, dRow: number, restrict?: Rect): let last = 0; for (let i = 0; i < toks.length; i++) { const tok = toks[i]; + // whole column `B:B` / whole row `1:1`: shifts along its own axis only. + // A cut never moves a whole column/row, so it's left alone then. + const line = wholeLineAt(toks, i); + if (line) { + const d = line.axis === 'col' ? dCol : dRow; + if (!restrict && d !== 0) { + for (const [t, v, abs] of [ + [line.a, line.start, line.startAbs], + [line.b, line.end, line.endAbs], + ] as const) { + if (abs) continue; + out += body.slice(last, t.pos) + formatLinePart(line.axis, Math.max(0, v + d), abs); + last = t.pos + t.value.length; + } + } + i += 2; + continue; + } if (tok.type !== 'IDENT' || !looksLikeCell(tok.value)) continue; const next = toks[i + 1]; if (next && (next.type === 'BANG' || next.type === 'DBLCOLON')) continue; // a table-name qualifier, not a cell @@ -337,6 +357,10 @@ function rewriteRefsForStructuralChange( cellIdx = i + 2; } } + if (cellIdx === -1 && (tok.type === 'IDENT' || tok.type === 'NUMBER') && wholeLineAt(toks, i)) { + resolvedTableId = homeTableId; + cellIdx = i; + } if (cellIdx === -1 && tok.type === 'IDENT' && looksLikeCell(tok.value)) { resolvedTableId = homeTableId; cellIdx = i; @@ -346,6 +370,32 @@ function rewriteRefsForStructuralChange( continue; } + // whole column `B:B` / whole row `1:1`: only a change on its own axis + // moves it -- same grow/shrink/#REF rules as a range's endpoints + const line = wholeLineAt(toks, cellIdx); + if (line) { + if (resolvedTableId === targetTableId && line.axis === axis) { + const lo = Math.min(line.start, line.end); + const hi = Math.max(line.start, line.end); + const newLo = delta > 0 ? insertShift(lo, at) : deleteShiftStart(lo, at); + const newHi = delta > 0 ? insertShift(hi, at) : deleteShiftEnd(hi, at); + if (newLo > newHi) { + repls.push({ pos: line.a.pos, end: line.b.pos + line.b.value.length, text: '#REF' }); + } else { + const aNew = line.start === lo ? newLo : newHi; + const bNew = line.end === lo ? newLo : newHi; + if (aNew !== line.start) { + repls.push({ pos: line.a.pos, end: line.a.pos + line.a.value.length, text: formatLinePart(axis, aNew, line.startAbs) }); + } + if (bNew !== line.end) { + repls.push({ pos: line.b.pos, end: line.b.pos + line.b.value.length, text: formatLinePart(axis, bNew, line.endAbs) }); + } + } + } + i = cellIdx + 3; + continue; + } + const cellTokA = toks[cellIdx]; if (!cellTokA || cellTokA.type !== 'IDENT' || !looksLikeCell(cellTokA.value)) { i++; // qualifier not actually followed by a cell -- malformed, leave alone @@ -417,6 +467,14 @@ function rewriteRefsForStructuralChange( return '=' + out; } +/** Where a token ends in the source text. A QUOTED token's `value` is the + * unquoted name ('Kosten Q3' -> Kosten Q3, '' -> '), so its source span is + * longer than the value by the two quotes plus one per doubled quote. */ +function tokenSourceEnd(tok: Token): number { + if (tok.type !== 'QUOTED') return tok.pos + tok.value.length; + return tok.pos + tok.value.length + 2 + (tok.value.match(/'/g)?.length ?? 0); +} + /** `'` needs quoting whenever a name isn't a bare identifier (spaces, * punctuation, ...) -- shared by the table- and sheet-qualifier cases. * Module-level (not a store method) since it's pure and both @@ -495,6 +553,14 @@ function rewriteRefsForRename( continue; } + if (wholeLineAt(toks, cellIdx)) { + // `Tabelle!B:B` / `Tabelle::1:1` -- the qualifier renames the same way + if (resolvedTableId === targetTableId && qualTok) { + repls.push({ pos: qualTok.pos, end: tokenSourceEnd(qualTok), text: quoteQualifier(newName) }); + } + i = cellIdx + 3; + continue; + } const cellTokA = toks[cellIdx]; if (!cellTokA || cellTokA.type !== 'IDENT' || !looksLikeCell(cellTokA.value)) { i++; // qualifier not actually followed by a cell -- malformed, leave alone @@ -505,7 +571,7 @@ function rewriteRefsForRename( const validRange = !!cellTokB && cellTokB.type === 'IDENT' && looksLikeCell(cellTokB.value); if (resolvedTableId === targetTableId && qualTok) { - repls.push({ pos: qualTok.pos, end: qualTok.pos + qualTok.value.length, text: quoteQualifier(newName) }); + repls.push({ pos: qualTok.pos, end: tokenSourceEnd(qualTok), text: quoteQualifier(newName) }); } i = validRange ? cellIdx + 3 : cellIdx + 1; @@ -1916,9 +1982,13 @@ function createStore() { if (trimmed === (table.cells[key] ?? '')) return; if (trimmed === '') delete table.cells[key]; else table.cells[key] = trimmed; + const grows = col >= table.cols || row >= table.rows; if (col >= table.cols) table.cols = col + 1; if (row >= table.rows) table.rows = row + 1; - wb.setCell(tableId, col, row, trimmed); + // a grown table changes what a whole-column/-row reference (`B:B`, `1:1`) + // covers -- rebuild so those formulas pick up the new cells + if (grows) syncWorkbook(); + else wb.setCell(tableId, col, row, trimmed); // Typing a recognizable date/time literal auto-applies the matching // display format, like Excel/Numbers -- but only while the cell has no // explicit numFmt of its own yet, so this never overrides a deliberate