@causl/formula
v0.2.2
Published
Spreadsheet formula primitives for Causl — grammar, parser, formula-to-derived adapter.
Readme
@causl/formula
Spreadsheet-style formula primitives layered on @causl/core. The core engine knows nothing about
=A1+B1; this package translates AST → derived node.
Install
pnpm add @causl/formula @causl/coreQuick start
import { createCausl } from '@causl/core'
import {
cellId,
createFormulaAdapter,
parseFormula,
valueOr,
} from '@causl/formula'
const graph = createCausl()
const a1 = graph.input(cellId('wb', 'Sheet1', { col: 0, row: 0 }), 5)
const b1 = graph.input(cellId('wb', 'Sheet1', { col: 1, row: 0 }), 10)
const adapter = createFormulaAdapter(graph, {
workbook: 'wb',
sheet: 'Sheet1',
resolve: (ref) => {
if (ref.col === 0 && ref.row === 0) return a1
if (ref.col === 1 && ref.row === 0) return b1
return undefined
},
})
const c1 = adapter.registerFormula(
{ col: 2, row: 0 },
parseFormula('=A1+B1'),
)
console.log(graph.read(c1)) // { kind: 'value', value: 15 }
console.log(valueOr(graph.read(c1), 0)) // 15Error states
Make impossible states impossible: rather than four optional fields
where "has a value AND an error" is representable, evaluation resolves
to a discriminated union. Divide-by-zero, non-numeric coercion,
unresolved refs, unknown functions, AVG/MIN/MAX of empty sets, and
upstream propagation all become {kind: 'error', error: {kind, message,
ref?}} rather than throwing or silently zeroing. The tag is the gate;
a compile-time check stands between a caller and the value.
const broken = adapter.registerFormula({col: 3, row: 0}, parseFormula('=A1/0'))
const r = graph.read(broken)
// { kind: 'error', error: { kind: 'div-by-zero', message: 'Division by zero' } }Built-in functions
SUM, AVG / AVERAGE, MIN, MAX. Operate on cell ranges
(A1:A10) or scalar lists (SUM(A1, B2, 5)).
Cycle detection
import {
emptyFormulaGraph,
addFormula,
detectCycle,
parseFormula,
} from '@causl/formula'
const g = emptyFormulaGraph()
addFormula(g, { col: 0, row: 0 }, parseFormula('=B1'))
addFormula(g, { col: 1, row: 0 }, parseFormula('=A1'))
detectCycle(g) // ['A1', 'B1', 'A1']The engine also catches cycles at first-commit time (CycleError).
This module is the pre-flight detector that lets the host application
reject formulas before they hit the engine.
Runnable demo
A 100-cell spreadsheet diamond demo lives under demo/ and exercises
the same wiring this README describes against @causl/core and
@causl/devtools. From the repo root:
pnpm --filter @causl/formula run demoThe demo builds the three packages and serves demo/index.html on a
local port. It is the runnable companion to the SPEC §11 "the engine
is its own observer" claim — every grid cell, the commit-log panel,
and the "why did this update?" line subscribe through the same public
engine surface tests use.
0.1.0 surface change
0.1.0 was a surface-breaking pre-1.0 minor (issue #1081, follow-up
to the IR carve in #1075 / original #697); both PRs landed pre-0.9.0
and the surface below is the current one. Two changes:
parseFormulaconsumes IR types directly.parseFormulaand the cycle helpers now importAst,BinOp, andCellReffrom the internal IR module instead of./grammar.js. The@causl/formulapackage barrel re-exports the same types, so the public type identity is unchanged for adopters that import from the package root. Adopters who imported types from the deep path@causl/formula/grammarwill see a moved-source identity.FormulaHostinterface + publicevaluate(ast, host). The evaluator no longer takes a(resolve, get)closure pair. It now consumes aFormulaHost:interface FormulaHost { readNumber(cellId: string): number | FormulaError } evaluate(ast: Ast, host: FormulaHost): FormulaResultcellIdis the A1 reference string. The host owns coercion and upstream propagation: a cell whose backing value is itself an erroredFormulaResultmust be returned as apropagatedFormulaError, so the evaluator can forward host errors as-is. A Rust evaluator port that satisfies the same contract is the long- term plan; the wire-IR mirror intools/engine-rs-core/src/formula_ir.rs(feature-gatedfuture, landed via #1078 / #1080) is the seam, but the actual evaluator port is deferred to post-0.9.0 epic #1133 with the GO/NO-GO criteria documented in the epic body.createFormulaAdapteris unchanged — it now constructs aFormulaHostinternally from the existingresolve+ the engine's read hook. Direct adopters of the previous internalevaluate(ast, resolve, get)form (none in-tree) must migrate to the new entry.
