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:
wkh
2026-09-28 02:30:08 +02:00
co-authored by Claude Opus 5.5
parent c4d2712903
commit 452f13e774
9 changed files with 343 additions and 12 deletions
+72 -2
View File
@@ -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