Skip to content

Commit eac5c72

Browse files
committed
commit missing stuff from v18 blog post
1 parent 319e7fb commit eac5c72

5 files changed

Lines changed: 516 additions & 0 deletions

File tree

blog/2026-02-20-ohm-v18.md

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
---
2+
title: Ohm v18 Beta
3+
slug: ohm-v18
4+
description: "Ohm v18 compiles grammars to WebAssembly, making parsing ~20x faster and using a fraction of the memory."
5+
image: ./ohm-v18.png
6+
---
7+
8+
_aka "The One that Compiles to Wasm"._
9+
10+
After nearly a year of development, we're excited to announce the first beta release of Ohm v18 — the biggest change to Ohm since its initial release. We've totally reworked the core parsing engine to be WebAssembly-based, making parsing around 20x faster on real-world grammars while using a fraction of the memory.
11+
12+
## What's new
13+
14+
Every version of Ohm up to v17 worked the same way under the hood: when you call `grammar.match()`, Ohm walks a tree of parsing expression objects (PExprs), calling `eval()` on each node. (It's a so-called [tree-walking interpreter](https://craftinginterpreters.com/a-tree-walk-interpreter.html).) In the process, it builds up a huge parse tree, with each node a separate object that must be managed by the GC.
15+
16+
v18 takes a completely different approach. At build time, the new `@ohm-js/compiler` translates your grammar into a WebAssembly module, compiling each rule to its own function. The parse tree is allocated into Wasm linear memory, and nodes used a packed representation which is much, much more memory efficient.
17+
18+
Also, the runtime (`ohm-js`) is now separate from the compiler (`@ohm-js/compiler`):
19+
20+
```bash
21+
npx ohm2wasm my-grammar.ohm # compile at build time
22+
```
23+
24+
```js
25+
import {Grammar} from 'ohm-js';
26+
27+
// load and run at runtime
28+
const g = await Grammar.instantiate(fs.readFileSync('my-grammar.wasm'));
29+
```
30+
31+
## How much faster?
32+
33+
We've been benchmarking with two real-world workloads:
34+
35+
1. Our [official ES5 grammar](https://github.com/ohmjs/ohm/blob/main/examples/ecmascript/src/es5.ohm) compiling a large (742K) JavaScript file.
36+
2. Shopify's [LiquidHTML grammar](https://github.com/Shopify/theme-tools/blob/main/packages/liquid-html-parser/grammar/liquid-html.ohm), parsing all the Liquid templates from their [Dawn theme](https://github.com/Shopify/dawn).
37+
38+
One these two benchmarks, v18 parses about **22x faster** than v17, and requires less than 20% of the memory. 🔥
39+
40+
## Breaking changes
41+
42+
Along with the new runtime, v18 has a significantly reworked API. Check out the [migration guide](../docs/releases/ohm-js-18.0) for all the details on the new API, what's changed, and what's not in v18 yet.
43+
44+
And please note: **the new API is still in flux**, so expect some changes before the stable release.
45+
46+
## Try it out
47+
48+
```bash
49+
npm install ohm-js@beta # Runtime (production dependency)
50+
npm install --save-dev @ohm-js/compiler@beta # Compiler (dev dependency)
51+
```
52+
53+
If you want to kick the tires without changing your build setup, there's a compat helper that parses, compiles, and instantiates in one step — just like the old `ohm.grammar()`:
54+
55+
```js
56+
import {grammar} from '@ohm-js/compiler/compat';
57+
58+
const g = grammar('MyGrammar { start = "hello" }');
59+
using result = g.match('hello');
60+
```
61+
62+
(This compiles on every call, so it's great for prototyping but probably not what you want for production.)
63+
64+
We'd love to hear your feedback on v18. Give it a spin, and let us know what you think on [Discord](https://discord.gg/KwxY5gegRQ) or [GitHub Discussions](https://github.com/ohmjs/ohm/discussions).

blog/ohm-v18.png

39.3 KB
Loading

docs/cli-migration-plan.md

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
# CLI Migration Plan
2+
3+
Unify around a single `ohm` CLI shipped by `@ohm-js/compiler`, with `compile`
4+
(wasm-first batch compilation), `match` (wasm-first), and type generation as the
5+
stable surface.
6+
7+
## End-State CLI Surface
8+
9+
Binary name: `ohm` (from `@ohm-js/compiler`)
10+
11+
### `ohm compile <patterns...>`
12+
13+
Compiles `.ohm` sources to wasm artifacts in batch (glob-aware), plus optional
14+
type generation. This is the v18 successor to `generateBundles`.
15+
16+
Flags:
17+
18+
- `--cwd <dir>` — base directory for glob expansion
19+
- `-o, --outDir <dir>` — where to write outputs (default: alongside source)
20+
- `-t, --withTypes` — generate corresponding `.d.ts`
21+
- `-g, --grammarName <name>` — compile only one grammar from a multi-grammar file
22+
(default: compile all)
23+
- `-n, --dryRun` — print/plan without writing
24+
- `--quiet` / `--verbose`
25+
26+
Output naming:
27+
28+
- `path/to/foo.ohm``path/to/foo.ohm.wasm`
29+
- With `--withTypes`: `path/to/foo.ohm.d.ts`
30+
31+
### `ohm match <inputPath>`
32+
33+
Wasm-first matching. Accepts either a `.wasm` artifact or a source `.ohm` file
34+
(compile-then-match).
35+
36+
Flags:
37+
38+
- `-g, --grammar <path>``.ohm` (compile in-memory, then match) or `.wasm`
39+
(load directly)
40+
- `--grammarName <name>` — select grammar from multi-grammar file
41+
- `--startRule <rule>` — optional start rule selection
42+
43+
Behavior: exit 0 on success, non-zero with failure message on mismatch.
44+
45+
### `ohm types <patterns...>` (optional)
46+
47+
Standalone type generation, useful when you want `.d.ts` without emitting wasm.
48+
49+
Flags: `--cwd`, `--outDir`, `--grammarName`, `--dryRun`
50+
51+
### Naming
52+
53+
- Primary verb: `ohm compile` (not `build`), since "compile to wasm" is the core
54+
v18 story.
55+
- No default action — bare `ohm` shows help.
56+
57+
## Migration Phases
58+
59+
### Phase 0: Today
60+
61+
- `@ohm-js/compiler` ships `ohm2wasm` (single-file, no globs/batch)
62+
- `@ohm-js/cli` ships `ohm` with `generateBundles` (v17 recipes) and `match`
63+
(v17 runtime)
64+
65+
### Phase 1: Introduce unified `ohm` in the compiler (v18 alpha → early beta)
66+
67+
Goal: make `@ohm-js/compiler` fully usable as the official CLI.
68+
69+
1. Add `ohm` bin entry to `@ohm-js/compiler`, alongside `ohm2wasm`.
70+
2. Refactor `ohm2wasm` implementation into `ohm compile`:
71+
- Add glob support via `fast-glob`
72+
- Add `--outDir`, `--cwd`, `--withTypes`, batch compilation
73+
- If invoked as `ohm2wasm`, behave like `ohm compile <file>` with a
74+
deprecation warning.
75+
3. Implement `ohm match` wasm-first:
76+
- `--grammar` accepts `.wasm` (load directly) or `.ohm` (compile-then-match)
77+
4. Move `generateTypes` into `@ohm-js/compiler` (the compiler already bundles
78+
`ohm-js-legacy` internally via esbuild — use that for type generation without
79+
exposing it publicly).
80+
5. Update docs to recommend `pnpm add -D @ohm-js/compiler` and `ohm compile`.
81+
82+
### Phase 2: Deprecate `@ohm-js/cli` (v18 beta)
83+
84+
Goal: keep the old install path working while steering users to the compiler.
85+
86+
1. Release a new major of `@ohm-js/cli` that:
87+
- Depends on `@ohm-js/compiler`
88+
- Forwards all args to the compiler's `ohm` CLI
89+
- Prints deprecation warning
90+
2. Compatibility mapping:
91+
- `ohm generateBundles …` → forwards to `ohm compile …`
92+
- `ohm match …` → forwards to compiler's `ohm match …`
93+
3. Deprecate the package on npm.
94+
95+
### Phase 3: Compiler CLI is the only real CLI (v18 stable)
96+
97+
1. `@ohm-js/compiler`'s `ohm` is the official CLI in all docs.
98+
2. `@ohm-js/cli` remains as deprecated wrapper only — no new features.
99+
3. `generateBundles` kept as deprecated alias for one stable cycle.
100+
101+
### Phase 4: Clean up (v19 or later)
102+
103+
- Stop publishing `@ohm-js/cli`, or keep as permanent thin wrapper.
104+
- Optionally remove `ohm2wasm` alias, or keep it (low maintenance cost).
105+
106+
## Type Generation in v18
107+
108+
The compiler already bundles `ohm-js-legacy` via esbuild for internal use. Type
109+
generation can reuse this internal representation to produce `.d.ts` files
110+
without exposing legacy APIs publicly.
111+
112+
- Primary: `ohm compile --withTypes`
113+
- Optional: `ohm types <patterns...>` for CI workflows that want types without
114+
wasm
115+
116+
## Risks and Guardrails
117+
118+
- **Artifact format churn**: lock down output naming (`.ohm.wasm`, `.ohm.d.ts`)
119+
early.
120+
- **Multi-grammar files**: default is compile all; `--grammarName` selects one.
121+
- **Dependencies**: anything needed at runtime by the CLI (`commander`,
122+
`fast-glob`) must be in `dependencies` of `@ohm-js/compiler`, not
123+
`devDependencies`.
124+
- **Node version**: `ohm match` with `.ohm` input requires Node 24 (same as the
125+
compiler). Document clearly.
126+
127+
## Future Considerations (not now)
128+
129+
- `--emit wasm+js` for generating JS loader modules (depends on v18 runtime
130+
loading API stabilizing)
131+
- `--watch` mode
132+
- Incremental compilation / caching via `--cacheDir`

docs/incremental-semantics.md

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
# Incremental semantics
2+
3+
Ohm supports incremental parsing, meaning that once an input is parsed, it can be quickly reparsed after each edit operation. Incremental _parsing_ is straightforward to use: you just need to use instantiate a [Matcher object](https://ohmjs.org/docs/api-reference#matcher-objects) rather than directly using your Grammar's `match` method.
4+
5+
It's also possible to build fully incremental processing pipelines on top of Ohm's incremental parsing, but this is less straightforward. In this document we talk about some of the strategies for doing so.
6+
7+
## Understanding the _overlap rule_
8+
9+
TODO
10+
11+
## Defining an attribute
12+
13+
The first step for building an incremental processing pipeline is to define an attribute. In Ohm, an _attribute_ is like an operation, but (a) it takes no arguments, and (b) it is memoized. The attribute's value for a given node will be recalculated whenever the edit may have affected that node. For nodes that are not affected by an edit, the attribute value is cached.
14+
15+
So, the simplest kind of incremental processing pipeline you can build consists of a single attribute. For example, for an arithmetic grammar, you might define a `value` attribute for evaluating arithmetic expressions:
16+
17+
```js
18+
const semantics = grammar.createSemantics().addAttribute('value', {
19+
Exp: (addExp) => addExp.value,
20+
AddExp_plus: (left, _op, right) => left.value + right.value,
21+
AddExp_minus: (left, _op, right) => left.value - right.value,
22+
AddExp: (priExp) => priExp.value,
23+
PriExp_paren: (_open, exp, _close) => exp.value,
24+
PriExp: (number) => number.value,
25+
number(digits) {
26+
return parseInt(this.sourceString, 10);
27+
}
28+
});
29+
```
30+
31+
And suppose you used the grammar and a `Matcher` object to evaluate an expression, then make an edit, then re-parse and re-evaluate:
32+
33+
```js
34+
const m = grammar.matcher();
35+
36+
m.setInput('(1 + 2) + (3 - 4)');
37+
assert.equal(semantics(m.match()).value, 2);
38+
39+
m.replaceInputRange(1, 2, '0'); // Replace 1 with 0
40+
assert.equal(semantics(m.match()).value, 1);
41+
```
42+
43+
Note that the `AddExp_minus` action, which calculates the value of "3 - 4", will only run once. The edit does not affect that part of the parse result, so the attribute value is cached.
44+
45+
Ohm's caching of attribute values is naive; notably, it does not track dependencies between attribute values or do any kind of [autotracking](https://www.pzuraq.com/blog/what-is-reactivity) as seen in signals frameworks.
46+
47+
If the naive caching is not sufficient, there is an **internal** operation named `_forgetMemoizedResultFor` TODO
48+
49+
## Building more complex pipelines
50+
51+
You can leverage the caching of attributes to build more complex transformations. For example, if you define an `ast` attribute, you can rely on object identity to determine which AST nodes were affected by an edit:
52+
53+
```js
54+
const m = grammar.matcher();
55+
m.setInput("(1 + 2) + (3 - 4)");
56+
57+
const seen = new Set(); // Could also use WeakSet here.
58+
let root = semantics(m.match()).ast;
59+
seen.add(root);
60+
seen.add(root.left);
61+
seen.add(root.right);
62+
63+
m.replaceInputRange(1, 2, "0"); // Replace 1 with 0
64+
root = semantics(m.match()).ast;
65+
66+
// The root and left child are recreated; the right child is reused.
67+
assert(!seen.has(root));
68+
assert(!seen.has(root.left));
69+
assert(seen.has(root.right));
70+
```
71+
72+
You can then build additional transformations on top of the AST, using recursive tree-walking functions and memoization to avoid reprocessing unchanged subtrees. For example:
73+
74+
```js
75+
// Returns an array containing all the nubmers in the given subtree.
76+
function getNumbers(node, cache = new WeakMap()) {
77+
if (cache.has(node)) return cache.get(node);
78+
79+
let result;
80+
if (node.type === "Number") {
81+
result = [node.value];
82+
} else if (node.type === "BinaryOp") {
83+
result = [
84+
...getNumbers(node.left, cache),
85+
...getNumbers(node.right, cache),
86+
];
87+
} else {
88+
throw new Error(`Unknown node type: ${node.type}`);
89+
}
90+
cache.set(node, result);
91+
return result;
92+
}
93+
```
94+
95+
Rather than implementing the memoization yourself, you can also use an existing library like [memoizee](https://www.npmjs.com/package/memoizee), [micro-memoize](https://www.npmjs.com/package/micro-memoize), etc.
96+
97+
## Dependencies on siblings
98+
99+
When an attribute value depends on the value of a sibling, you can:
100+
101+
- Define an operation to compute the value, potentially taking one or more arguments with context information.
102+
- Define an attribute which caches the value of invoking the operation on all the node's children.
103+
104+
This is an extremely useful pattern that can apply to many problems in text processing. For example, Ohm internally uses a version of this when calculating the absolute offset of nodes in the parse tree. Each node caches the relative offsets of all its children, so after an edit that affects _k_ nodes, the offsets can be updated in O(k) time.
105+
106+
See [Zed Decoded: Rope & SumTree](https://zed.dev/blog/zed-decoded-rope-sumtree) for a discussion of how this same pattern is used in text editors.

0 commit comments

Comments
 (0)