Skip to content

This site describes solve-engine as it is on main: 2.43.0, which npm does not have yet. npm installs 2.40.0, so a page may show an answer that version does not give yet.

Writing a package

Every feature in the engine is a package, including arithmetic. Writing one is the supported way to add syntax.

Most of the time you only want to add a function: a name, some arguments, a result. You should not have to learn the parser and the bytecode VM to do that, so defineFunction derives the whole wiring from a declaration and hands back a package you register like any other.

import { createEngine, defineFunction } from "solve-engine";
const vat = defineFunction({
name: "vat",
args: [{ name: "amount", type: "number" }],
returns: "number",
call: (amount) => amount * 1.2,
});
// createEngine registers every built-in package; extraPackages adds yours on top.
const engine = createEngine({ extraPackages: [vat] });
engine.evaluateExpression("vat(100)"); // 120

That is the whole thing. From the declaration, defineFunction allocates the plugin function index, declares the name as a call word, builds the name(args) parselet, highlights the call as a function, offers it in completion with its signature (vat(amount: number): number), and wraps call in a handler that checks the call before running it. call receives plain JavaScript values and returns one; the engine does the unwrapping and wrapping.

A call word becomes the call only where an opening parenthesis follows it, so the name stays an ordinary word everywhere else. With vat defined, a reader can still write :vat = 0.2, vat * 100 or a vat: label, and vat(100) on the next line is still the call. The match ignores case, so VAT(100) is the same call.

The checks come for free, and they raise the engine’s own structured errors rather than anything you hand-roll:

engine.evaluateExpression("vat()"); // vat() takes 1 argument, but was given none
engine.evaluateExpression('vat("x")'); // vat() expects "amount" to be a number, but was given a string

call’s parameters and return are typed from the declaration, so (amount) => amount * 1.2 needs no annotations: amount is a number because the argument said so, and returning anything but a number is a compile error.

Each defineFunction returns a self-contained package named solve-fn-<name>. Add several functions by passing several packages.

defineFunction is deliberately the common case, not a second contract. Its arguments are a fixed-length list, and both arguments and the return are one of number, string, or boolean. A number argument accepts a plain number or a based literal such as 0xFF; a value with a unit, a percentage, or a date is a different kind of value and is refused rather than quietly reinterpreted.

The name has to be one the engine reads as a plain word. A name it already reads as something else, a unit (kg), a keyword (in) or a built-in function (sqrt), could never become the call, so registering it is refused with PLUGIN_CALL_FUSION_UNREACHABLE. createEngine logs the refusal and carries on without that function. A name another package already uses as a call word, such as md5, is allowed: the engine warns, the later registration is in force, and unregistering it hands the word back.

Anything past that keeps using the full contract below, unchanged:

  • variadic or optional arguments,
  • other value types (units, percentages, dates, matrices),
  • asynchronous work (a network lookup), which wants an async data source and a parselet,
  • any syntax that is not name(args).

defineFunction sits on top of the contract and changes none of it, so you can reach for the longhand the moment the shortcut stops fitting.

A package that lives outside this repository, and depends on the published engine by name, starts from the package starter. It carries one of each common piece (a function, a phrase, an as converter and a live lookup, whose async resolver is made with createQueryResolver from solve-engine/resolvers, the short way for a lookup of one quoted query), imports only the engine’s public entry points, and is tested with solve-engine/testing. The engine’s own build packs the published tarball, builds the starter against it and runs its tests, so it compiles against exactly what an author installs. Copy the directory, rename the words, keep the shape.

The longer path: a whole package, scaffolded

Section titled “The longer path: a whole package, scaffolded”

A function is not always what you want. A phrase, an operator, a unit or a normalizer rule needs a package of its own, and that touches at least eight places before it can be judged: the package folder, both lists in BUILTIN_PACKAGES, a spec, a documentation page, the sidebar, a changeset, and the derived figures.

If you are working in a clone of this repository, on a package that ships with the engine, one command writes all of it. (For a package of your own, in its own repository, start from the package starter instead: this command wires a built-in package into the engine’s registry, docs and tests.)

Terminal window
npm run new:package -- fuel-economy --group "Units"

What comes out registers, evaluates something, and passes npm run verify before you edit a line: fuel economy of 21 answers 42. That placeholder is there so the whole chain is wired and green from the first run, phrase to parselet to plugin function to pure operation to spec to a proven documentation example. Replace the behaviour, keep the shape.

The generated files carry the comments this codebase expects rather than placeholders to delete, including the two every package here writes down: what it answers, and what it deliberately refuses to guess at.

The command edits builtins.ts and the docs sidebar by matching on text in them. If either has moved on, it says so and names the file rather than writing something plausible into the wrong place, and the fix is to add that one entry by hand.

