Skip to content

The three modes ​

Superseded in part

Statements on this page about the composeLeaf precompiled assembly, the size budget, and runtime compose() under CSP are superseded by the runtime and size contract, which is binding. See its "Superseded statements" list.

One grammar, three ways to run it, identical results from all of them. You write the combinators once; the mode only decides when they turn into running code, and how fast that code ends up being.

ModeSetupPer-parse workWhere it fits
InterpreterNoneRuns the grammar directly, with automatic optimizations for parsers that return JavaScript valuesTests, REPLs, dynamic grammars, browsers, anywhere a bundler isn't around
Macro buildBundler plugin + with { type: 'macro' } — lowers at build timeRuns a table artifact through the shared table runtime; a large terminal composeLeaf also embeds one strict assemblyProduction apps built with Vite/Rollup/webpack
compile()Call compile() — lowers once at runtimeRuns a specialised live table assembly, with a closure fallbackGrammars assembled dynamically at runtime

Most projects live in the first two. compile() is there for the grammars you can't know about until runtime.

Interpreter (the default) ​

Import a combinator, call parse(), done. Parsers that return ordinary JavaScript values are optimized automatically, while advanced features such as recovery, line tracking, and CST/trivia capture continue to work as before. There is no build step, no new Function, and nothing to configure. The interpreter stays lightweight for browsers and runs anywhere JavaScript does.

ts
import { choice, literal, parse } from 'parseman'

const yesNo = choice(literal('yes'), literal('no'))
parse(yesNo, 'yes') // { ok: true, value: 'yes', … }

This is the mode your tests use, and it's the safety net the other two fall back to when their tooling isn't around.

Macro build (no runtime compile step) ​

Register the bundler plugin and add with { type: 'macro' } to your parseman import — that's standard import-attributes syntax, with macro as the bundler convention for "evaluate this at build time."

At build time the plugin runs your combinator declarations and replaces them with a table literal. Ordinary rules and compose() stay compact data. A big enough terminal composeLeaf() gets one extra thing: a precompiled assembly baked in at build time, trading a few generated bytes for a faster parse. Its line-tracking and CST siblings stay compact, so you don't pay for that four times over.

The combinator import vanishes. Every artifact imports the shared parseman/table runtime instead of carrying its own copy of a parser.

Running the grammar is still a parse() call, so parseman stays an ordinary import in the code that drives the parser. The macro removes the combinators and the compiler from your bundle — not the driver.

ts
import { literal, sequence, choice } from 'parseman' with { type: 'macro' }

Same combinators, nothing else changes. If the attribute ever gets stripped — an older bundler, a test runner — it's silently ignored and the interpreter takes over. Identical results, no errors, no drama. This is the recommended path for shipping apps; the details live in Macro mode.

compile() (runtime lowering) ​

compile() runs the same optimizer as the plugin, just later. Reach for it when you assemble a grammar from config or user input, or when you want the speed without adding a build step:

ts
import { choice, literal, compile } from 'parseman'

const compiled = compile(choice(literal('yes'), literal('no')))
compiled.parse('yes', 0, { trackLines: false }) // { ok: true, value: 'yes', … }
compiled.source                                  // printable table module source
compiled.inlineExpression                        // table expression (requires tableRules)

Content Security Policy

Macro output and every artifact printed by compile() carry an explicit empty assembly inventory (a:[]), so importing and parsing those artifacts never calls new Function. A live parser returned directly by runtime compile() instead tries to specialise its table once with new Function; if the environment rejects that operation, Parseman catches the EvalError and uses the closure assembler. Runtime compose() follows the same rule. Both paths therefore parse under a strict Content Security Policy, while the macro avoids even attempting runtime source construction.

Terminal large composeLeaf() artifacts carry one ordinary function literal for their strict AST/no-lines assembly; it is emitted by the build, not constructed from source at runtime. Small leaves remain on the empty-inventory closure form.

test/unit/no-function-constructor.test.ts proves the serialized and macro routes never reach globalThis.Function. test/unit/canonical-closure-artifact.test.ts separately proves one-time live specialisation, cache reuse, and the blocked-Function closure fallback.

Compiling costs something up front — roughly 75–650 µs depending on how big the grammar is — so it pays off when you're parsing many inputs through the same compiled parser, and not much when you're parsing one.

Choosing a mode ​

  • Shipping an app through a bundler? Use the macro build — no compile step at runtime, and it falls back to the interpreter automatically anywhere the attribute is stripped.
  • Writing tests, scripts, or a REPL? Use the interpreter. It's the default and needs nothing.
  • Building a grammar from user input or config at runtime? Use compile(); it specialises a live table when permitted and falls back automatically under CSP.

Since all three produce identical results, you can develop against the interpreter and flip on the macro for production without touching a line of grammar.

Debugging compiled grammars ​

Compiled parsers have a reputation for being undebuggable. That reputation is earned elsewhere, not here — what you step through depends on the mode, and two of the three show you your own source:

ModeWhat you step through
InterpreterYour combinator source directly — no compilation, no indirection
Macro buildYour combinator source via source maps — breakpoints on choice(...) lines hit when the compiled function runs
compile()Generated JS (compiled.source) — no IDE source maps today

Interpreter is the easiest place to be while you're still writing the grammar. You're running the tree you wrote, so a breakpoint lands exactly where you'd expect.

Macro build compiles that tree away, but the bundler plugin emits precise source maps via magic-string. Step through in a debugger and you see your combinator source, not the emitted codePointAt dispatch.

compile() hands you the generated source as a string you can read, but doesn't wire up IDE source maps today. The pragmatic move: develop on the interpreter, switch to macro or compile() once the grammar has stopped moving.

Released under the MIT License. Commercial support available on request.