Skip to content

About

An embeddable expression engine for natural-language calculations: units, currencies, percentages, dates and matrices, with the parsing and evaluation plumbing already built.

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

4 stars

Watchers

1 watching

Forks

Latest commit

 

History

517 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Solve, a natural language expression engine

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.

npm CI Node License

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            // 5,400.00 frames
[1, 2; 3, 4] * [5, 6; 7, 8]   // [19, 22; 43, 50]
expand((x+1)*(x+2))           // x^2+3x+2

Every solve example in this file, and every solve and solve-doc example in the documentation, is executed by the test suite. If one of them stops being true, the build goes red. A TypeScript snippet in the guides that states its result (formatValue(value); // "= 3,000.00 m") is run too, and the few that need a network, a worker or a runtime Temporal are listed by name with the reason. Every TypeScript snippet in the guides and the package-author pages is also type-checked under strict against the published entry points, and the few sketches that elide what they stand for are listed by name with the reason. The TypeScript snippets in this file are fragments, and are not checked.

Install

npm install solve-engine
import { createEngine } from "solve-engine";

const engine = createEngine(); // every built-in package
const value = engine.evaluateExpression("2 + 2 * 10");

console.log(value.toNumber()); // 22

createEngine is batteries-included; to keep the built-ins and add your own, pass it as createEngine({ extraPackages: [myPackage] }). The ExpressionEngine constructor registers only the packages you pass it, so your bundler can drop the built-ins you never use:

import { ExpressionEngine } from "solve-engine";
import { ARITHMETIC_PACKAGE, UOM_PACKAGE } from "solve-engine/packages";

// Only arithmetic and units reach the bundle.
const engine = new ExpressionEngine({ packages: [ARITHMETIC_PACKAGE, UOM_PACKAGE] });

No dependencies on a UI framework, a DOM, or an editor. It runs in Node, in a browser, and in a worker.

What it can do

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                    // 1,000.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
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: a value named on one line is read by the lines below it, and an edit above flows down.

:subtotal = 240
:tax = :subtotal * 20%
:subtotal + :tax              // 288

A note that knows its own structure. Tag the lines that belong together and total them, state what must stay true, ask what a line would say with a different input, and ask which lines fed an answer. The lines are category tags, a check, a what-if and a trace; headings split the note into sections.

# Trip
nights = 3
rate = £95
flights: £420 #travel
hotel: nights * rate #travel
total of #travel                 // £705.00
check total of #travel <= £800   // ✓

# Questions
line 5 with nights = 4           // £380.00
inputs of line 6                 // £705.00 (line 6) <- £420.00 (line 4), £285.00 (line 5) <- [nights 3 (line 2), rate £95.00 (line 3)]

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.

How it works

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 44 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. createEngine() registers 41 of them; stocks, knowledge and crypto stay out until a host supplies a data source, and a bare new ExpressionEngine() registers none until you pass the packages you want.

Architecture covers this properly, including a candid list of what is not finished.

Design

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.

Non-goals

  • 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 symbolic der, integral, taylor and jacobian. It is deliberately bounded, and it says what it cannot do rather than guessing. Factoring works over the rationals, so x^2-2 comes back unfactored; solving goes further and works over the complex numbers, so it answers with every root a polynomial has or counts the ones it could not find; and integration reports when an expression has no elementary antiderivative. Where an answer is a number the algebra cannot reach (a root of cos(x) = x, a definite integral of exp(x^2), a limit), it is found numerically, checked before it is shown, and documented as approximate.
  • Not a spreadsheet. Lines reference earlier lines. There are no sheets, no cells, and no circular references to resolve.
  • Not arbitrary-precision everywhere. A typed decimal is held and compared exactly, up to 34 significant digits, and so is a whole number, including one past 2^53; a fraction such as 1/3 is kept as the exact fraction. Irrational results (roots, logarithms, trigonometry), numbers written in scientific notation, and quantities with a unit other than money use floating point, as the decimals page sets out.
0.1 + 0.2 == 0.3          // true
1/3 * 3                   // 1
2^53 + 1                  // 9,007,199,254,740,993
sqrt(2) * sqrt(2) == 2    // false

Security

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({ 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 soak

Verified 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.

Repository

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/package-starter A starter for a third-party package: public imports only, built and tested against the packed tarball
packages/engine/examples/osrs An internal fixture package the playground and the engine's tests use
npm install && npm run verify

verify is the fast loop: type check, the default test run, and the package build. npm run verify:ci is the whole gate, every check continuous integration applies as one command. See CONTRIBUTING.md.

Status

Released and on a 2.x line. The public entry points follow semantic versioning, a deprecated name keeps working for at least one minor release before a major removes it, and every release runs the full suite on Node 22 and 24 before it reaches npm; versioning and support states the policy in full. Open items are tracked as issues rather than hidden, and each release note names the ones that matter.

Licence

MIT. See LICENSE.

If it is useful to you, sponsorship is welcome and never expected.

About

An embeddable expression engine for natural-language calculations: units, currencies, percentages, dates and matrices, with the parsing and evaluation plumbing already built.

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

4 stars

Watchers

1 watching

Forks

Releases

Sponsor this project

Used by

Contributors

Languages