Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,12 +33,13 @@
"typecheck": "vue-tsc --noEmit",
"lint": "eslint src demo",
"test": "vitest run",
"test:engine": "tsx --test \"tests/engine/test_*.ts\"",
"test:watch": "vitest",
"test:fixtures": "tsx tests/engine/run-all-fixtures.ts",
"test:calculator": "tsx tests/engine/run-calculator-tests.ts",
"test:evaluator": "tsx tests/engine/run-evaluator-tests.ts",
"test:functions": "tsx tests/engine/test-functions.ts",
"test:all": "vitest run && tsx tests/engine/run-all-fixtures.ts"
"test:all": "vitest run && yarn run test:engine && tsx tests/engine/run-all-fixtures.ts"
},
"peerDependencies": {
"gui-chat-protocol": "^2.0.0",
Expand Down
520 changes: 218 additions & 302 deletions src/engine/calculator.ts

Large diffs are not rendered by default.

81 changes: 81 additions & 0 deletions src/engine/cellBuilder.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
/**
* Build a SpreadsheetCell from the raw input captured by the mini
* editor (type + value / formula / format). Extracted from
* `saveMiniEditor` in `src/plugins/spreadsheet/View.vue` where it
* was inlined as ~30 lines of nested if/else that pushed the
* surrounding function over the cognitive-complexity threshold.
*
* Pure — no refs, no DOM, no side effects. Given the same inputs
* it always returns the same SpreadsheetCell. Tested in
* `test/plugins/spreadsheet/engine/test_cellBuilder.ts`.
*/

import type { SpreadsheetCell } from "./types.js";

/** Inputs to the cell builder. Mirrors the mini editor refs in the
* View but as plain values so unit tests don't need a Vue runtime. */
export interface MiniEditorInput {
/** "string" → value is stored as-is as a string.
* Anything else → the `formula` field is parsed (formula / number / raw string). */
type: string;
/** Used when type === "string". Coerced to string. */
value: unknown;
/** Used when type !== "string". Trimmed before classification. */
formula?: string;
/** Optional format code (e.g. "$#,##0.00"). */
format?: string;
}

// Anchored at the start of the input (after optional unary +/-) so we
// only treat expressions that clearly begin with a function call as
// formulas. Unanchored would match "abc FOO(" inside ordinary text.
const FORMULA_FUNCTION_CALL = /^[-+]?\s*[A-Z]+\s*\(/i;

// `A1 + B2` style — cell reference next to an arithmetic operator.
const FORMULA_CELL_OP = /[A-Z]+\d+\s*[+\-*/^]/;

// `6/100`, `5 * 2` — arithmetic between two literal numbers.
const FORMULA_NUMERIC_OP = /\d+\s*[+\-*/^]\s*\d+/;

// Strict numeric literal. `parseFloat` accepts trailing junk
// ("42abc" → 42) which silently corrupts user input; this anchor
// ensures the ENTIRE trimmed string is a number.
const STRICT_NUMBER = /^[-+]?(?:\d+\.?\d*|\.\d+)(?:[eE][-+]?\d+)?$/;

/**
* Best-effort formula detection. The rules are conservative enough
* that plain text like "hello world" stays as text, but any input
* with arithmetic operators or function calls is treated as a
* formula and gets the "=" prefix the engine expects.
*/
export function looksLikeFormula(input: string): boolean {
return FORMULA_FUNCTION_CALL.test(input) || FORMULA_CELL_OP.test(input) || FORMULA_NUMERIC_OP.test(input);
}

/**
* Parse the raw (non-string-type) editor input into a cell value.
* Priority: formula > number > raw string > empty string.
*/
export function parseNonStringInput(raw: string): number | string {
const input = raw.trim();
if (input === "") return "";
if (looksLikeFormula(input)) return `=${input}`;
return STRICT_NUMBER.test(input) ? Number(input) : input;
}

/**
* Build the full SpreadsheetCell from a mini editor input record.
* String type short-circuits to `{ v: String(value) }`.
* Everything else goes through parseNonStringInput for formula /
* number / text classification, then optionally attaches `f`.
*/
export function buildCellFromInput(input: MiniEditorInput): SpreadsheetCell {
if (input.type === "string") {
return { v: String(input.value) };
}
const cell: SpreadsheetCell = { v: parseNonStringInput(input.formula ?? "") };
if (input.format && input.format.length > 0) {
cell.f = input.format;
}
return cell;
}
26 changes: 26 additions & 0 deletions src/engine/cellEmpty.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
/**
* Telling a genuinely empty cell apart from one that holds the number 0.
*
* The calculator reads a blank cell as 0 for arithmetic (`=A1+1` on a blank A1
* is 1, as in Excel). But an aggregate must not: `AVERAGE` divides by the count
* of real values, and `COUNT` counts numbers — a blank that reads as 0 inflates
* the denominator and the count. So range collection needs to skip the blanks,
* which means distinguishing them from a stored 0, which this does.
*/

import { isObj } from "./guards";

/** True when a cell holds no value at all — absent, null, or an empty/whitespace
* string, in either the bare or the `{ v }` form. A cell containing the number
* 0, `false`, or any non-empty text is NOT empty. */
export function isEmptyCell(cell: unknown): boolean {
if (cell === null || cell === undefined) return true;
if (typeof cell === "string") return cell.trim() === "";
if (isObj(cell)) {
if (!("v" in cell)) return true;
const value = (cell as { v: unknown }).v;
if (value === null || value === undefined) return true;
return typeof value === "string" && value.trim() === "";
}
return false;
}
59 changes: 59 additions & 0 deletions src/engine/cellFormatting.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
/**
* Cell display formatting
*
* Turns a cell's raw calculated value into its display value (currency,
* percentage, date, ...). Pure — no engine state — so cross-sheet reference
* resolution can deliberately SKIP it and keep raw serial numbers, while the
* final output pass applies it for presentation.
*/

import { formatNumber } from "./formatter";
import { isRecord } from "./guards";
import { isSpreadsheetErrorValue } from "./spreadsheet-errors";
import type { CellValue, SpreadsheetCell, StoredCellValue } from "./types";

// Integer serials the engine is willing to auto-format as dates without an
// explicit format code: ~Jul 1998 (36000) through ~Dec 2073 (63499). Narrow on
// purpose so ordinary sums/averages are not mistaken for dates.
const DATE_SERIAL_MIN = 36000;
const DATE_SERIAL_MAX = 63499;

const isSpreadsheetCell = (value: unknown): value is SpreadsheetCell => isRecord(value) && "v" in value;

/** An integer within the date-serial window — a calculated number the engine
* should display as a date when the cell carries no explicit format. */
export const isLikelyDateSerial = (value: CellValue): boolean =>
typeof value === "number" && Number.isInteger(value) && value >= DATE_SERIAL_MIN && value <= DATE_SERIAL_MAX;

/**
* Resolve the display value of one cell from its original definition and its
* calculated value.
*
* - A formula error renders as its code, so the cell still reads `#NUM!`.
* - Explicit format code wins (currency, percentage, date, ...).
* - A formula that produced a date serial auto-formats as a date.
* - Everything else (text, plain numbers, empty) passes through unchanged.
*
* The result is always a STORED value: this is the boundary where a computed
* error becomes the text a cell shows and a workbook serializes.
*/
export const formatCellForDisplay = (originalCell: unknown, calculatedValue: CellValue, preferDDMMYYYY: boolean): StoredCellValue => {
if (isSpreadsheetErrorValue(calculatedValue)) {
return calculatedValue.code;
}
if (!isSpreadsheetCell(originalCell) || typeof calculatedValue !== "number") {
return calculatedValue;
}

const explicitFormat = typeof originalCell.f === "string" ? originalCell.f : "";
if (explicitFormat) {
return formatNumber(calculatedValue, explicitFormat);
}

const isFormula = typeof originalCell.v === "string" && originalCell.v.startsWith("=");
if (isFormula && isLikelyDateSerial(calculatedValue)) {
return formatNumber(calculatedValue, preferDDMMYYYY ? "DD/MM/YYYY" : "MM/DD/YYYY");
}

return calculatedValue;
};
26 changes: 26 additions & 0 deletions src/engine/coerce-boolean.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import type { CellValue } from "./types";
import { isSpreadsheetErrorValue } from "./spreadsheet-errors";

/** Excel-style truthiness, shared by IF and AND/OR/NOT so the same value cannot
* read as true in one function and false in another. A number is false only
* when 0; blank and empty text are false; the words `true`/`false` are their
* logical values (case-insensitively); a numeric string follows its number
* (`"0"` → false); any other non-empty text is true. */
export function coerceToBoolean(value: CellValue | null | undefined): boolean {
if (typeof value === "boolean") return value;
if (value === null || value === undefined) return false;
if (typeof value === "number") return value !== 0;
// Pinned: an error reads as non-empty text, i.e. true — the same answer the
// error strings gave before they became values.
if (isSpreadsheetErrorValue(value)) return true;

const text = value.trim();
if (text === "") return false;

const lowered = text.toLowerCase();
if (lowered === "true") return true;
if (lowered === "false") return false;

const asNumber = Number(text);
return Number.isNaN(asNumber) ? true : asNumber !== 0;
}
182 changes: 182 additions & 0 deletions src/engine/condition.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
/**
* Evaluating a spreadsheet condition without running it as code.
*
* A condition is one comparison, or a bare value tested for truthiness. That is
* the whole grammar — small enough to read directly, which is the point: the
* previous implementation handed the substituted text to `eval`, so a cell
* containing `globalThis.x = 1` executed when any IFS referenced it.
*/

import type { CellValue } from "./types";
import { isSpreadsheetErrorValue } from "./spreadsheet-errors";

export type ComparisonOperator = ">=" | "<=" | "<>" | "!=" | "==" | "=" | ">" | "<";

// Longest first: `>=` must win over `>`, and `<>` / `<=` over `<`.
const OPERATORS: readonly ComparisonOperator[] = [">=", "<=", "<>", "!=", "==", "=", ">", "<"];

export interface Comparison {
left: string;
operator: ComparisonOperator;
right: string;
}

/** Remove parentheses that wrap the WHOLE expression, repeatedly. `(1>0)` is
* the same condition as `1>0`, but `(A)=(B)` is not `A)=(B` — the leading `(`
* closes before the end, so it wraps only its own operand and must stay.
* Unbalanced input is left untouched rather than guessed at. */
export function stripOuterParens(condition: string): string {
let text = condition.trim();
while (text.startsWith("(") && text.endsWith(")")) {
let depth = 0;
let quote: string | null = null;
let wrapsAll = true;
for (let index = 0; index < text.length; index++) {
const char = text[index];
if (quote !== null) {
if (char === "\\")
index++; // skip the escaped character
else if (char === quote) quote = null;
continue;
}
if (char === '"' || char === "'") {
quote = char;
continue;
}
if (char === "(") depth++;
else if (char === ")") {
depth--;
// Back to zero before the end means this `(` closed early.
if (depth === 0 && index < text.length - 1) {
wrapsAll = false;
break;
}
if (depth < 0) return text; // unbalanced
}
}
if (!wrapsAll || depth !== 0) return text;
text = text.slice(1, -1).trim();
}
return text;
}

/** Split a condition into its two sides, or null when it holds no comparison.
* Only the FIRST top-level operator counts — `a>b>c` is not a chain here, and
* treating it as one is what let `1=1=1` reach a JS parser before.
*
* "Top-level" means outside quotes: a cell holding `a>b` substitutes into the
* condition as `"a>b"`, and splitting on that `>` would compare two fragments
* of one string literal. */
export function splitComparison(condition: string): Comparison | null {
const text = stripOuterParens(condition);
let quote: string | null = null;
for (let index = 0; index < text.length; index++) {
const char = text[index];
if (quote !== null) {
if (char === "\\")
index++; // skip the escaped character
else if (char === quote) quote = null;
continue;
}
if (char === '"' || char === "'") {
quote = char;
continue;
}
for (const operator of OPERATORS) {
if (!text.startsWith(operator, index)) continue;
return { left: text.slice(0, index).trim(), operator, right: text.slice(index + operator.length).trim() };
}
}
return null;
}

/** Strip one matching pair of surrounding quotes, and undo the `\"` / `\\`
* escaping that `renderConditionOperand` applies when it quotes a cell value. */
function unquote(text: string): { value: string; quoted: boolean } {
const isQuoted = text.length >= 2 && ((text.startsWith('"') && text.endsWith('"')) || (text.startsWith("'") && text.endsWith("'")));
if (!isQuoted) return { value: text, quoted: false };
const inner = text.slice(1, -1).replace(/\\(["'\\])/g, "$1");
return { value: inner, quoted: true };
}

/** Read an operand as the value it denotes: a quoted string stays text, a
* numeric literal becomes a number, `TRUE`/`FALSE` become booleans, and
* anything else stays the text it already is. */
export function readOperand(raw: string): CellValue {
const { value, quoted } = unquote(raw.trim());
if (quoted) return value;
if (value === "") return "";
const upper = value.toUpperCase();
if (upper === "TRUE") return true;
if (upper === "FALSE") return false;
// `Number` rather than `parseFloat`: it rejects trailing garbage, so "12abc"
// stays text instead of becoming 12.
const numeric = Number(value);
return Number.isNaN(numeric) ? value : numeric;
}

function compareValues(left: CellValue, right: CellValue): number | null {
if (typeof left === "number" && typeof right === "number") return left - right;
if (typeof left === "boolean" || typeof right === "boolean") return null;
return String(left).localeCompare(String(right));
}

function applyOperator(operator: ComparisonOperator, left: CellValue, right: CellValue): boolean {
// Equality does not need an ordering, so it works for every type pair —
// including the boolean combinations `compareValues` refuses to order.
if (operator === "=" || operator === "==") return left === right;
if (operator === "<>" || operator === "!=") return left !== right;
const ordering = compareValues(left, right);
if (ordering === null) return false;
if (operator === ">") return ordering > 0;
if (operator === ">=") return ordering >= 0;
if (operator === "<") return ordering < 0;
return ordering <= 0;
}

/** Whether a resolved condition value counts as satisfied. Mirrors the
* spreadsheet convention rather than JavaScript's: 0 and an empty string are
* false, every other value is true. */
function valueIsTruthy(value: CellValue): boolean {
if (typeof value === "boolean") return value;
if (typeof value === "number") return value !== 0;
return value !== "";
}

/** True when a bare (non-comparison) condition counts as satisfied. */
export function isTruthyCondition(raw: string): boolean {
return valueIsTruthy(readOperand(stripOuterParens(raw)));
}

/** Evaluate a condition — one comparison, or a value tested for truthiness.
* Never executes its input. */
export function evaluateCondition(condition: string): boolean {
const comparison = splitComparison(condition);
if (!comparison) return isTruthyCondition(condition);
return applyOperator(comparison.operator, readOperand(comparison.left), readOperand(comparison.right));
}

/** Like `evaluateCondition`, but each operand is resolved by `evaluate` — so a
* caller holding the engine can compute arithmetic and sub-expressions
* (`5+1>10`) instead of reading each side as a bare string. It still never runs
* the condition as code: it only splits on the top-level comparison and
* compares the two resolved values. */
export function evaluateConditionValues(condition: string, evaluate: (operand: string) => CellValue): boolean {
const comparison = splitComparison(condition);
if (!comparison) return valueIsTruthy(evaluate(stripOuterParens(condition)));
return applyOperator(comparison.operator, evaluate(comparison.left), evaluate(comparison.right));
}

/** Render a cell's value as an operand for a condition string. A string is
* quoted, with its own quotes and backslashes escaped, so its contents cannot
* be re-parsed as operators — `evaluateCondition` then unquotes it back to the
* original text. A missing value becomes an empty string; numbers and booleans
* render as themselves. */
export function renderConditionOperand(value: CellValue | null | undefined): string {
if (value === null || value === undefined) return '""';
// A formula error renders as its quoted code, so a condition compares it as
// the text a cell shows rather than as a bare `#NUM!` token.
const text = isSpreadsheetErrorValue(value) ? value.code : value;
if (typeof text === "string") return `"${text.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
return text.toString();
}
Loading
Loading