solve-engine
v2.16.0
Published
An embeddable expression engine for natural-language calculations: units, currencies, percentages, dates and matrices, with the parsing and evaluation plumbing already built.
Maintainers
Readme
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
That's what a user sees. This package is the engine behind it: a lexer, a
Pratt parser, a bytecode VM, and an extensible package system, with units,
currencies, percentages, dates and matrices already built in. There's no
expression parser to write and no plumbing to wire up. It evaluates
natural-language-flavoured expressions out of the box: 2 + 2 * 10,
50% of 200, 3 days + 4 hours, 10 USD to GBP, 100 cm + 2 m.
Originally the engine inside Solve for Obsidian, extracted so it can be embedded in any host: an editor plugin, a CLI, a desktop app, a server. No dependency on a UI framework, a DOM, or an editor.
See ARCHITECTURE.md for how the pipeline, package system, async evaluation model, and caching layers fit together, plus a candid list of known architectural debt.
Installation
npm install solve-engineQuick start
import { createEngine } from "solve-engine";
const engine = createEngine();
const value = engine.evaluateExpression("2 + 2 * 10");
console.log(value.toNumber()); // 22evaluateExpression throws an EngineError (see solve-engine/errors) on a parse or
evaluation failure, wrap calls with untrusted input in a try/catch.
For line-oriented input (e.g. a document made of multiple expressions, some referencing
variables defined on earlier lines), use evaluateLine/parseDocument instead, see the
engine subpath below.
Engine lifecycle
Call clear() when you are finished with an engine that has parsed a document.
Dropping your last reference is not enough on its own: the async batcher is
reachable from the module-level data query service, so a parsed engine stays
retained until clear() releases it.
const engine = createEngine();
engine.parseDocument(text);
// ... read results ...
engine.clear();Measured per engine after a forced collection:
| Lifecycle | Retained | | --- | --- | | constructed, never parsed | 8.2KB | | constructed and parsed | 128KB | | constructed, parsed, cleared | 10KB |
This matters most for hosts that create one engine per document or per tab. Over 10,000 create-and-drop cycles the uncleared path reaches roughly 1.2GB.
Reusing one engine across documents is also fine. clear() resets an engine for
the next document rather than consuming it, so there is no separate teardown
call to remember.
Formatting a result for display
import { formatValue } from "solve-engine/format";
const value = engine.evaluateExpression("10 USD to GBP");
console.log(formatValue(value)); // uses DEFAULT_FORMATTING_SETTINGS if no settings passedPackage structure
solve-engine exposes its API as a set of subpath exports, grouped by how stable/low-level
they are:
| Subpath | Purpose |
|---|---|
| solve-engine | Start here, ExpressionEngine, createEngine, IEnginePackage. |
| solve-engine/engine | ExpressionEngine and its supporting types (Explanation, EngineSnapshot, etc.) directly, without the package-registration wrapper. |
| solve-engine/vm | The bytecode VM: Value/ValueType, opcode dispatch, allocatePluginFunctionIndex. |
| solve-engine/format | Turning a Value into a display string (numbers, dates, units, vectors, ...). |
| solve-engine/language | Editor-agnostic language service: token categories, completions, highlighting. |
| solve-engine/packages | The built-in packages (arithmetic, datetime, time, dice, uom, currency, vector, conditionals, converters, mathphrases, ...). |
| solve-engine/constants | Engine configuration types and defaults (EngineConfig, VMConfig, ...). |
The following subpaths are advanced-public, everything a third-party package author needs to extend the engine, but with a looser stability contract than the tier above (these are the pieces the built-in packages and the OSRS example themselves depend on):
| Subpath | Purpose |
|---|---|
| solve-engine/lexer | Tokenizer, LexerVocabulary for registering custom keywords/operators/units. |
| solve-engine/parser | Pratt parser, BytecodeBuilder, OpCode. |
| solve-engine/normalizer | Post-lexer token transforms (phrase fusion, implicit multiply). |
| solve-engine/resolvers | Async resolvers (IAsyncResolver) for data that loads asynchronously. |
| solve-engine/errors | EngineError and the error factory. |
| solve-engine/utilities | Small stateless helpers (e.g. stripQuotes). |
| solve-engine/uom | Units-of-measurement conversion tables and currency exchange. |
| solve-engine/services | Supporting services (query client construction, etc). |
Anything not listed above (telemetry, cache, diagnostics, types, workers) is
internal and not part of the package's public contract, it may change or disappear between
minor versions without notice.
Authoring a package
A package (IEnginePackage) is a plain data descriptor bundling everything needed to
extend the engine with a new domain: custom tokens, parselets, VM opcode handlers, and
optional async resolvers. See
solve-engine/api's IEnginePackage for the full field list
with inline documentation and examples for each field.
Minimal shape:
import { allocatePluginFunctionIndex } from "solve-engine/vm";
import type { IEnginePackage } from "solve-engine";
const MY_FN_IDX = allocatePluginFunctionIndex();
export const MY_PACKAGE: IEnginePackage = {
name: "MyPackage",
// engineVersion: "^0.1.0", // optional, see below
prefixParselets: [{ tokenType: "MY_FUNC", parselet: new MyParselet() }],
pluginFunctions: [{ index: MY_FN_IDX, handler: (args) => /* ... */ }],
};Register it either as one of the packages passed to the ExpressionEngine constructor, or
at runtime via ExpressionEngine.registerPackage() / unregisterPackage().
Declaring engine-version compatibility
IEnginePackage.engineVersion is an optional semver range (e.g. "^0.1.0") declaring which
solve-engine versions your package is built against. It's checked against the real, running
engine version at registration time. Omit it and your package always registers, exactly as
before this field existed, this is the default for every package that predates it. Declare it
once you want protection against the reverse case: your package being loaded into a much
newer (or much older) engine whose IEnginePackage contract has since changed shape.
Unlike every other compatibility signal in this codebase (see ARCHITECTURE.md §5.2's
sibling-package collision warnings, which always log and proceed), a declared range the
running engine does not satisfy causes registerPackage() to throw, not warn, see
ARCHITECTURE.md §5.3 for the full reasoning.
Three more extension points, beyond pluginFunctions
solve-engine/parser'sdefinePhrasePattern(), build a phrase-grammar parselet (roll between X and Y,average of X, Y, Z) from a declarative list of{ slots, emit }alternatives instead of hand-writingparser.consume()/parseExpression()calls. Seepackages/mathphrases/for several real examples, and its own JSDoc for the one hard constraint (every alternative must start with a keyword slot) and when a hand-written parselet is the right call instead.solve-engine/resolvers'screateQueryResolver(), a factory for the common "one cached async fetch → oneValue" shape (weather, stock prices, a game-item price API, seeexamples/osrs), generalizing the caching/staleness plumbing so a package only needs to write the fetch call and the response mapping.IEnginePackage.asConverters, contribute a customas <name>conversion (e.g.50% as decimal) to the built-inconverterspackage's grammar:{ myUnit: (value) => /* ... */ }. No lexer keyword registration needed, any bare word after "as" that isn't one of the built-in names resolves against this registry at runtime.
See ARCHITECTURE.md's §5.1 for the full reasoning behind each, including a real
regression (and its fix pattern) worth reading before picking a keyword for your own
package: a colon-prefixed variable name (:name = expr) can never be a keyword-shaped
word in this engine, so a common-noun trigger word (like "total") should be phrase-fused
with its qualifying keyword rather than claimed bare.
Two runnable examples, both under examples/ (example code, not part of the
published package, see files in package.json, only dist/ ships):
examples/basic, the smallest complete package: one custom keyword (reverse("text")) dispatched through a plugin function, nothing else. Start here. Its test,__tests__/examples/basic/BasicPackage.spec.ts, shows the full register-and-evaluate loop end to end.examples/osrs, a fuller example covering everythingbasicleaves out: a phrase-fused multi-word item name, an async resolver backed by a real HTTP API, a custom highlight category, and completion items. Prices Old School RuneScape Grand Exchange items (e.g.ge("Abyssal whip")).
Known limitations
Cross-instance isolation is partial. Plugin functions and the opcode registry are
now owned per ExpressionEngine, so two engines with different package sets no longer
interfere across those. The lexer and the currency exchange rates
are still module-level singletons, so full isolation between two engines in one process
cannot yet be assumed. Tracked as "L1, EngineContext"; three of its five migrations have
landed and the remaining two are a prerequisite for 1.0.0 proper.
Async results need a host hook. AsyncResolutionBatcher.onLineResult is the only
mechanism that patches a resolved async value back into the document model, and it is not
wired inside the package. A host that does not supply it gets async values that never
resolve, with no error to explain why.
Development
Developed in the solve-engine repository as
an npm workspace (packages/engine).
npm run build # tsup, emits ESM + CJS + .d.ts to dist/
npm run dev # tsup --watch
npm test # standalone jest run, scoped to this packageLicense
MIT, see LICENSE.
