Whole-column and whole-row references (B:B, A:C, 1:1, 2:5)
Formulas accept B:B / A:C / $B:$B and 1:1 / 2:5 / $3:$3, also qualified
(Umsatz!B:B, Umsatz::1:1, 'Blatt 2'!Kosten::A:C). The open end is clamped
to the table's current size wherever cells are read (evaluation, the
dependency graph, SETVALUE targets, FOR sources), and typing past the
table's end now rebuilds the workbook so such a reference covers the new
cells. The Name Box selects the whole column/row.
The token-level rewriters handle the new shape: copy/fill shifts B:B only
sideways and 1:1 only up/down ($ stays), column insert/delete moves or
#REFs B:B like a range (rows leave it alone, and vice versa), and a table
rename rewrites its qualifier.
Also fixes renaming a table referenced with a quoted name ('Tabelle 1'!B2):
the rewriter replaced only as many characters as the unquoted name has,
leaving a stray quote (=Daten1'!B2).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -12,7 +12,7 @@ Eine browserbasierte Tabellenkalkulation nach dem Vorbild von **Apple Numbers**.
|
|||||||
```bash
|
```bash
|
||||||
npm install
|
npm install
|
||||||
npm run dev # Vite-Devserver
|
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 build # Typecheck + Produktionsbuild
|
||||||
npm run lint # ESLint (flat config)
|
npm run lint # ESLint (flat config)
|
||||||
```
|
```
|
||||||
@@ -744,7 +744,11 @@ namedref = IDENT ; (* ein unqualifizierter,
|
|||||||
außerhalb eines
|
außerhalb eines
|
||||||
Funktionskörpers *)
|
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 *)
|
qualifier = (IDENT | QUOTED) ("!" | "::") ; (* Tabellenname *)
|
||||||
sheetQualifier = (IDENT | QUOTED) "!" ; (* Blattname -- nur erkannt,
|
sheetQualifier = (IDENT | QUOTED) "!" ; (* Blattname -- nur erkannt,
|
||||||
wenn direkt gefolgt von
|
wenn direkt gefolgt von
|
||||||
@@ -758,6 +762,8 @@ sheetQualifier = (IDENT | QUOTED) "!" ; (* Blattname -- nur erkannt,
|
|||||||
IDENT = /[A-Za-z_][A-Za-z0-9_]*/ ;
|
IDENT = /[A-Za-z_][A-Za-z0-9_]*/ ;
|
||||||
QUOTED = /' ( [^'] | '' )* '/ ; (* Name mit Leerzeichen *)
|
QUOTED = /' ( [^'] | '' )* '/ ; (* Name mit Leerzeichen *)
|
||||||
CELL = /\$?[A-Za-z]{1,3}\$?[0-9]{1,7}/ ;
|
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" |
|
ERRORLIT = "#REF" | "#DIV/0!" | "#NAME?" | "#ERR" | "#CIRC" |
|
||||||
"#VALUE!" | "#N/A" ; (* ein Fehlercode, direkt
|
"#VALUE!" | "#N/A" ; (* ein Fehlercode, direkt
|
||||||
getippt oder von einem
|
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.
|
Funktioniert genauso in einer „Mit Schleife"-`IF`-Bedingung (s. o.), z. B.
|
||||||
`IF AND(x > 0; x < schwelle) THEN … END`.
|
`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
|
**Datum & Zeit**: ein Datum ist — wie in jeder Tabellenkalkulation — intern
|
||||||
einfach eine **Zahl** (Tage seit dem 30.12.1899, Uhrzeit als Bruchteil eines
|
einfach eine **Zahl** (Tage seit dem 30.12.1899, Uhrzeit als Bruchteil eines
|
||||||
Tages), keine eigene Zellart. Deshalb funktioniert Datums-Arithmetik ganz
|
Tages), keine eigene Zellart. Deshalb funktioniert Datums-Arithmetik ganz
|
||||||
@@ -1112,7 +1132,7 @@ Grundrechenarten: `Übersicht!B2 = =SUM(Umsatz!B2:E2)`, `D2 = =B2-C2`,
|
|||||||
|
|
||||||
## Tests
|
## Tests
|
||||||
|
|
||||||
`npm run test` — Vitest, 553 Tests:
|
`npm run test` — Vitest, 566 Tests:
|
||||||
|
|
||||||
- **lexer / parser** — Präzedenz & Assoziativität (`^` vor `*/` vor `+-`, `^`
|
- **lexer / parser** — Präzedenz & Assoziativität (`^` vor `*/` vor `+-`, `^`
|
||||||
rechts, `-2^2 = -(2^2)`), lokale/qualifizierte/quoted Bezüge, `!` **und** `::`,
|
rechts, `-2^2 = -(2^2)`), lokale/qualifizierte/quoted Bezüge, `!` **und** `::`,
|
||||||
|
|||||||
@@ -152,6 +152,41 @@ describe('parser references', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('parser whole-column / whole-row references', () => {
|
||||||
|
const range = (src: string) => parseFormula(src) as Extract<Node, { kind: 'range' }>;
|
||||||
|
|
||||||
|
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"', () => {
|
describe('parser sheet qualifier -- "Blatt!Tabelle::Zelle"', () => {
|
||||||
it('parses the explicit 3-part form, bare and quoted', () => {
|
it('parses the explicit 3-part form, bare and quoted', () => {
|
||||||
expect(parse('=Analyse!Umsatz::B2')).toBe('Analyse!Umsatz!cell(1,1)');
|
expect(parse('=Analyse!Umsatz::B2')).toBe('Analyse!Umsatz!cell(1,1)');
|
||||||
|
|||||||
+14
-4
@@ -41,7 +41,7 @@
|
|||||||
|
|
||||||
import type { ActionNode, BlockCallNode, BlockNode, CellNode, FuncNode, JsonLiteralNode, NamedRefNode, Node, RangeNode, Statement } from './parser';
|
import type { ActionNode, BlockCallNode, BlockNode, CellNode, FuncNode, JsonLiteralNode, NamedRefNode, Node, RangeNode, Statement } from './parser';
|
||||||
import { getFunction, isTruthy } from './functions';
|
import { getFunction, isTruthy } from './functions';
|
||||||
import { expandRange } from './refs';
|
import { rangeCells } from './refs';
|
||||||
import { substitute } from './userFunctions';
|
import { substitute } from './userFunctions';
|
||||||
import {
|
import {
|
||||||
FormulaError,
|
FormulaError,
|
||||||
@@ -876,7 +876,7 @@ function resolveWriteTarget(node: Node, host: EngineHost): CellRef[] {
|
|||||||
const tableId = host.resolveTableName(n.qualifier, n.sheetQualifier ?? null);
|
const tableId = host.resolveTableName(n.qualifier, n.sheetQualifier ?? null);
|
||||||
if (!tableId) throw new Error(`SETVALUE: unbekannte Tabelle '${n.qualifier}'`);
|
if (!tableId) throw new Error(`SETVALUE: unbekannte Tabelle '${n.qualifier}'`);
|
||||||
if (n.kind === 'cell') return [{ tableId, col: n.col, row: n.row }];
|
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');
|
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 === 'cell') return [node];
|
||||||
if (node.kind === 'range') {
|
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 => ({
|
(c): CellNode => ({
|
||||||
kind: 'cell',
|
kind: 'cell',
|
||||||
qualifier: node.qualifier,
|
qualifier: node.qualifier,
|
||||||
@@ -925,7 +935,7 @@ function evalArg(node: Node, host: EngineHost): FormulaError | CellValue[] {
|
|||||||
? host.homeTableId
|
? host.homeTableId
|
||||||
: host.resolveTableName(node.qualifier, node.sheetQualifier ?? null);
|
: host.resolveTableName(node.qualifier, node.sheetQualifier ?? null);
|
||||||
if (!tableId) return new FormulaError('#REF', `unknown table '${node.qualifier}'`);
|
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);
|
const v = evalScalar(node, host);
|
||||||
return [v];
|
return [v];
|
||||||
|
|||||||
@@ -30,6 +30,11 @@ export {
|
|||||||
toA1,
|
toA1,
|
||||||
looksLikeCell,
|
looksLikeCell,
|
||||||
expandRange,
|
expandRange,
|
||||||
|
rangeCells,
|
||||||
|
wholeLineAt,
|
||||||
|
parseLinePart,
|
||||||
|
formatLinePart,
|
||||||
|
WHOLE_LINE_END,
|
||||||
cycleReferenceLocks,
|
cycleReferenceLocks,
|
||||||
currentIdentifierAtCaret,
|
currentIdentifierAtCaret,
|
||||||
} from './refs';
|
} from './refs';
|
||||||
|
|||||||
+33
-1
@@ -90,7 +90,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { tokenize, type Token, type TokenType, LexError } from './lexer';
|
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';
|
import type { ErrorCode } from './types';
|
||||||
|
|
||||||
export type Node =
|
export type Node =
|
||||||
@@ -163,6 +163,10 @@ export interface RangeNode {
|
|||||||
sheetQualifier?: string;
|
sheetQualifier?: string;
|
||||||
start: { col: number; row: number };
|
start: { col: number; row: number };
|
||||||
end: { 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 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`
|
* -- a candidate reference to a document-wide *named range* (`Umsatz_Q1`
|
||||||
@@ -568,6 +572,8 @@ class Parser {
|
|||||||
const t = this.peek();
|
const t = this.peek();
|
||||||
|
|
||||||
if (t.type === 'NUMBER') {
|
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();
|
this.next();
|
||||||
return { kind: 'number', value: Number(t.value) };
|
return { kind: 'number', value: Number(t.value) };
|
||||||
}
|
}
|
||||||
@@ -653,6 +659,32 @@ class Parser {
|
|||||||
* IDENT already consumed by the caller for the *local* case.
|
* IDENT already consumed by the caller for the *local* case.
|
||||||
*/
|
*/
|
||||||
private reference(qualifier: string | null, sheetQualifier: string | null, firstTok?: Token): Node {
|
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();
|
const a = firstTok ?? this.expectCellIdent();
|
||||||
if (!firstTok && a.type !== 'IDENT') {
|
if (!firstTok && a.type !== 'IDENT') {
|
||||||
throw new ParseError(`Expected cell reference at ${a.pos}`);
|
throw new ParseError(`Expected cell reference at ${a.pos}`);
|
||||||
|
|||||||
@@ -174,6 +174,70 @@ export function currentIdentifierAtCaret(
|
|||||||
return { text: last.value, start: last.pos + 1 };
|
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
|
* Expand a rectangular range (given by two corners, any order) into the list of
|
||||||
* cell coordinates it covers, row-major.
|
* cell coordinates it covers, row-major.
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
|
|
||||||
import { DependencyGraph } from './graph';
|
import { DependencyGraph } from './graph';
|
||||||
import { collectRefs, parseFormula, type Node } from './parser';
|
import { collectRefs, parseFormula, type Node } from './parser';
|
||||||
import { expandRange } from './refs';
|
import { rangeCells } from './refs';
|
||||||
import { parseDateTimeLiteral } from './dates';
|
import { parseDateTimeLiteral } from './dates';
|
||||||
import { buildFunctionIndex, expandUserFunctions, type FunctionDef } from './userFunctions';
|
import { buildFunctionIndex, expandUserFunctions, type FunctionDef } from './userFunctions';
|
||||||
import {
|
import {
|
||||||
@@ -249,7 +249,7 @@ export class Workbook implements EngineHost {
|
|||||||
if ('col' in resolved) {
|
if ('col' in resolved) {
|
||||||
out.push(keyOf(target, resolved.col, resolved.row));
|
out.push(keyOf(target, resolved.col, resolved.row));
|
||||||
} else {
|
} 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));
|
out.push(keyOf(target, c.col, c.row));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
+72
-2
@@ -25,6 +25,8 @@ import {
|
|||||||
tokenize,
|
tokenize,
|
||||||
looksLikeCell,
|
looksLikeCell,
|
||||||
parseA1,
|
parseA1,
|
||||||
|
wholeLineAt,
|
||||||
|
formatLinePart,
|
||||||
parseFormula,
|
parseFormula,
|
||||||
parseFunctionBlock,
|
parseFunctionBlock,
|
||||||
parseMacroBlock,
|
parseMacroBlock,
|
||||||
@@ -197,6 +199,24 @@ function shiftFormula(raw: string, dCol: number, dRow: number, restrict?: Rect):
|
|||||||
let last = 0;
|
let last = 0;
|
||||||
for (let i = 0; i < toks.length; i++) {
|
for (let i = 0; i < toks.length; i++) {
|
||||||
const tok = toks[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;
|
if (tok.type !== 'IDENT' || !looksLikeCell(tok.value)) continue;
|
||||||
const next = toks[i + 1];
|
const next = toks[i + 1];
|
||||||
if (next && (next.type === 'BANG' || next.type === 'DBLCOLON')) continue; // a table-name qualifier, not a cell
|
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;
|
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)) {
|
if (cellIdx === -1 && tok.type === 'IDENT' && looksLikeCell(tok.value)) {
|
||||||
resolvedTableId = homeTableId;
|
resolvedTableId = homeTableId;
|
||||||
cellIdx = i;
|
cellIdx = i;
|
||||||
@@ -346,6 +370,32 @@ function rewriteRefsForStructuralChange(
|
|||||||
continue;
|
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];
|
const cellTokA = toks[cellIdx];
|
||||||
if (!cellTokA || cellTokA.type !== 'IDENT' || !looksLikeCell(cellTokA.value)) {
|
if (!cellTokA || cellTokA.type !== 'IDENT' || !looksLikeCell(cellTokA.value)) {
|
||||||
i++; // qualifier not actually followed by a cell -- malformed, leave alone
|
i++; // qualifier not actually followed by a cell -- malformed, leave alone
|
||||||
@@ -417,6 +467,14 @@ function rewriteRefsForStructuralChange(
|
|||||||
return '=' + out;
|
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,
|
/** `'` needs quoting whenever a name isn't a bare identifier (spaces,
|
||||||
* punctuation, ...) -- shared by the table- and sheet-qualifier cases.
|
* punctuation, ...) -- shared by the table- and sheet-qualifier cases.
|
||||||
* Module-level (not a store method) since it's pure and both
|
* Module-level (not a store method) since it's pure and both
|
||||||
@@ -495,6 +553,14 @@ function rewriteRefsForRename(
|
|||||||
continue;
|
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];
|
const cellTokA = toks[cellIdx];
|
||||||
if (!cellTokA || cellTokA.type !== 'IDENT' || !looksLikeCell(cellTokA.value)) {
|
if (!cellTokA || cellTokA.type !== 'IDENT' || !looksLikeCell(cellTokA.value)) {
|
||||||
i++; // qualifier not actually followed by a cell -- malformed, leave alone
|
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);
|
const validRange = !!cellTokB && cellTokB.type === 'IDENT' && looksLikeCell(cellTokB.value);
|
||||||
|
|
||||||
if (resolvedTableId === targetTableId && qualTok) {
|
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;
|
i = validRange ? cellIdx + 3 : cellIdx + 1;
|
||||||
@@ -1916,9 +1982,13 @@ function createStore() {
|
|||||||
if (trimmed === (table.cells[key] ?? '')) return;
|
if (trimmed === (table.cells[key] ?? '')) return;
|
||||||
if (trimmed === '') delete table.cells[key];
|
if (trimmed === '') delete table.cells[key];
|
||||||
else table.cells[key] = trimmed;
|
else table.cells[key] = trimmed;
|
||||||
|
const grows = col >= table.cols || row >= table.rows;
|
||||||
if (col >= table.cols) table.cols = col + 1;
|
if (col >= table.cols) table.cols = col + 1;
|
||||||
if (row >= table.rows) table.rows = row + 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
|
// Typing a recognizable date/time literal auto-applies the matching
|
||||||
// display format, like Excel/Numbers -- but only while the cell has no
|
// display format, like Excel/Numbers -- but only while the cell has no
|
||||||
// explicit numFmt of its own yet, so this never overrides a deliberate
|
// explicit numFmt of its own yet, so this never overrides a deliberate
|
||||||
|
|||||||
Reference in New Issue
Block a user