solve-engine 1.0.0-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +236 -0
  3. package/package.json +225 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2023-2026 Liam Riddell
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,236 @@
1
+ <div align="center">
2
+
3
+ <img src="https://raw.githubusercontent.com/LiamRiddell/solve-engine/main/static/solve-engine-banner-github.png" alt="Solve, a natural language expression engine" width="100%" />
4
+
5
+ **A calculator that reads like a sentence.**
6
+
7
+ [![npm](https://img.shields.io/npm/v/solve-engine?color=%230b7285&label=npm)](https://www.npmjs.com/package/solve-engine)
8
+ [![CI](https://github.com/LiamRiddell/solve-engine/actions/workflows/ci.yml/badge.svg)](https://github.com/LiamRiddell/solve-engine/actions/workflows/ci.yml)
9
+ [![Node](https://img.shields.io/node/v/solve-engine)](https://nodejs.org)
10
+ [![License](https://img.shields.io/badge/license-MIT-blue)](https://github.com/LiamRiddell/solve-engine/blob/main/packages/engine/LICENSE)
11
+
12
+ [Documentation](https://liamriddell.github.io/solve-engine/) &nbsp;&#8226;&nbsp;
13
+ [Playground](https://liamriddell.github.io/solve-engine/playground/) &nbsp;&#8226;&nbsp;
14
+ [Syntax reference](https://liamriddell.github.io/solve-engine/syntax/cheatsheet/)
15
+
16
+ </div>
17
+
18
+ A lexer, Pratt parser, bytecode VM, and an extensible package system for
19
+ evaluating natural-language-flavoured expressions: `2 + 2 * 10`, `50% of 200`,
20
+ `3 days + 4 hours`, `10 USD to GBP`, `100 cm + 2 m`.
21
+
22
+ Originally the engine inside
23
+ [Solve for Obsidian](https://github.com/LiamRiddell/obsidian-solve), extracted
24
+ so it can be embedded in any host: an editor plugin, a CLI, a desktop app, a
25
+ server. No dependency on a UI framework, a DOM, or an editor.
26
+
27
+ See [ARCHITECTURE.md](https://github.com/LiamRiddell/solve-engine/blob/main/packages/engine/ARCHITECTURE.md) for how the pipeline, package system, async
28
+ evaluation model, and caching layers fit together, plus a candid list of known
29
+ architectural debt.
30
+
31
+ ## Installation
32
+
33
+ ```bash
34
+ npm install solve-engine
35
+ ```
36
+
37
+ ## Quick start
38
+
39
+ ```typescript
40
+ import { ExpressionEngine } from "solve-engine";
41
+
42
+ const engine = new ExpressionEngine("en");
43
+ const [value] = engine.evaluateExpression("2 + 2 * 10");
44
+
45
+ console.log(value.toNumber()); // 22
46
+ ```
47
+
48
+ `evaluateExpression` throws an `EngineError` (see `solve-engine/errors`) on a parse or
49
+ evaluation failure, wrap calls with untrusted input in a `try`/`catch`.
50
+
51
+ For line-oriented input (e.g. a document made of multiple expressions, some referencing
52
+ variables defined on earlier lines), use `evaluateLine`/`parseDocument` instead, see the
53
+ `engine` subpath below.
54
+
55
+ ## Engine lifecycle
56
+
57
+ Call `clear()` when you are finished with an engine that has parsed a document.
58
+ Dropping your last reference is not enough on its own: the async batcher is
59
+ reachable from the module-level data query service, so a parsed engine stays
60
+ retained until `clear()` releases it.
61
+
62
+ ```typescript
63
+ const engine = new ExpressionEngine();
64
+ engine.parseDocument(text);
65
+ // ... read results ...
66
+ engine.clear();
67
+ ```
68
+
69
+ Measured per engine after a forced collection:
70
+
71
+ | Lifecycle | Retained |
72
+ | --- | --- |
73
+ | constructed, never parsed | 8.2KB |
74
+ | constructed and parsed | 128KB |
75
+ | constructed, parsed, cleared | 10KB |
76
+
77
+ This matters most for hosts that create one engine per document or per tab. Over
78
+ 10,000 create-and-drop cycles the uncleared path reaches roughly 1.2GB.
79
+
80
+ Reusing one engine across documents is also fine. `clear()` resets an engine for
81
+ the next document rather than consuming it, so there is no separate teardown
82
+ call to remember.
83
+
84
+ ## Formatting a result for display
85
+
86
+ ```typescript
87
+ import { formatValue } from "solve-engine/format";
88
+
89
+ const [value] = engine.evaluateExpression("10 USD to GBP");
90
+ console.log(formatValue(value)); // uses DEFAULT_FORMATTING_SETTINGS if no settings passed
91
+ ```
92
+
93
+ ## Package structure
94
+
95
+ `solve-engine` exposes its API as a set of subpath exports, grouped by how stable/low-level
96
+ they are:
97
+
98
+ | Subpath | Purpose |
99
+ |---|---|
100
+ | `solve-engine` | Start here, `ExpressionEngine`, `PackageRegistry`/`packageRegistry`, `IEnginePackage`. |
101
+ | `solve-engine/engine` | `ExpressionEngine` and its supporting types (`LineEvaluation`, `EvalResults`, etc.) directly, without the package-registration wrapper. |
102
+ | `solve-engine/vm` | The bytecode VM: `Value`/`ValueType`, opcode dispatch, `allocatePluginFunctionIndex`. |
103
+ | `solve-engine/format` | Turning a `Value` into a display string (numbers, dates, units, vectors, ...). |
104
+ | `solve-engine/language` | Editor-agnostic language service: token categories, completions, highlighting. |
105
+ | `solve-engine/packages` | The built-in packages (arithmetic, datetime, time, dice, uom, currency, vector, conditionals, converters, mathphrases, ...). |
106
+ | `solve-engine/constants` | Engine configuration types and defaults (`EngineConfig`, `VMConfig`, ...). |
107
+
108
+ The following subpaths are **advanced-public**, everything a third-party package author
109
+ needs to extend the engine, but with a looser stability contract than the tier above (these
110
+ are the pieces the built-in packages and the [OSRS example](https://github.com/LiamRiddell/solve-engine/tree/main/packages/engine/examples/osrs) themselves
111
+ depend on):
112
+
113
+ | Subpath | Purpose |
114
+ |---|---|
115
+ | `solve-engine/lexer` | Tokenizer, `LexerVocabulary` for registering custom keywords/operators/units. |
116
+ | `solve-engine/parser` | Pratt parser, `BytecodeBuilder`, `OpCode`. |
117
+ | `solve-engine/normalizer` | Post-lexer token transforms (phrase fusion, implicit multiply). |
118
+ | `solve-engine/variables` | Variable resolution (`IVariableSource`). |
119
+ | `solve-engine/resolvers` | Async resolvers (`IAsyncResolver`) for data that loads asynchronously. |
120
+ | `solve-engine/errors` | `EngineError` and the error factory. |
121
+ | `solve-engine/utilities` | Small stateless helpers (e.g. `stripQuotes`). |
122
+ | `solve-engine/uom` | Units-of-measurement conversion tables and currency exchange. |
123
+ | `solve-engine/services` | Supporting services (query client construction, etc). |
124
+
125
+ Anything not listed above (`telemetry`, `cache`, `diagnostics`, `types`, `workers`) is
126
+ internal and not part of the package's public contract, it may change or disappear between
127
+ minor versions without notice.
128
+
129
+ ## Authoring a package
130
+
131
+ A **package** (`IEnginePackage`) is a plain data descriptor bundling everything needed to
132
+ extend the engine with a new domain: custom tokens, parselets, VM opcode handlers, variable
133
+ sources, and optional async resolvers. See
134
+ [`solve-engine/api`'s `IEnginePackage`](https://github.com/LiamRiddell/solve-engine/blob/main/packages/engine/src/api/PackageRegistry.ts) for the full field list
135
+ with inline documentation and examples for each field.
136
+
137
+ Minimal shape:
138
+
139
+ ```typescript
140
+ import { allocatePluginFunctionIndex } from "solve-engine/vm";
141
+ import type { IEnginePackage } from "solve-engine";
142
+
143
+ const MY_FN_IDX = allocatePluginFunctionIndex();
144
+
145
+ export const MY_PACKAGE: IEnginePackage = {
146
+ name: "MyPackage",
147
+ // engineVersion: "^0.1.0", // optional, see below
148
+ prefixParselets: [{ tokenType: "MY_FUNC", parselet: new MyParselet() }],
149
+ pluginFunctions: [{ index: MY_FN_IDX, handler: (args) => /* ... */ }],
150
+ };
151
+ ```
152
+
153
+ Register it either as one of the packages passed to the `ExpressionEngine` constructor, or
154
+ at runtime via `ExpressionEngine.registerPackage()` / `unregisterPackage()`.
155
+
156
+ ### Declaring engine-version compatibility
157
+
158
+ `IEnginePackage.engineVersion` is an optional semver range (e.g. `"^0.1.0"`) declaring which
159
+ `solve-engine` versions your package is built against. It's checked against the real, running
160
+ engine version at registration time. Omit it and your package always registers, exactly as
161
+ before this field existed, this is the default for every package that predates it. Declare it
162
+ once you want protection against the reverse case: your package being loaded into a much
163
+ newer (or much older) engine whose `IEnginePackage` contract has since changed shape.
164
+
165
+ Unlike every other compatibility signal in this codebase (see `ARCHITECTURE.md` §5.2's
166
+ sibling-package collision warnings, which always log and proceed), a declared range the
167
+ running engine does **not** satisfy causes `registerPackage()` to **throw**, not warn, see
168
+ `ARCHITECTURE.md` §5.3 for the full reasoning.
169
+
170
+ ### Three more extension points, beyond `pluginFunctions`
171
+
172
+ - **`solve-engine/parser`'s `definePhrasePattern()`**, build a phrase-grammar parselet
173
+ (`roll between X and Y`, `average of X, Y, Z`) from a declarative list of
174
+ `{ slots, emit }` alternatives instead of hand-writing `parser.consume()`/
175
+ `parseExpression()` calls. See `packages/mathphrases/` for several real examples, and
176
+ its own JSDoc for the one hard constraint (every alternative must start with a keyword
177
+ slot) and when a hand-written parselet is the right call instead.
178
+ - **`solve-engine/resolvers`'s `createQueryResolver()`**, a factory for the common
179
+ "one cached async fetch → one `Value`" shape (weather, stock prices, a game-item price
180
+ API, see `examples/osrs`), generalizing the caching/staleness plumbing so a package
181
+ only needs to write the fetch call and the response mapping.
182
+ - **`IEnginePackage.asConverters`**, contribute a custom `as <name>` conversion (e.g.
183
+ `50% as decimal`) to the built-in `converters` package's grammar: `{ myUnit: (value) =>
184
+ /* ... */ }`. No lexer keyword registration needed, any bare word after "as" that isn't
185
+ one of the built-in names resolves against this registry at runtime.
186
+
187
+ See `ARCHITECTURE.md`'s §5.1 for the full reasoning behind each, including a real
188
+ regression (and its fix pattern) worth reading before picking a keyword for your own
189
+ package: a colon-prefixed variable name (`:name = expr`) can never be a keyword-shaped
190
+ word in this engine, so a common-noun trigger word (like "total") should be phrase-fused
191
+ with its qualifying keyword rather than claimed bare.
192
+
193
+ Two runnable examples, both under [`examples/`](https://github.com/LiamRiddell/solve-engine/tree/main/packages/engine/examples) (example code, not part of the
194
+ published package, see `files` in `package.json`, only `dist/` ships):
195
+
196
+ - [`examples/basic`](https://github.com/LiamRiddell/solve-engine/tree/main/packages/engine/examples/basic), the smallest complete package: one custom keyword
197
+ (`reverse("text")`) dispatched through a plugin function, nothing else. Start here. Its
198
+ test, [`__tests__/examples/basic/BasicPackage.spec.ts`](https://github.com/LiamRiddell/solve-engine/blob/main/packages/engine/__tests__/examples/basic/BasicPackage.spec.ts),
199
+ shows the full register-and-evaluate loop end to end.
200
+ - [`examples/osrs`](https://github.com/LiamRiddell/solve-engine/tree/main/packages/engine/examples/osrs), a fuller example covering everything `basic` leaves
201
+ out: a phrase-fused multi-word item name, an async resolver backed by a real HTTP API, a
202
+ custom highlight category, and completion items. Prices Old School RuneScape Grand Exchange
203
+ items (e.g. `ge("Abyssal whip")`).
204
+
205
+ ## Known limitations
206
+
207
+ **Cross-instance isolation is partial.** Plugin functions, the opcode registry and
208
+ variable sources are now owned per `ExpressionEngine`, so two engines with different
209
+ package sets no longer interfere across those. The lexer and the currency exchange rates
210
+ are still module-level singletons, so full isolation between two engines in one process
211
+ cannot yet be assumed. Tracked as "L1, EngineContext"; three of its five migrations have
212
+ landed and the remaining two are a prerequisite for 1.0.0 proper.
213
+
214
+ **`variableSources` does nothing.** A package can declare them, the engine registers and
215
+ unregisters them, and no evaluation path ever consults them. Treat the extension point as
216
+ absent until that changes.
217
+
218
+ **Async results need a host hook.** `AsyncResolutionBatcher.onLineResult` is the only
219
+ mechanism that patches a resolved async value back into the document model, and it is not
220
+ wired inside the package. A host that does not supply it gets async values that never
221
+ resolve, with no error to explain why.
222
+
223
+ ## Development
224
+
225
+ Developed in the [solve-engine](https://github.com/LiamRiddell/solve-engine) repository as
226
+ an npm workspace (`packages/engine`).
227
+
228
+ ```bash
229
+ npm run build # tsup, emits ESM + CJS + .d.ts to dist/
230
+ npm run dev # tsup --watch
231
+ npm test # standalone jest run, scoped to this package
232
+ ```
233
+
234
+ ## License
235
+
236
+ MIT, see [LICENSE](https://github.com/LiamRiddell/solve-engine/blob/main/packages/engine/LICENSE).
package/package.json ADDED
@@ -0,0 +1,225 @@
1
+ {
2
+ "name": "solve-engine",
3
+ "version": "1.0.0-beta.0",
4
+ "description": "The expression evaluation engine behind Solve — lexer, parser, bytecode VM, and an extensible package system.",
5
+ "keywords": [
6
+ "expression-evaluator",
7
+ "calculator",
8
+ "bytecode-vm",
9
+ "parser",
10
+ "lexer",
11
+ "dsl",
12
+ "markdown",
13
+ "obsidian"
14
+ ],
15
+ "license": "MIT",
16
+ "author": "Liam Riddell",
17
+ "homepage": "https://liamriddell.github.io/solve-engine/",
18
+ "repository": {
19
+ "type": "git",
20
+ "url": "git+https://github.com/LiamRiddell/solve-engine.git",
21
+ "directory": "packages/engine"
22
+ },
23
+ "bugs": {
24
+ "url": "https://github.com/LiamRiddell/solve-engine/issues"
25
+ },
26
+ "publishConfig": {
27
+ "access": "public"
28
+ },
29
+ "type": "module",
30
+ "engines": {
31
+ "node": ">=22"
32
+ },
33
+ "files": [
34
+ "dist",
35
+ "LICENSE"
36
+ ],
37
+ "main": "./dist/index.cjs",
38
+ "module": "./dist/index.js",
39
+ "types": "./dist/index.d.ts",
40
+ "exports": {
41
+ ".": {
42
+ "import": {
43
+ "types": "./dist/index.d.ts",
44
+ "default": "./dist/index.js"
45
+ },
46
+ "require": {
47
+ "types": "./dist/index.d.cts",
48
+ "default": "./dist/index.cjs"
49
+ }
50
+ },
51
+ "./engine": {
52
+ "import": {
53
+ "types": "./dist/engine.d.ts",
54
+ "default": "./dist/engine.js"
55
+ },
56
+ "require": {
57
+ "types": "./dist/engine.d.cts",
58
+ "default": "./dist/engine.cjs"
59
+ }
60
+ },
61
+ "./vm": {
62
+ "import": {
63
+ "types": "./dist/vm.d.ts",
64
+ "default": "./dist/vm.js"
65
+ },
66
+ "require": {
67
+ "types": "./dist/vm.d.cts",
68
+ "default": "./dist/vm.cjs"
69
+ }
70
+ },
71
+ "./format": {
72
+ "import": {
73
+ "types": "./dist/format.d.ts",
74
+ "default": "./dist/format.js"
75
+ },
76
+ "require": {
77
+ "types": "./dist/format.d.cts",
78
+ "default": "./dist/format.cjs"
79
+ }
80
+ },
81
+ "./language": {
82
+ "import": {
83
+ "types": "./dist/language.d.ts",
84
+ "default": "./dist/language.js"
85
+ },
86
+ "require": {
87
+ "types": "./dist/language.d.cts",
88
+ "default": "./dist/language.cjs"
89
+ }
90
+ },
91
+ "./packages": {
92
+ "import": {
93
+ "types": "./dist/packages.d.ts",
94
+ "default": "./dist/packages.js"
95
+ },
96
+ "require": {
97
+ "types": "./dist/packages.d.cts",
98
+ "default": "./dist/packages.cjs"
99
+ }
100
+ },
101
+ "./constants": {
102
+ "import": {
103
+ "types": "./dist/constants.d.ts",
104
+ "default": "./dist/constants.js"
105
+ },
106
+ "require": {
107
+ "types": "./dist/constants.d.cts",
108
+ "default": "./dist/constants.cjs"
109
+ }
110
+ },
111
+ "./lexer": {
112
+ "import": {
113
+ "types": "./dist/lexer.d.ts",
114
+ "default": "./dist/lexer.js"
115
+ },
116
+ "require": {
117
+ "types": "./dist/lexer.d.cts",
118
+ "default": "./dist/lexer.cjs"
119
+ }
120
+ },
121
+ "./parser": {
122
+ "import": {
123
+ "types": "./dist/parser.d.ts",
124
+ "default": "./dist/parser.js"
125
+ },
126
+ "require": {
127
+ "types": "./dist/parser.d.cts",
128
+ "default": "./dist/parser.cjs"
129
+ }
130
+ },
131
+ "./normalizer": {
132
+ "import": {
133
+ "types": "./dist/normalizer.d.ts",
134
+ "default": "./dist/normalizer.js"
135
+ },
136
+ "require": {
137
+ "types": "./dist/normalizer.d.cts",
138
+ "default": "./dist/normalizer.cjs"
139
+ }
140
+ },
141
+ "./variables": {
142
+ "import": {
143
+ "types": "./dist/variables.d.ts",
144
+ "default": "./dist/variables.js"
145
+ },
146
+ "require": {
147
+ "types": "./dist/variables.d.cts",
148
+ "default": "./dist/variables.cjs"
149
+ }
150
+ },
151
+ "./resolvers": {
152
+ "import": {
153
+ "types": "./dist/resolvers.d.ts",
154
+ "default": "./dist/resolvers.js"
155
+ },
156
+ "require": {
157
+ "types": "./dist/resolvers.d.cts",
158
+ "default": "./dist/resolvers.cjs"
159
+ }
160
+ },
161
+ "./errors": {
162
+ "import": {
163
+ "types": "./dist/errors.d.ts",
164
+ "default": "./dist/errors.js"
165
+ },
166
+ "require": {
167
+ "types": "./dist/errors.d.cts",
168
+ "default": "./dist/errors.cjs"
169
+ }
170
+ },
171
+ "./utilities": {
172
+ "import": {
173
+ "types": "./dist/utilities.d.ts",
174
+ "default": "./dist/utilities.js"
175
+ },
176
+ "require": {
177
+ "types": "./dist/utilities.d.cts",
178
+ "default": "./dist/utilities.cjs"
179
+ }
180
+ },
181
+ "./uom": {
182
+ "import": {
183
+ "types": "./dist/uom.d.ts",
184
+ "default": "./dist/uom.js"
185
+ },
186
+ "require": {
187
+ "types": "./dist/uom.d.cts",
188
+ "default": "./dist/uom.cjs"
189
+ }
190
+ },
191
+ "./services": {
192
+ "import": {
193
+ "types": "./dist/services.d.ts",
194
+ "default": "./dist/services.js"
195
+ },
196
+ "require": {
197
+ "types": "./dist/services.d.cts",
198
+ "default": "./dist/services.cjs"
199
+ }
200
+ }
201
+ },
202
+ "scripts": {
203
+ "build": "tsup",
204
+ "dev": "tsup --watch",
205
+ "test": "jest --config jest.config.cjs --no-coverage",
206
+ "typecheck": "tsgo --noEmit --skipLibCheck",
207
+ "typecheck:tsc": "tsc --noEmit --skipLibCheck"
208
+ },
209
+ "dependencies": {
210
+ "@tanstack/query-core": "^5.100.10",
211
+ "semver": "^7.6.0",
212
+ "tslib": "^2.6.1"
213
+ },
214
+ "devDependencies": {
215
+ "@jest/globals": "^30.4.1",
216
+ "@types/node": "^26.1.2",
217
+ "@types/semver": "^7.5.0",
218
+ "convert": "^7.0.0",
219
+ "jest": "^30.4.2",
220
+ "ts-jest": "^29.4.12",
221
+ "tsup": "8.5.1",
222
+ "typescript": "5.9.3"
223
+ },
224
+ "sideEffects": false
225
+ }