Files
numolus/src/engine/evaluator.ts
T
wkhandClaude Sonnet 5 2c54537427 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>
2026-09-08 21:59:47 +02:00

568 lines
24 KiB
TypeScript

/**
* AST evaluator.
*
* `evaluate(ast, host)` walks the tree and returns a {@link CellValue}. It never
* recurses into other cells directly -- it asks the {@link EngineHost} for their
* *already computed* values, which the recalculation driver guarantees are ready
* (topological order). Circular cells are detected up front by the graph module
* and never reach the evaluator.
*
* Evaluation model
* ----------------
* - Arithmetic is numeric. `null` (empty cell) counts as 0. A numeric-looking
* string is coerced; any other string yields `#VALUE!`.
* - 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!`.
*
* `kind: 'block'` custom functions (FOR/IF/RETURN "code" bodies -- see
* userFunctions.ts and src/engine/README.md's "Schleifen" section) are the
* one thing here that isn't a pure, static AST walk: a `BlockCallNode`
* embedded in a cell's AST is *interpreted* right here, at evaluation time
* (`evaluateBlockCall`, below `evalScalar`) -- real cell values are needed
* to decide an `IF` branch or when an early `RETURN` fires, so unlike an
* ordinary function call this can't be resolved any earlier.
*/
import type { BlockCallNode, BlockNode, CellNode, FuncNode, NamedRefNode, Node, RangeNode, Statement } from './parser';
import { getFunction } from './functions';
import { expandRange } from './refs';
import { substitute } from './userFunctions';
import { FormulaError, isError, type CellValue, type EngineHost } from './types';
export function evaluate(ast: Node, host: EngineHost): CellValue {
return evalScalar(ast, host);
}
function evalScalar(node: Node, host: EngineHost): CellValue {
switch (node.kind) {
case 'number':
return node.value;
case 'string':
return node.value;
case 'unary': {
const v = toNumber(evalScalar(node.operand, host));
if (isError(v)) return v;
return node.op === '-' ? -v : v;
}
case 'binary': {
if (node.op === '^') {
const l = toNumber(evalScalar(node.left, host));
if (isError(l)) return l;
const r = toNumber(evalScalar(node.right, host));
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));
if (isError(r)) return r;
switch (node.op) {
case '+':
return l + r;
case '-':
return l - r;
case '*':
return l * r;
case '/':
return r === 0 ? new FormulaError('#DIV/0!') : l / r;
}
return new FormulaError('#ERR', `unknown operator ${node.op}`);
}
case 'cell': {
const tableId =
node.qualifier == null
? host.homeTableId
: host.resolveTableName(node.qualifier, node.sheetQualifier ?? null);
if (!tableId) return new FormulaError('#REF', `unknown table '${node.qualifier}'`);
return host.getComputed(tableId, node.col, node.row);
}
case 'range':
// 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
// be computed, so e.g. IF(B1=0; 0; A1/B1) never divides by zero when
// B1=0. Special-cased here, before the generic eager-args dispatch.
const upperName = node.name.toUpperCase();
if (upperName === 'IF') return evalIf(node, host);
// XLOOKUP needs its two range arguments kept separate (not flattened
// into one combined list like every other function gets, see
// functions.ts's FUNCTIONS.XLOOKUP comment) and its if_not_found
// argument evaluated lazily -- both require handling here, before the
// generic eager/flattening dispatch below.
if (upperName === 'XLOOKUP') return evalXLookup(node, host);
const fn = getFunction(node.name);
if (!fn) return new FormulaError('#NAME?', `unknown function ${node.name}`);
const values: CellValue[] = [];
for (const arg of node.args) {
const part = evalArg(arg, host);
if (isError(part)) return part;
for (const v of part) {
if (isError(v)) return v;
values.push(v);
}
}
return fn(values);
}
case 'param':
// userFunctions.ts's expandUserFunctions() substitutes every ParamNode
// away before a cell's AST is ever stored -- this should be unreachable
// in practice. Fails as a cell error rather than however an unhandled
// case would otherwise behave, matching the engine's "no crash" rule.
return new FormulaError('#ERR', `unresolved parameter ${node.name}`);
case 'blockcall':
return evaluateBlockCall(node, host);
case 'omitted':
// an empty argument slot (`XLOOKUP(x;a;b;;-1)`'s skipped 4th
// argument) evaluates like any other missing value -- same as an
// empty cell. A function that needs to distinguish "omitted" from
// "evaluates to null" checks the raw arg Node's kind directly before
// ever calling evalScalar on it (see evalXLookup below).
return null;
}
}
/** IF(cond; then; else?) -- the real, short-circuiting implementation (see
* the `case 'func'` special-case above and functions.ts's FUNCTIONS.IF
* comment). Only `node.args[0]` is always evaluated; whichever of the
* other two branches is taken is evaluated next, and the other is never
* touched at all -- so an error (or, in a block-mode call, any other side
* effect) in the untaken branch never surfaces. Same truthy convention as
* everywhere else in this engine (`execStatement`'s `ifstmt` case, above):
* a number is truthy unless it's exactly 0; text/refs are truthy unless
* empty. */
function evalIf(node: FuncNode, host: EngineHost): CellValue {
if (node.args.length !== 2 && node.args.length !== 3) {
return new FormulaError('#ERR', 'IF expects 2 or 3 arguments');
}
const cond = evalScalar(node.args[0], host);
if (isError(cond)) return cond;
const truthy = typeof cond === 'number' ? cond !== 0 : cond !== null && cond !== '';
if (truthy) return evalScalar(node.args[1], host);
return node.args.length === 3 ? evalScalar(node.args[2], host) : 0;
}
/** Exact-match equality for XLOOKUP -- deliberately its own thing, not the
* `=` operator (which coerces both sides through `toNumber()` and so can
* never match on text, XLOOKUP's single most common real-world use: "find
* the row where this name/ID matches"). Numbers compare numerically, text
* compares case-insensitively with surrounding whitespace ignored (matches
* this engine's existing case-insensitive conventions -- function/table
* names, `!`/`::` qualifiers). A number is never treated as equal to text
* (no cross-type coercion -- predictable, no "5" == 5 surprises); two
* empty cells are equal to each other. */
function xlookupEquals(a: CellValue, b: CellValue): boolean {
if (isError(a) || isError(b)) return false;
if (a === null || b === null) return a === b;
if (typeof a === 'number' && typeof b === 'number') return a === b;
if (typeof a === 'string' && typeof b === 'string') {
return a.trim().toLowerCase() === b.trim().toLowerCase();
}
return false;
}
/**
* XLOOKUP(Suchwert; Suchbereich; Rückgabebereich; [WennNichtGefunden];
* [Vergleichsmodus]; [Suchmodus]) -- see functions.ts's FUNCTIONS.XLOOKUP
* comment for why this has to live here instead. `Suchbereich` and
* `Rückgabebereich` are evaluated separately via `evalArg` (which already
* returns one argument's values as their own array -- it's only `case
* 'func'`'s generic dispatch above that flattens every argument together)
* and must come out the same length.
*
* `Vergleichsmodus`: `0` (default) exact match; `-1` exact match or next
* *smaller*; `1` exact match or next *larger* (both numeric-only -- a
* nearest match against text isn't supported, documented scope limit,
* see README.md). `2` (Excel's wildcard mode) is not supported.
* `Suchmodus`: `1` (default) first-to-last; `-1` last-to-first. Excel's
* binary-search modes (`2`/`-2`) are not supported -- pointless at this
* app's table sizes and would require an assumed-sorted precondition this
* engine can't verify.
*
* No match and no `WennNichtGefunden` given -> `#N/A` (a documented
* extension to this engine's original error set, exactly the same
* reasoning `#VALUE!` was added under). `WennNichtGefunden` is evaluated
* lazily -- only when actually needed, same short-circuit principle as
* `IF`'s branches -- so an error inside it never surfaces on a successful
* match.
*/
function evalXLookup(node: FuncNode, host: EngineHost): CellValue {
const args = node.args;
if (args.length < 3 || args.length > 6) {
return new FormulaError('#ERR', 'XLOOKUP expects 3 to 6 arguments');
}
// An optional argument counts as "given" only if the slot both exists AND
// wasn't left empty (`XLOOKUP(x;a;b;;-1)`'s skipped 4th argument, an
// OmittedNode -- see parser.ts). `args.length` alone can't tell "not
// provided at all" apart from "explicitly skipped to reach a later one".
const given = (i: number) => i < args.length && args[i].kind !== 'omitted';
const lookupValue = evalScalar(args[0], host);
if (isError(lookupValue)) return lookupValue;
const lookupArr = evalArg(args[1], host);
if (isError(lookupArr)) return lookupArr;
const returnArr = evalArg(args[2], host);
if (isError(returnArr)) return returnArr;
if (lookupArr.length !== returnArr.length) {
return new FormulaError(
'#VALUE!',
'XLOOKUP: Suchbereich und Rückgabebereich müssen gleich viele Zellen haben',
);
}
for (const v of lookupArr) if (isError(v)) return v;
for (const v of returnArr) if (isError(v)) return v;
let matchMode = 0;
if (given(4)) {
const mm = toNumber(evalScalar(args[4], host));
if (isError(mm)) return mm;
if (mm !== -1 && mm !== 0 && mm !== 1) {
return new FormulaError(
'#ERR',
'XLOOKUP: Vergleichsmodus muss -1, 0 oder 1 sein (2 wird nicht unterstützt)',
);
}
matchMode = mm;
}
let searchMode = 1;
if (given(5)) {
const sm = toNumber(evalScalar(args[5], host));
if (isError(sm)) return sm;
if (sm !== 1 && sm !== -1) {
return new FormulaError(
'#ERR',
'XLOOKUP: Suchmodus muss 1 oder -1 sein (2/-2 werden nicht unterstützt)',
);
}
searchMode = sm;
}
const n = lookupArr.length;
const order = searchMode === -1 ? [...Array(n).keys()].reverse() : [...Array(n).keys()];
if (matchMode === 0) {
for (const i of order) {
if (xlookupEquals(lookupValue, lookupArr[i])) return returnArr[i];
}
} else if (typeof lookupValue === 'number') {
// Nearest-match modes: numeric only. An exact match still wins outright
// wherever it's found; otherwise track the closest qualifying candidate.
let bestIdx = -1;
let bestVal = 0;
for (const i of order) {
const cand = lookupArr[i];
if (xlookupEquals(lookupValue, cand)) return returnArr[i];
if (typeof cand !== 'number') continue;
const qualifies = matchMode === -1 ? cand < lookupValue : cand > lookupValue;
if (!qualifies) continue;
const better =
bestIdx === -1 || (matchMode === -1 ? cand > bestVal : cand < bestVal);
if (better) {
bestIdx = i;
bestVal = cand;
}
}
if (bestIdx !== -1) return returnArr[bestIdx];
}
if (given(3)) return evalScalar(args[3], host);
return new FormulaError('#N/A', 'XLOOKUP: kein Treffer');
}
// ---------------------------------------------------------------------------
// `kind: 'block'` custom functions: a small tree-walking interpreter for a
// BlockCallNode's `block` (see parser.ts's grammar comment and
// src/engine/README.md's "Schleifen" section). Everything else in this file
// is a pure, static AST walk; this is the one place real control flow
// (branching, early exit) happens, so it needs real cell values as it goes
// -- unlike a `kind: 'expression'` call, which is fully resolved to a plain
// expression at parse time and never reaches any special-case code here.
// ---------------------------------------------------------------------------
/** Thrown by a `return` statement to unwind out of `execBlock`/`execStatement`
* immediately, carrying the function's result. Caught only by
* `evaluateBlockCall`, right where it's thrown from -- a plain, standard
* "unwind to a known boundary" pattern for early-exit control flow. */
class ReturnSignal {
constructor(public readonly value: CellValue) {}
}
/** Total loop iterations one `evaluateBlockCall` may run, summed across
* every FOR loop (including nested ones) it executes -- a safety net
* against a pathologically large range (e.g. `FOR x IN A1:Z10000 DO ...`),
* matching the "no crash, no hang" guarantee. A chain of nested
* block-kind calls is separately bounded by expandUserFunctions()'s own
* MAX_EXPANSION_DEPTH, so total work across one formula stays finite
* either way. Generous for anything this app's tables realistically hold. */
const MAX_LOOP_STEPS = 10_000;
function evaluateBlockCall(node: BlockCallNode, host: EngineHost): CellValue {
const env = new Map<string, Node>(node.paramNames.map((p, i) => [p.toUpperCase(), node.args[i]]));
const budget = { steps: 0 };
try {
execBlock(node.block, env, host, budget);
} catch (e) {
if (e instanceof ReturnSignal) return e.value;
throw e; // a real problem (bad FOR source, step budget, ...) -- propagates
// to evaluateCell()'s existing catch-all in workbook.ts, becoming #ERR,
// exactly like a plain evaluation exception already does.
}
throw new Error(`'${node.label}' ended without RETURN`);
}
function execBlock(block: BlockNode, env: Map<string, Node>, host: EngineHost, budget: { steps: number }): void {
for (const stmt of block.statements) {
execStatement(stmt, env, host, budget);
}
}
/** Resolve `expr` against the current environment (substitute() replaces
* any name reference with its bound Node -- the caller's argument, or an
* earlier statement's/iteration's literal result) and evaluate it. */
function evalInEnv(expr: Node, env: Map<string, Node>, host: EngineHost): CellValue {
return evalScalar(substitute(expr, env), host);
}
/** Wrap an already-computed scalar back into a literal `Node` so it can be
* stored in `env` and later be resolved/evaluated exactly like any other
* bound name (a parameter, or the caller's argument expression). `null`
* (an empty cell's value) becomes the number `0`, matching how every other
* arithmetic context in this engine already treats an empty cell. Callers
* check `isError` before ever calling this -- an error value is never
* passed in (it aborts the whole function via `ReturnSignal` instead). */
function literalNodeOf(v: number | string | null): Node {
if (v === null) return { kind: 'number', value: 0 };
return typeof v === 'number' ? { kind: 'number', value: v } : { kind: 'string', value: v };
}
function execStatement(
stmt: Statement,
env: Map<string, Node>,
host: EngineHost,
budget: { steps: number },
): void {
switch (stmt.kind) {
case 'assign': {
const v = evalInEnv(stmt.value, env, host);
if (isError(v)) throw new ReturnSignal(v); // an error in a plain assignment aborts the function with that error
env.set(stmt.name.toUpperCase(), literalNodeOf(v));
return;
}
case 'return': {
const v = evalInEnv(stmt.value, env, host);
throw new ReturnSignal(v);
}
case 'ifstmt': {
const c = evalInEnv(stmt.cond, env, host);
if (isError(c)) throw new ReturnSignal(c);
const truthy = typeof c === 'number' ? c !== 0 : c !== null && c !== '';
if (truthy) execBlock(stmt.then, env, host, budget);
else if (stmt.else) execBlock(stmt.else, env, host, budget);
return;
}
case 'for': {
const source = substitute(stmt.source, env);
const cells = rangeOrCellToCellNodes(source, stmt.varName, host);
for (const cell of cells) {
budget.steps++;
if (budget.steps > MAX_LOOP_STEPS) {
throw new Error(`FOR-Schleife(n) über zu viele Zellen (> ${MAX_LOOP_STEPS} insgesamt)`);
}
env.set(stmt.varName.toUpperCase(), cell);
execBlock(stmt.body, env, host, budget);
}
return;
}
}
}
/** A FOR loop's source, once names are resolved against the current
* 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(
(c): CellNode => ({
kind: 'cell',
qualifier: node.qualifier,
...(node.sheetQualifier === undefined ? {} : { sheetQualifier: node.sheetQualifier }),
col: c.col,
row: c.row,
}),
);
}
throw new Error(`FOR ${loopVarName} IN ...: die Quelle muss ein Zell- oder Bereichsbezug sein`);
}
/** 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
? 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));
}
const v = evalScalar(node, host);
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;
if (v === null) return 0;
if (typeof v === 'number') return v;
const trimmed = v.trim();
if (trimmed === '') return 0;
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;
}
}