A calculation engine for text. Give it a block of lines and it hands back a result for every line that reads like a calculation, plus highlight spans for colouring the source. Lines that read like prose stay prose.
rate = 45 45
hours = 12 12
rate * hours 540
200 usd in eur €184.00
6 m / 2 s 3 m/s
today + 21 days Sep 9, 2026
20% of 300 60
This is a real language implementation, not a pile of regexes. Lexer, Pratt parser, typed evaluator. It has no runtime dependencies and knows nothing about the DOM, React, or React Native, so it runs in a browser, in Node, or on a phone.
It powers däpter, a notes app for iOS, which is where the 356 tests come from.
npm install dapter-engineESM only. Node 20 or newer.
One call evaluates a whole document.
import { evaluateDocument } from "dapter-engine";
const result = evaluateDocument("100 usd in eur\n10 km + 5 m in miles", {
rates: { USD: 1, EUR: 0.92 },
now: Date.now(),
});
for (const line of result.lines) {
console.log(line.source, "=>", line.display?.text ?? "(prose)");
}result.lines is canonical. Each LineResult carries the source, the AST, the
typed value, the formatted display string, the tokens, the highlight spans, and
the error if the line has one. result.variables is the final environment, so you
can see what the document defined.
For an editor, use a session instead. It re-folds only from the first changed line, which is what makes highlighting a note feel free on every keystroke.
import { createNotebookSession } from "dapter-engine";
const session = createNotebookSession({ now: Date.now() });
session.update(text); // full result, same shape as evaluateDocument
session.ids(); // stable per-line ids, usable as React keysThe ids survive an insert or delete above them, so a renderer keyed on them remounts only the lines that actually changed.
Highlighting returns roles, never colours.
import { roleColor } from "dapter-engine";
roleColor("number"); // "syntaxNumber"Your theme owns the nine token names roleColor can return. The engine never
learns what syntaxNumber looks like, which is how the same spans serve a light
theme, a dark theme, and a test in Node.
Rates are injected the same way. The package ships no rate table and makes no
network calls. Pass rates if you want live currency conversion, pass now if
you want today to resolve. Leave now out and relative dates read as ordinary
words rather than reading a clock behind your back.
Four decisions carry the whole design. docs/DESIGN.md is the long version, 1,300
lines of it, and it is the document to read before changing anything here.
Every line is parsed alone. A document is a left fold over its lines. Each line parses on its own and evaluates against the environment accumulated above it. One malformed line cannot kill the rest of the document, and recompute starts at the first line that changed instead of the top.
A Pratt parser, so precedence is data. Binding powers live in a table. Adding an operator does not mean rewriting a recursive-descent chain.
Features are registry entries, not grammar. Units, currencies, functions,
constants, date words, and keywords are all data under src/registry/. Adding
furlong is a row. The parser does not change. This is also why Russian works:
the registries carry the real inflected forms, including the prepositional plural
that в километрах needs, rather than transliterating and hoping.
Values are typed, and units are exponent vectors. A quantity carries a
dimension like { length: 1, time: -1 }. Multiplication adds exponents,
division subtracts them, and that is why 6 m / 2 s knows it produced a speed
instead of a number that happens to be 3.
Fetch exchange rates. Read the clock. Render anything. Persist anything. Each of those is the host's job, and keeping them out is what makes the engine testable in plain Node with no mocks.
pnpm install
pnpm test # 356 tests, 15 files
pnpm typecheck
pnpm build # tsc to dist/, ESM plus declarations21 source files, about 3,600 lines. The tests are the specification for the
language, so a new feature starts with a case in src/__tests__/.
MIT