diff --git a/src/engine/arrayEvaluation.ts b/src/engine/arrayEvaluation.ts index d861205..d5ca51d 100644 --- a/src/engine/arrayEvaluation.ts +++ b/src/engine/arrayEvaluation.ts @@ -11,7 +11,7 @@ import { indexToColumn } from "./parser"; import { isFormulaError, unknownError } from "./formulaError"; import { isSpreadsheetErrorValue, NA_ERROR, spreadsheetError, type SpreadsheetError } from "./spreadsheet-errors"; import { renderOperand } from "./expressionText"; -import type { EvaluatorContext } from "./evaluator"; +import type { EvaluatorContext } from "./evaluatorContext"; /** An array operand as the element formulas see it: its shape, and what stands in its place in the * formula for one element — the text written there, or the error that element is. */ diff --git a/src/engine/evaluator.ts b/src/engine/evaluator.ts index e94609b..2f1c461 100644 --- a/src/engine/evaluator.ts +++ b/src/engine/evaluator.ts @@ -4,7 +4,7 @@ * Evaluates spreadsheet formulas including functions, cell references, and arithmetic */ -import { CALL_NAMES, FUNCTION_NAME_SOURCE, functionRegistry, isCallableName, tooFewArgumentsError, type CellCoordinates } from "./registry"; +import { CALL_NAMES, FUNCTION_NAME_SOURCE, functionRegistry, isCallableName, tooFewArgumentsError } from "./registry"; const SIMPLE_CALL = new RegExp(`^(${FUNCTION_NAME_SOURCE})\\((.*)\\)$`, "i"); const CALL_START = new RegExp(`^(${FUNCTION_NAME_SOURCE})\\(`, "i"); @@ -27,28 +27,7 @@ import { booleansAsNumbers, logicalLiteralsAsBooleans, loneBoolean } from "./log import { textOperandsAsNumbers } from "./textArithmetic"; import { endOfStringLiteral, findCellRefs, isSafeConcatExpression, parseFunctionArgs, renderOperand, substituteCellRefs } from "./expressionText"; import { evaluateOverArrays } from "./arrayEvaluation"; - -/** - * Evaluation context for formulas - */ -export interface EvaluatorContext { - getCellValue: (ref: string) => CellValue; - getRangeValues: (range: string) => CellValue[]; - getRangeValuesRaw?: ((range: string) => CellValue[]) | undefined; - getRangeValuesKeepingBlanks?: ((range: string) => CellValue[]) | undefined; - countFilledCells?: ((range: string) => number) | undefined; - evaluateFormula: (formula: string) => CellValue; - /** A formula's value, a table included; see FunctionContext. */ - evaluateValue?: ((formula: string) => FormulaValue) | undefined; - getRangeGrid?: ((range: string) => CellValue[][]) | undefined; - /** Reading order for an ambiguous slash date; see engine/date-locale.ts. */ - preferDDMMYYYY?: boolean | undefined; - /** Whether the referenced cell holds nothing. `getCellValue` reads such a cell as 0, which - * is right for arithmetic and wrong for `&`, where a blank cell joins as "". */ - isCellEmpty?: ((ref: string) => boolean) | undefined; - currentCell?: CellCoordinates | undefined; - currentSheet?: string | undefined; -} +import type { EvaluatorContext } from "./evaluatorContext"; /** Run `new Function` on an expression the callers have already gated with a * character allowlist. A JS parse/eval failure becomes #ERROR! — a genuinely diff --git a/src/engine/evaluatorContext.ts b/src/engine/evaluatorContext.ts new file mode 100644 index 0000000..5cf7fa2 --- /dev/null +++ b/src/engine/evaluatorContext.ts @@ -0,0 +1,27 @@ +// What a formula is evaluated against: reading cells and ranges, evaluating a nested formula, and +// where the formula is. + +import type { CellCoordinates } from "./registry"; +import type { CellValue, FormulaValue } from "./types"; + +/** + * Evaluation context for formulas + */ +export interface EvaluatorContext { + getCellValue: (ref: string) => CellValue; + getRangeValues: (range: string) => CellValue[]; + getRangeValuesRaw?: ((range: string) => CellValue[]) | undefined; + getRangeValuesKeepingBlanks?: ((range: string) => CellValue[]) | undefined; + countFilledCells?: ((range: string) => number) | undefined; + evaluateFormula: (formula: string) => CellValue; + /** A formula's value, a table included; see FunctionContext. */ + evaluateValue?: ((formula: string) => FormulaValue) | undefined; + getRangeGrid?: ((range: string) => CellValue[][]) | undefined; + /** Reading order for an ambiguous slash date; see engine/date-locale.ts. */ + preferDDMMYYYY?: boolean | undefined; + /** Whether the referenced cell holds nothing. `getCellValue` reads such a cell as 0, which + * is right for arithmetic and wrong for `&`, where a blank cell joins as "". */ + isCellEmpty?: ((ref: string) => boolean) | undefined; + currentCell?: CellCoordinates | undefined; + currentSheet?: string | undefined; +} diff --git a/src/engine/index.ts b/src/engine/index.ts index 67bad6b..cb2006d 100644 --- a/src/engine/index.ts +++ b/src/engine/index.ts @@ -18,6 +18,7 @@ export * from "./translateFormula"; export * from "./formulaError"; export * from "./spreadsheet-errors"; export * from "./evaluator"; +export * from "./evaluatorContext"; export * from "./expressionText"; export * from "./calculator"; export * from "./formulaRefs"; diff --git a/src/engine/rangeReading.ts b/src/engine/rangeReading.ts index 687c1f2..d6e11ce 100644 --- a/src/engine/rangeReading.ts +++ b/src/engine/rangeReading.ts @@ -22,7 +22,7 @@ export interface RangeReadOptions { /** Before a range is read, the spills that could reach any of its cells: those reaching its * bottom-right corner, since a table spills right and down. */ -export function ensureSpillsCovering(sheet: OpenSheet, coords: readonly CellCoordinates[]): void { +function ensureSpillsCovering(sheet: OpenSheet, coords: readonly CellCoordinates[]): void { const corner = coords.reduce((far, { row, col }) => ({ row: Math.max(far.row, row), col: Math.max(far.col, col) }), { row: -1, col: -1 }); if (corner.row >= 0) sheet.ensureSpillsReaching(corner.row, corner.col); } diff --git a/src/vue/editorSelection.ts b/src/vue/editorSelection.ts index 8101b8c..1dde50c 100644 --- a/src/vue/editorSelection.ts +++ b/src/vue/editorSelection.ts @@ -1,7 +1,6 @@ // With the JSON editor open, a click on a table cell selects that cell's JSON in the editor. -import { findCellJsonPosition } from "../engine"; -import { normalizeSheetData } from "./sheetRows"; +import { findCellJsonPosition, normalizeData } from "../engine"; /** Select, in `textarea` holding `editableJson`, the JSON of the cell at `rowIndex`, `colIndex` of the * sheet at `sheetIndex`, and scroll it into view; nothing when the cell is not in the JSON. */ @@ -10,7 +9,7 @@ export function selectCellInEditor(textarea: HTMLTextAreaElement, editableJson: const sheets = JSON.parse(editableJson); const currentSheet = sheets[sheetIndex]; if (!currentSheet || !currentSheet.data) return; - const normalizedData = normalizeSheetData(currentSheet.data); + const normalizedData = normalizeData(currentSheet.data); if (!normalizedData[rowIndex] || normalizedData[rowIndex][colIndex] === undefined) { return; } diff --git a/src/vue/sheetRows.ts b/src/vue/sheetRows.ts deleted file mode 100644 index a6d0e66..0000000 --- a/src/vue/sheetRows.ts +++ /dev/null @@ -1,35 +0,0 @@ -// A sheet's rows as the editors read them from the JSON they edit. - -import { isObj } from "../engine/guards"; - -/** - * Normalize malformed data structures - * Some models generate flat arrays instead of 2D arrays - fix them - */ -export function normalizeSheetData(data: unknown): unknown[][] { - if (!Array.isArray(data) || data.length === 0) { - return []; - } - - // If data is already a 2D array, return as-is - if (Array.isArray(data[0])) { - return data; - } - - // If data is a flat array of cell objects, convert to 2D by pairing cells - // Pattern: [cell1, cell2, cell3, cell4] -> [[cell1, cell2], [cell3, cell4]] - if (isObj(data[0])) { - const rows: unknown[][] = []; - for (let i = 0; i < data.length; i += 2) { - const row = [data[i]]; - if (i + 1 < data.length) { - row.push(data[i + 1]); - } - rows.push(row); - } - return rows; - } - - // Unknown structure - return empty - return []; -} diff --git a/src/vue/useMiniEditor.ts b/src/vue/useMiniEditor.ts index b13de1a..0399ded 100644 --- a/src/vue/useMiniEditor.ts +++ b/src/vue/useMiniEditor.ts @@ -3,9 +3,8 @@ import { ref, type Ref } from "vue"; import type { SpreadsheetSheet, SpreadsheetCell } from "../core/types"; -import { extractCellReferences, buildCellFromInput } from "../engine"; +import { extractCellReferences, buildCellFromInput, normalizeData } from "../engine"; import { errorMessage as formatErrorMessage, isRecord } from "../engine/guards"; -import { normalizeSheetData } from "./sheetRows"; import { getArrowKeyOffset, isWithinSheetBounds } from "./keyboardNav"; /** What the mini editor works on: the editor's JSON, the sheet shown, and how a change is saved. */ @@ -45,7 +44,7 @@ export function useMiniEditor({ editableData, activeSheetIndex, persistSheets }: } // Normalize the data in case it's malformed - const normalizedData = normalizeSheetData(currentSheet.data); + const normalizedData = normalizeData(currentSheet.data); if (!normalizedData[rowIndex] || normalizedData[rowIndex][colIndex] === undefined) { return; @@ -119,7 +118,7 @@ export function useMiniEditor({ editableData, activeSheetIndex, persistSheets }: const { row, col } = miniEditorCell.value; // Normalize the data in case it's malformed - const normalizedData = normalizeSheetData(currentSheet.data); + const normalizedData = normalizeData(currentSheet.data); // Ensure the row exists while (normalizedData.length <= row) { diff --git a/tests/engine/run-evaluator-tests.ts b/tests/engine/run-evaluator-tests.ts index 8255f9e..3ef71c0 100644 --- a/tests/engine/run-evaluator-tests.ts +++ b/tests/engine/run-evaluator-tests.ts @@ -3,7 +3,8 @@ * Run with: npx tsx tests/engine/run-evaluator-tests.ts */ -import { evaluateFormula, type EvaluatorContext } from "../../src/engine/evaluator"; +import { evaluateFormula } from "../../src/engine/evaluator"; +import type { EvaluatorContext } from "../../src/engine/evaluatorContext"; import { parseFunctionArgs } from "../../src/engine/expressionText"; import "../../src/engine/functions"; // Load all functions import { expect, scriptRunner } from "./scriptRunner";