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:
+136
-21
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user