A package is a plain object. Every field is optional, so you declare only what you need.

import type { IEnginePackage } from "solve-engine";
export const myPackage: IEnginePackage = {
name: "my-package",
engineVersion: "^2.0.0",
};

name must be unique and not empty: the engine keeps everything a package adds under its name, so a package without one is refused at registration. engineVersion is a semantic version range checked at registration, so a package built against an incompatible engine is refused with a clear message rather than failing mysteriously later. Declare the major version you built against: "^2.0.0" accepts every 2.x engine, and a range the running engine does not satisfy is refused, so "^1.0.0" on a 2.x engine is refused with Package "my-package" declares engineVersion "^1.0.0", which is not satisfied by the running engine version "2.41.0".

FieldPurposeHow-to
lexerVocabularyKeywords, operators and units the tokeniser should recognise, and whole-line patterns that take a line as free textUnits and keywords
prefixParseletsParsing rules for tokens that begin an expression, written by hand or built from a phrase’s words with definePhrasePatternFunctions and operators, Recognising phrases and words
infixParseletsParsing rules for tokens that combine expressionsFunctions and operators
pluginFunctionsFunctions the virtual machine can call, and whether a call is one that never waits (emitPluginCall(name, argCount, { synchronous: true })), so a function body or a map can hold it, or one that reads other lines ({ readsDocument: true }), so a function body or a held expression (a map’s, a plot’s, a formula’s) is refused for that reasonFunctions and operators
normalizerRules / phrases / callFusionsToken-stream rewrites: phrase fusion, name( function-call words, the shape a rule declares so it is only tried where it can fire, and the engine environment a rule is handedRecognising phrases and words
asConvertersTargets for the as conversion formCustom as converters
unitAliasesWords for units the engine already has (Meile for the mile), read after a value and as a conversion target, and shown as writtenWords for units
asyncResolversExternal data sources: the fetch (createQueryResolver for a question and a fetch), how many run at once, and the record of where each fetched value came fromAsync data source
tokenCategoriesHighlighting categories for new tokens, which a keyword also needs to be offered in completionsHighlighting and completions
completionItemsEditor completion candidates beyond the keywords, call words and phrases already offeredHighlighting and completions
explainDescribe the package’s own calls, conversions and phrases as readable steps when a host explains a lineExplaining your steps

Each field has a hands-on guide in the How-to column: this table is the map, and each guide walks its extension point end to end.

The common case is your package on top of the built-ins, which extraPackages does in one line:

import { createEngine } from "solve-engine";
const engine = createEngine({ extraPackages: [myPackage] });

Order matters, and extraPackages gets it right by construction: it appends your package after the built-ins, so arithmetic and the rest are already in place before anything you add builds on them. When you need to control the order yourself, or leave the built-ins out entirely, pass an explicit packages list to the ExpressionEngine constructor instead:

import { ExpressionEngine } from "solve-engine";
import { BUILTIN_PACKAGES } from "solve-engine/packages";
const engine = new ExpressionEngine({ packages: [...BUILTIN_PACKAGES, myPackage] });

A package can fail to register: its engineVersion range is not satisfied (PACKAGE_ENGINE_VERSION_MISMATCH), a keyword it declares is one a built-in already owns (PLUGIN_KEYWORD_COLLISION), it has no name (PACKAGE_NAME_MISSING), or a resolver it carries waits for a plugin function the package does not declare (PACKAGE_RESOLVER_FUNCTION_MISSING). Through registerPackage the failure is thrown, with that code. Through the constructor and createEngine the engine is built without the package and the failure is logged, so one bad package cannot take the whole engine down, which is also how a reader in a host with no console meets it: as a parse error on every line that uses the package. Two options say so instead:

import { createEngine } from "solve-engine";
import { myPackage } from "./my-package";
// Throw the package's coded error from the constructor.
const strict = createEngine({ extraPackages: [myPackage], strict: true });
// Or be told of each failure, and build with the rest.
const told = createEngine({
extraPackages: [myPackage],
onPackageError: (pkg, error) => console.error(`${pkg.name}: ${error.code}`),
});
// The names that registered, in order, the built-ins first.
told.getRegisteredPackages().includes("my-package"); // true

A strict engine, and one whose onPackageError throws, unregisters the packages it had already registered before it throws, so nothing is left behind in the registries engines share. Collisions that only pick a winner between two packages stay warnings, since a package may replace a built-in’s grammar on purpose. expectPackage(myPackage).toBeWellFormed() (see testing a package) catches most of these before a package ships.

The hardest part of writing a package is not the code, it is picking syntax that does not collide with ordinary prose. Read trigger words before claiming a bare English word. The short version: prefer a multi-word phrase, and prefer requiring a parenthesis, over claiming a common noun as a keyword.