Text-Vergleich, &-Verkettung und benannte Bereiche

- Vergleichsoperatoren (= <> > < >= <=) unterscheiden jetzt Text- von
  Zahlenvergleich statt jede Seite zwangsweise durch toNumber() zu
  schicken (=A1="Nord" gab vorher #VALUE! statt 1); ermöglicht
  Text-Kriterien in selbstgebauten COUNTIF/SUMIF/AVERAGEIF-Funktionen
  (neue README-Sektion "SUMIF/COUNTIF/AVERAGEIF nachbauen").
- Neuer &-Operator (Excel-Präzedenz zwischen Vergleich und +/-) plus
  CONCAT/CONCATENATE als Funktions-Äquivalent.
- Benannte Bereiche: neuer Toolbar-Button "🏷" verwaltet dokumentweite
  Namen für eine feste Zelle/einen festen Bereich (z. B. Umsatz_Q1statt
  Umsatz!B2:B6), unqualifiziert in jeder Top-Level-Formel nutzbar.
  Live gegen die Tabellen-ID aufgelöst (übersteht Tabellen-Umbenennung),
  bewusst nicht innerhalb einer benutzerdefinierten Funktion selbst
  nutzbar (Aufrufer können sie aber als Argument übergeben).
- Nebenbei: COUNT2s Autovervollständigungs-Hint korrigiert (zeigte
  "COUNT(...)" statt "COUNT2(...)").

374 Tests, vue-tsc/eslint/build sauber, Playwright-live-verifiziert.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
wkh
2026-09-08 21:59:47 +02:00
co-authored by Claude Sonnet 5
parent 9015a991aa
commit 2c54537427
22 changed files with 1460 additions and 86 deletions
+136 -21
View File
@@ -11,10 +11,22 @@
* ----------------
* - Arithmetic is numeric. `null` (empty cell) counts as 0. A numeric-looking
* string is coerced; any other string yields `#VALUE!`.
* - Comparisons (`= <> > < >= <=`) are numeric too, result `1`/`0`.
* - Comparisons (`= <> > < >= <=`) result `1`/`0`. Two numbers (or an empty
* cell, which takes on `0`) compare numerically; two texts (or an empty
* cell, which takes on `''`) compare case-insensitively and trimmed --
* same convention as XLOOKUP's own text matching, see evalXLookup(). A
* number against a real (non-empty) text never crossmatches: `=`/`<>`
* answer definitively false/true, `> < >= <=` have no sane answer and
* yield `#VALUE!` (see compareValues()).
* - `&` concatenates as text: a number renders plain (`String(n)`, no
* locale formatting), an empty cell renders as `''` (see toText()).
* - Any error among operands / arguments propagates unchanged (first wins).
* - A range may only appear as a function argument. Used in scalar position it
* yields `#VALUE!`.
* - A `NamedRefNode` (a bare, unqualified name that isn't a cell) resolves
* live against `host.resolveNamedRange()` into the cell/range it stands
* for, then evaluates exactly like that reference would have (see
* `resolveNamedRef()`). Unknown name -> `#NAME?`.
* - Unknown sheet name -> `#REF`. Unknown function -> `#NAME?`.
* - Division by zero -> `#DIV/0!`.
*
@@ -27,7 +39,7 @@
* ordinary function call this can't be resolved any earlier.
*/
import type { BlockCallNode, BlockNode, CellNode, FuncNode, Node, Statement } from './parser';
import type { BlockCallNode, BlockNode, CellNode, FuncNode, NamedRefNode, Node, RangeNode, Statement } from './parser';
import { getFunction } from './functions';
import { expandRange } from './refs';
import { substitute } from './userFunctions';
@@ -58,6 +70,20 @@ function evalScalar(node: Node, host: EngineHost): CellValue {
if (isError(r)) return r;
return Math.pow(l, r);
}
if (isComparisonOp(node.op)) {
const lv = evalScalar(node.left, host);
if (isError(lv)) return lv;
const rv = evalScalar(node.right, host);
if (isError(rv)) return rv;
return compareValues(node.op, lv, rv);
}
if (node.op === '&') {
const lv = toText(evalScalar(node.left, host));
if (isError(lv)) return lv;
const rv = toText(evalScalar(node.right, host));
if (isError(rv)) return rv;
return lv + rv;
}
const l = toNumber(evalScalar(node.left, host));
if (isError(l)) return l;
const r = toNumber(evalScalar(node.right, host));
@@ -71,18 +97,6 @@ function evalScalar(node: Node, host: EngineHost): CellValue {
return l * r;
case '/':
return r === 0 ? new FormulaError('#DIV/0!') : l / r;
case '=':
return l === r ? 1 : 0;
case '<>':
return l !== r ? 1 : 0;
case '>':
return l > r ? 1 : 0;
case '<':
return l < r ? 1 : 0;
case '>=':
return l >= r ? 1 : 0;
case '<=':
return l <= r ? 1 : 0;
}
return new FormulaError('#ERR', `unknown operator ${node.op}`);
}
@@ -100,6 +114,12 @@ function evalScalar(node: Node, host: EngineHost): CellValue {
// A bare range can't collapse to a scalar.
return new FormulaError('#VALUE!', 'range used where a value is expected');
case 'namedref': {
const resolved = resolveNamedRef(node, host);
if (isError(resolved)) return resolved;
return evalScalar(resolved, host); // a resolved 1x1 range is a 'cell', so this recursion terminates
}
case 'func': {
// IF is the one built-in that must NOT evaluate its arguments eagerly
// (see functions.ts's FUNCTIONS.IF comment): only the taken branch may
@@ -390,7 +410,7 @@ function execStatement(
}
case 'for': {
const source = substitute(stmt.source, env);
const cells = rangeOrCellToCellNodes(source, stmt.varName);
const cells = rangeOrCellToCellNodes(source, stmt.varName, host);
for (const cell of cells) {
budget.steps++;
if (budget.steps > MAX_LOOP_STEPS) {
@@ -405,12 +425,21 @@ function execStatement(
}
/** A FOR loop's source, once names are resolved against the current
* environment, must be a single cell or a range -- never a computed value
* (mirrors "a range may only appear as a function argument" for ordinary
* formulas). Expands a range to its individual cells via `expandRange`
* (the same helper `evalArg` uses for a range function argument), carrying
* the same table/sheet qualifier through to each. */
function rangeOrCellToCellNodes(node: Node, loopVarName: string): CellNode[] {
* environment, must be a single cell, a range, or a named range -- never a
* computed value (mirrors "a range may only appear as a function argument"
* for ordinary formulas). Expands a range to its individual cells via
* `expandRange` (the same helper `evalArg` uses for a range function
* argument), carrying the same table/sheet qualifier through to each. A
* named range resolves first (via `host`), then falls into the same
* cell/range handling as any other reference. */
function rangeOrCellToCellNodes(node: Node, loopVarName: string, host: EngineHost): CellNode[] {
if (node.kind === 'namedref') {
const resolved = resolveNamedRef(node, host);
if (isError(resolved)) {
throw new Error(`FOR ${loopVarName} IN ...: unbekannter Name '${node.name}'`);
}
node = resolved;
}
if (node.kind === 'cell') return [node];
if (node.kind === 'range') {
return expandRange(node.start, node.end).map(
@@ -428,6 +457,11 @@ function rangeOrCellToCellNodes(node: Node, loopVarName: string): CellNode[] {
/** Evaluate one function argument into a flat list of values (ranges expand). */
function evalArg(node: Node, host: EngineHost): FormulaError | CellValue[] {
if (node.kind === 'namedref') {
const resolved = resolveNamedRef(node, host);
if (isError(resolved)) return resolved;
return evalArg(resolved, host); // now a plain 'cell'/'range', handled below/by evalScalar
}
if (node.kind === 'range') {
const tableId =
node.qualifier == null
@@ -440,6 +474,22 @@ function evalArg(node: Node, host: EngineHost): FormulaError | CellValue[] {
return [v];
}
/** Resolve a `NamedRefNode` to the concrete `CellNode`/`RangeNode` it stands
* for right now (via `host.resolveNamedRange()`), so every other place that
* already knows how to evaluate a cell/range (`evalScalar`, `evalArg`,
* `rangeOrCellToCellNodes`) can just fall through to that existing logic
* instead of duplicating it. `#NAME?` when the name is unknown -- the same
* error class an unknown function name gets. */
function resolveNamedRef(node: NamedRefNode, host: EngineHost): CellNode | RangeNode | FormulaError {
const def = host.resolveNamedRange(node.name);
if (!def) return new FormulaError('#NAME?', `unknown name '${node.name}'`);
const sheetField = def.sheetQualifier === undefined ? {} : { sheetQualifier: def.sheetQualifier };
if (def.start.col === def.end.col && def.start.row === def.end.row) {
return { kind: 'cell', qualifier: def.qualifier, ...sheetField, col: def.start.col, row: def.start.row };
}
return { kind: 'range', qualifier: def.qualifier, ...sheetField, start: def.start, end: def.end };
}
/** Coerce a cell value to a number for arithmetic, or return an error value. */
function toNumber(v: CellValue): number | FormulaError {
if (isError(v)) return v;
@@ -450,3 +500,68 @@ function toNumber(v: CellValue): number | FormulaError {
const n = Number(trimmed);
return Number.isFinite(n) ? n : new FormulaError('#VALUE!', `'${v}' is not a number`);
}
/** Coerce a cell value to text for `&` concatenation, or return an error
* value. An empty cell becomes `''` (not `'0'`), matching how it's ignored
* everywhere else in this engine rather than treated as a literal number.
* A number renders plain (`String(n)`, no thousands separator/locale --
* same "locale-neutral" convention as CSV/XLSX export), never `Intl`. */
function toText(v: CellValue): string | FormulaError {
if (isError(v)) return v;
if (v === null) return '';
return typeof v === 'string' ? v : String(v);
}
type ComparisonOp = '=' | '<>' | '>' | '<' | '>=' | '<=';
function isComparisonOp(op: string): op is ComparisonOp {
return op === '=' || op === '<>' || op === '>' || op === '<' || op === '>=' || op === '<=';
}
/** `= <> > < >= <=` on two already-evaluated scalars -- see the module
* comment's "Comparisons" bullet for the full rule. An empty cell (`null`)
* takes on whichever type the other side is (`0` for a number, `''` for
* text), so both `=A1=0` and `=A1=""` work on a genuinely empty `A1`. Text
* is matched the same way XLOOKUP matches text (case-insensitive, trimmed)
* -- kept as one convention across the engine rather than two. */
function compareValues(op: ComparisonOp, l: CellValue, r: CellValue): CellValue {
const lIsText = typeof l === 'string';
const rIsText = typeof r === 'string';
// A real number against real (non-empty) text is a genuine type mismatch
// -- an empty cell (null) is handled below, taking on whichever type the
// OTHER side is, so it never reaches this branch.
if ((typeof l === 'number' && rIsText) || (lIsText && typeof r === 'number')) {
if (op === '=') return 0;
if (op === '<>') return 1;
return new FormulaError('#VALUE!', 'text and number are not comparable with > < >= <=');
}
if (lIsText || rIsText) {
const lt = (l ?? '').toString().trim().toLowerCase();
const rt = (r ?? '').toString().trim().toLowerCase();
return applyCompare(op, lt, rt);
}
const ln = l === null ? 0 : (l as number);
const rn = r === null ? 0 : (r as number);
return applyCompare(op, ln, rn);
}
function applyCompare(op: ComparisonOp, l: number, r: number): CellValue;
function applyCompare(op: ComparisonOp, l: string, r: string): CellValue;
function applyCompare(op: ComparisonOp, l: number | string, r: number | string): CellValue {
switch (op) {
case '=':
return l === r ? 1 : 0;
case '<>':
return l !== r ? 1 : 0;
case '>':
return l > r ? 1 : 0;
case '<':
return l < r ? 1 : 0;
case '>=':
return l >= r ? 1 : 0;
case '<=':
return l <= r ? 1 : 0;
}
}