This site describes solve-engine 2.44.0, the version npm installs.
Recognising phrases and words
The engine reads prose, so a package’s syntax is usually a phrase a person would
write, total of, next friday, weather in London, not a symbol. The
challenge is claiming those words without breaking the ones a reader might use for
something else: total is also a fine variable name. There are two mechanisms,
and choosing the right one is most of the work.
- A phrase that is always the same words in the same order: use the
declarative
phrasesfield. - A word that must stay ordinary elsewhere, special only in one position
(before a
(, next to another token): use anormalizerRulesrule, and give it ashapeso the normalizer knows where it can fire.
Both run in the normalizer, the step between lexing and parsing, and both fuse several tokens into one that a parselet then handles.
The declarative way: phrases
Section titled “The declarative way: phrases”phrases is a map from a space-separated phrase to the token type it fuses into:
import type { IEnginePackage } from "solve-engine";
export const myPackage: IEnginePackage = { name: "my-aggregates", phrases: { "total of": "TOTAL_OF", "average of": "AVERAGE_OF", }, prefixParselets: { TOTAL_OF: new AggregateParselet(TOTAL), AVERAGE_OF: new AggregateParselet(AVERAGE), },};That is the whole loop: phrase → a token type you name → a parselet keyed by that
type. When the reader writes total of 1, 2, 3, the normalizer fuses the two
words total of into a single TOTAL_OF token, and the parser hands that token
to your AggregateParselet exactly as if the lexer had produced it. Register the
parselet under the same string you targeted, and see
adding functions and operators for what the
parselet itself does.
Phrases go into a trie, so you get two things for free:
- Longest match wins, with no priority to set. Register both
power ofandto the power ofand the longer one is taken when both could match. - It is cheap. A phrase is rejected on its first word in one lookup, and a number or a bracket never enters the trie at all.
A phrase never fuses across a tag, and its words are matched case-insensitively.
The mathphrases package is the smallest complete example to read: a row of
phrases and the matching row of prefixParselets.
Function-call words: callFusions
Section titled “Function-call words: callFusions”A very common shape is a single word that becomes a function call when it is
immediately followed by (: sha256("hi"), length("hi"), percentile([...], 90). It reads as a variable everywhere else (sha256 on its own, or
:sha256 = ...). Rather than hand-write a normalizer rule for each such word,
declare it:
callFusions: { sha256: "HASH_CALL", md5: "HASH_CALL", // ...or, mapping a whole set of names to one token type: // ...Object.fromEntries(Object.keys(FUNCS).map((n) => [n, "HASH_CALL"])),}Each entry maps the lower-cased word to the token type to mint when the
word is followed by (. The engine merges every package’s callFusions into one
shared map and runs a single rule for all of them, so adding a function is one map
entry rather than one more rule tried at every identifier. The fused token carries
the lower-cased word as its value (your call parselet reads it to pick the
function) and the original text as its raw value; a matching prefixParselet on
the token type does the actual call parsing (see
Functions and operators). The :name = ...
variable case is handled for you: a word after a : is left alone.
Two packages may declare the same word for different token types; the engine
warns (the callFusionName compatibility conflict), the one registered last is
in force, and unregistering it hands the word back to the other.
The word has to reach the rule as a plain word. One the lexer already reads as
something else, a unit, a keyword or a built-in function, never does, so its
call could never fire; registering it throws PLUGIN_CALL_FUSION_UNREACHABLE
and the package is not registered. The check reads the lexer as it stands when
the package registers, so a unit or keyword a later package adds under the same
word is not caught.
The boundary is deliberately narrow. callFusions is only the plain word (
shape with that one : guard. If your rule needs a different lookbehind, a deeper
lookahead, or any value logic beyond “is this word one of mine”, write a
normalizerRules entry by hand instead.
Words around their operands: definePhrasePattern
Section titled “Words around their operands: definePhrasePattern”A phrase such as halfway between 10 and 20 has words on both sides of its
values: halfway starts it, between and and sit between the operands. The
leading word is a keyword the lexer turns into a token of its own, and the rest
is the parselet’s grammar, which definePhrasePattern (from
solve-engine/parser) builds from a declaration: a list of slots, each a
keyword token or an expression, and what to emit once they are all read.
import type { IEnginePackage } from "solve-engine";import { definePhrasePattern, BindingPower, OpCode } from "solve-engine/parser";
// `halfway between A and B` is the midpoint, (A + B) / 2.const halfway = definePhrasePattern({ category: "Halfway", alternatives: [ { slots: [ { kind: "keyword", tokenTypes: ["BETWEEN"] }, { kind: "expr", bindingPower: BindingPower.Conjunction }, { kind: "keyword", tokenTypes: ["AND_CONJ"] }, { kind: "expr", bindingPower: BindingPower.Conjunction }, ], emit: (builder) => { builder.emitOpcode(OpCode.ADD); builder.emitOpcode(OpCode.PUSH_NUMBER); builder.emitNumber(2); builder.emitOpcode(OpCode.DIV); }, }, ],});
export const HALFWAY: IEnginePackage = { name: "halfway", lexerVocabulary: { keywords: { halfway: "HALFWAY" } }, prefixParselets: { HALFWAY: halfway }, tokenCategories: { HALFWAY: "function" },};With it registered, halfway between 10 and 20 is 15, and halfway between $10 and $20 is $15.00, since the operands are ordinary expressions. Each
expression slot is parsed at the binding power it names: Conjunction stops it
at the and, which would otherwise be read as part of the first value. A
phrase with more than one wording lists an alternative for each, and the first
slot of every alternative must be a keyword, which is how the parselet chooses
between them from the next token alone; a line that matches none is refused
with the keywords it expected (halfway 10 and 20 names BETWEEN).
When a word must stay a variable too
Section titled “When a word must stay a variable too”factor(x^2-4) should call the algebra solver, but :factor = 1.5 should still
define a variable named factor. A phrases entry, or a lexer keyword, would
claim factor everywhere and lose the variable. The fix is a rule that fuses the
word only in the position where it means something, here, only when the very
next token is an opening parenthesis.
A rule is an object with a name, a priority, and a match that either returns a
replacement or null:
import type { NormalizerRule } from "solve-engine/normalizer";import { LexerToken, tokenTypeId } from "solve-engine/lexer";
export function factorCallRule(priority = 80): NormalizerRule { return { name: "my-algebra:factor-call", priority, // The same two conditions the guards below check, stated as data so the // normalizer can skip this rule everywhere they cannot hold. shape: [{ types: ["IDENT"], values: ["factor"] }, { types: ["LPAREN"] }], match(tokens, pos) { const token = tokens[pos]; if (!token || token.type !== "IDENT" || token.value.toLowerCase() !== "factor") return null; if (tokens[pos + 1]?.type !== "LPAREN") return null; // the position condition
// Fuse just the word; the "(" stays for the parselet to read. return { consumed: 1, replacement: [ new LexerToken("FACTOR_FN", tokenTypeId("FACTOR_FN"), token.value, token.value, token.offset, 0, token.line, token.col), ], ruleName: "my-algebra:factor-call", }; }, };}Register it with normalizerRules: [factorCallRule()]. match must be pure: it
looks at the tokens from pos and returns without mutating anything. consumed
is how many tokens it replaces (one here, only the word), and replacement is
what to put in their place (one minted FACTOR_FN token). Return null the
instant the shape is not yours.
Declare the shape you match
Section titled “Declare the shape you match”shape is the same information as the first few guards in match, written as
data. Each entry describes one token position from the match point onward, by
type, by value, or by both, and an entry with neither constrains nothing.
Without it, a rule is tried at every position of every line. With it, the normalizer intersects the declarations of every registered rule and tries only the ones that could possibly fire. On the built-in packages that takes a position from 55 candidate rules to 9, because the alternative discriminates poorly: every call-fusion rule starts at an identifier, the commonest token in prose, so the start type alone leaves all of them candidates at every word. The word, and the token after it, are what tell them apart.
// Two positions: a known word, then an opening parenthesis.shape: [{ types: ["IDENT"], values: ["factor"] }, { types: ["LPAREN"] }]
// A clock time is a number followed by a colon.shape: [{ types: ["NUMBER"] }, { types: ["COLON"] }]
// Reach past a position you do not care about.shape: [{ types: ["NUMBER"] }, {}, { types: ["UNIT"] }]The one rule to get right: a shape may admit more than the rule matches, but
never less. Admitting more costs a match() call that returns null, which is
what happens without a shape at all. Admitting less makes the rule unreachable at
the positions left out, and nothing announces it: no error, no failing type, just
a spelling that quietly stops working. So when a rule has two forms, the shape is
their union. The clock-time rule accepts both 9:00am and the bare 4pm, so
its second slot is ["COLON", "IDENT"], not ["COLON"].
If in doubt, declare less, or omit shape entirely. Being unindexed is slow, not
wrong.
startTokenTypes is the older, narrower form of the same idea, constraining only
the first token. It still works; shape supersedes it.
This is how the symbolic package claims factor, solve and expand, and how
the lines package claims sum( and average(. Both also refuse to fire right
after a colon, so :solve = 2 is never touched.
Ordering is by priority, not registration
Section titled “Ordering is by priority, not registration”The normalizer runs the token stream through several passes, and at each position it tries rules from highest priority first. This is the whole ordering contract, and it is why the order you register packages in does not matter.
The rule to internalise: a rule that reads a token another rule mints must have a lower priority. A freshly minted token only becomes visible on the next pass, so the producer runs first (higher priority), the consumer after (lower).
Goal seek is the canonical example. solve line 4 for rate = 900 involves two
packages:
- The
linespackage fusesline 4into aLINE_REFtoken, at priority 80. - The
goal-seekpackage, at priority 75, matchessolvesitting beside aLINE_REFand mintsGOAL_SEEK, leaving theLINE_REFfor its parselet.
Because 75 is lower than 80, the LINE_REF exists by the time goal seek looks for
it, and the two compose in either registration order. As a rough guide, use the
bands the built-ins use: around 100 for a long phrase, 80 for a short one, 50 for
an implicit operator, 20 for a domain rule, and go lower than the rule whose
output you read.
Reading the engine a rule runs in
Section titled “Reading the engine a rule runs in”A rule is one object, and every engine that loads the package runs the same
object. So a rule never reaches for a module-level registry to learn what an
engine holds: match is handed the engine’s environment as its third argument,
and reads it there.
import type { NormalizerRule } from "solve-engine/normalizer";
export const inConverterRule: NormalizerRule = { name: "my-package:in-converter", priority: 20, match(tokens, pos, environment) { const word = tokens[pos + 1]; if (tokens[pos]?.type !== "IN" || word?.type !== "IDENT") return null; // Whether this engine, not some other one, has a converter of that name. const found = environment?.asConverters?.match(word.value); if (found !== "exact" && found !== "folded") return null; // Build the replacement here, as the rule above does. return null; },};The environment’s type is NormalizerEnvironment, exported from
solve-engine/normalizer beside NormalizerRule, for a helper that takes it
apart from a rule.
Today the environment carries the engine’s as converters, as the built-in
rule that reads 99 in roman as 99 as roman needs them. Every field is
optional, and a normaliser built on its own, with no engine behind it, hands an
empty one, which a rule reads as “nothing registered”. A rule that reads nothing
of the engine’s, which is most of them, ignores the argument.
Name each rule, uniquely
Section titled “Name each rule, uniquely”Name a rule yourpackage:what-it-does, the way the built-ins do
(symbolic:call, lines:line-ref, goalseek:solve-line). The name is not just a
label: the normalizer unregisters rules by name, and removes every rule that
shares it. Two rules with the same name cannot be removed independently, so
unloading one package would silently drop the other’s rule. Give every rule its
own name. Phrases do not have this hazard, they live in the trie by their text and
are simply replaced if registered twice.