A calculator that reads like a sentence.
Type what you mean. Units, currencies, percentages, dates, matrices and plain-English phrasing all work in the same expression, and the answer appears as you type.
Documentation • Playground • Syntax reference • Contributing
That's what a user sees. What you're looking at is the engine behind it: a lexer, parser, and evaluator, with units, currencies, percentages, dates and matrices already built in. There's no expression parser to write, no plumbing to wire up. Install it, point it at an input, and everything below is there out of the box.
Open source, sponsored by Oyren.
1024 * 8 // 8,192
15% of 2400 // 360
100 cm + 2 m // 300.00 cm
30 fps * 3 minutes // 5400.00 frames
[1, 2; 3, 4] * [5, 6; 7, 8] // [19, 22; 43, 50]
expand((x+1)*(x+2)) // x^2+3x+2
Every example in this file, and every example in the documentation, is executed by the test suite. If one of them stops being true, the build goes red.
npm install solve-engineimport { ExpressionEngine } from "solve-engine";
const engine = new ExpressionEngine();
const [value] = engine.evaluateExpression("2 + 2 * 10");
console.log(value.toNumber()); // 22No dependencies on a UI framework, a DOM, or an editor. It runs in Node, in a browser, and in a worker.
Units, converted and carried through arithmetic. Not string matching on a suffix: units participate in the calculation and the result keeps the right one.
72F to C // 22.22 C
5 miles in km // 8.05 km
2 cups to ml // 473.18 ml
250 kg to pounds // 551.16 pounds
1 GB in MB // 1000.00 MB
90 minutes in hours // 1.50 hours
2 hours + 45 minutes // 2.75 hours
Percentages, in the several different things people mean by them.
15% of 2400 // 360
increase 100 by 10% // 110.00
100 to 150 // 50.00%
5% of what is 6 // 120
Named values, across lines. A document is a calculation, not a set of unrelated sums.
:subtotal = 240
Matrices, ranges, and symbolic algebra.
[1, 2; 3, 4] * [5, 6; 7, 8] // [19, 22; 43, 50]
det([1, 2; 3, 4]) // -2
Everything else you reach for. Number bases, comparisons, conditionals, money, dice, live weather, and a function library.
0xFF + 0b1010 // 265
255 as hex // 0xFF
2.5k * 4 // 10,000
max(3, 9, 2) // 9
10 mod 3 // 1
if 5 > 3 then 100 else 200 // 100
$100 + $250 // $350.00
The syntax reference is the complete list. There is rather more of it than fits here.
Text goes through a lexer, a normaliser that fuses multi-word phrases into single tokens, a Pratt parser that emits bytecode, and a register-based virtual machine. Results are cached per line, and a dependency graph means editing one line re-evaluates only the lines that actually depended on it.
That is more machinery than a calculator strictly needs, and the reason for it is the typing. The engine is built to run on every keystroke, on a document rather than a single expression, where most lines have not changed and the one that did should not cost a full re-evaluation of the rest.
Everything above the pipeline is a package. All 22 of them, arithmetic included, register through the same public interface: token vocabulary, normaliser rules, parselets, and VM functions. There is no privileged built-in tier, which means an extension can do anything the built-ins can. Twenty register by default; stocks and knowledge stay out until a host supplies a data source.
Architecture covers this properly, including a candid list of what is not finished.
Natural phrasing, without hijacking English. A calculator that understands
sentences has an obvious failure mode: claim in, to, at and for as
keywords and you break every line of prose that happens to contain one, and you
make those words unusable as variable names. So bare common words are almost
never keywords here. Multi-word phrases get fused by the normaliser only in
positions where nothing else is plausible.
Trigger words and fusion
explains where the lines are drawn and why.
An error, never a guess. When a resolver has not returned yet the result is a pending value that resolves later. When a data source is not configured the result says so. The engine does not invent a plausible number, because a wrong answer that looks right is worse than no answer.
Bounded on untrusted input. Expression length, parse depth, instruction count and stack depth all have limits, and each produces a named error rather than hanging. Input arriving one keystroke at a time from a person who is still mid-thought is the normal case, not the edge case.
- Not a general-purpose language. No loops, no I/O, no arbitrary code execution. Expressions compile to a fixed instruction set.
- Not a full computer algebra system. There is a real one inside: exact
rational arithmetic,
expand,factor,solve, and symbolicder,integral,taylorandjacobian. It is deliberately bounded, and it says what it cannot do rather than approximating. Factoring works over the rationals, sox^2-2comes back unfactored; solving goes further and works over the complex numbers, so it answers with every root an equation has or counts the ones it could not find; and integration reports when an expression has no elementary antiderivative instead of guessing. - Not a spreadsheet. Lines reference earlier lines. There are no sheets, no cells, and no circular references to resolve.
- Not arbitrary-precision by default. Ordinary arithmetic uses doubles, and a big-integer type is available where exactness matters.
This engine runs untrusted input by design: a calculator's whole job is to evaluate whatever someone typed. That shapes how it is built and how it is tested.
No dynamic code execution. There is no eval, no new Function, and no
code generation anywhere in the source. An expression is lexed, parsed, and
compiled to a fixed bytecode instruction set, then run on a VM that can only do
what its opcodes do. There is no path from an expression to arbitrary
JavaScript.
No I/O of its own. The engine reads no files and spawns no processes. It
does make network requests, and the honest version of that is more specific
than "opt in": two of the packages registered by default reach out on their
own. 100 USD in GBP fetches an exchange rate, and weather in london calls a
geocoder, with no host configuration at all. Two further packages (stocks,
knowledge) need a host-supplied data source and do nothing without one.
If you need an engine that never touches the network, build the package list without those two rather than relying on a default:
import { ExpressionEngine } from "solve-engine";
import { BUILTIN_PACKAGES, CURRENCY_PACKAGE, WEATHER_PACKAGE } from "solve-engine/packages";
const offline = BUILTIN_PACKAGES.filter(
(p) => p !== CURRENCY_PACKAGE && p !== WEATHER_PACKAGE,
);
const engine = new ExpressionEngine("en", false, { packages: offline });One runtime dependency. @tanstack/query-core, for caching async
resolution. Everything else, including the parser, the VM, the unit table and
the computer algebra, is in this repository.
Bounded by construction. Untrusted input must not be able to hang or kill
the host process, which for an editor plugin means the editor. Expression
length, nesting depth, instruction count, stack depth, collection size, total
allocation, function-call breadth and recursion depth are all capped. Most are configurable through
EngineConfig; function recursion depth and the normaliser token cap are set
elsewhere. Exceeding any of them raises a recoverable error naming what was
refused rather than an unrecoverable one. The allocation budget exists
because per-operation limits do not compose: two individually legal matrices
can multiply into a fatal one.
Fuzzed, not just tested. A seeded fuzzer with automatic shrinking runs
against both the expression grammar and the bytecode VM. It generates malformed
programs, mutates valid ones, and asserts three invariants: the process never
dies, nothing hangs, and every failure is a well-formed EngineError rather
than a raw JavaScript exception. executeBytecode is a public export, so
malformed bytecode is a real caller surface and is fuzzed as one. Findings are
shrunk to a minimal reproducer and committed to a corpus that replays on every
test run, so a fixed bug cannot come back quietly.
Run it yourself:
npm run fuzz # random seeds, both generators
npm run fuzz -- --minutes=10 # a longer soakVerified against the previous release. tools/differential/ compares every
expression it can find against the last published version and classifies every
difference, so a behaviour change has to be deliberate rather than discovered
afterwards.
To report a vulnerability, see SECURITY.md.
| Path | What is in it |
|---|---|
packages/engine |
The published package |
packages/playground-bridge |
Shared glue between the engine and the playground |
playground |
The interactive playground, with every pipeline stage exposed |
docs |
The documentation site |
examples/osrs |
A worked example of a third-party package |
npm install && npm run verifyverify is the whole gate: type check, test suite, and package build. See
CONTRIBUTING.md.
Working toward 1.0.0-beta. The engine is stable and heavily tested, but the
API surface may still move before 1.0. Open items are tracked as issues rather
than hidden, and the beta release notes will name the ones that matter.
MIT. See LICENSE.
If it is useful to you, sponsorship is welcome and never expected.
