Recursive rules
Real grammars are recursive: a JSON value contains arrays, which contain values. To let combinators reference each other by name — including before they're defined — use rules().
rules()
Pass a factory that receives all rule names as ready-to-use references and returns the definitions. Any rule can reference any other through the g argument, regardless of declaration order.
Rule names must be valid JavaScript identifiers (
Value,valueList,$foo_1). They compile to_r_<Name>functions and cross-artifact dispatch guards, so a non-identifier key like'my-rule'is rejected at compile time with a clear error rather than silently mangled. (This is about the grammar's rule names — not the text your grammar parses, which can be anything.)
import { rules, parser, choice, sequence, literal, sepBy, transform, trivia, regex } from 'parseman'
import type { Combinator } from 'parseman'
type JSON = null | boolean | number | string | JSON[] | Record<string, JSON>
const ws = trivia(regex(/[ \t\n\r]*/))
const { value } = rules<{ value: Combinator<JSON> }>(g => {
const comma = literal(',')
const array = transform(
sequence(literal('['), sepBy(g.value, comma), literal(']')),
([, items]) => items as JSON[]
)
const pair = transform(
sequence(jsonString, literal(':'), g.value),
([key, , val]) => [key, val] as [string, JSON]
)
const object = transform(
sequence(literal('{'), sepBy(pair, comma), literal('}')),
([, pairs]) => Object.fromEntries(pairs) as Record<string, JSON>
)
return {
value: choice(object, array, jsonString, jsonNumber, jsonBool, jsonNull) as Combinator<JSON>,
}
})
export const jsonParser = parser({ trivia: ws }, value)
jsonParser.parse('{ "a": 1 }')g.value is a reference that works anywhere inside the factory regardless of order.
Which rules go in the returned object?
- Local helpers that don't need to be cross-referenced (
comma,pair,objectabove) can be plainconst. - Only put a rule in the returned object if other rules need to reach it as
g.xxx, or if you'll call it directly (e.g. as a start rule, or as an entry in the registry for incremental re-parsing).
Each rule returned from the factory is independently callable — that returned object is the "rule registry" that incremental re-parsing needs.
Grammar-level options — rules(options, factory)
Pass an options object first — mirroring parser({ trivia }, combinator) — to set options once for the whole grammar, instead of wrapping rules individually.
const rw = trivia(oneOrMore(choice(ws, comment)))
const grammar = rules({ trivia: rw }, (g) => ({
Stylesheet: many(g.Rule),
Rule: sequence(g.Selector, literal('{'), many(g.Declaration), literal('}')),
// …every rule below skips `rw` between its terms, automatically…
}))Every rule skips rw between its terms — the rule you start at and every rule it reaches — and a single rule parsed on its own (run(grammar.Rule, …)) skips it too. To use different trivia in one region, wrap it with parser({ trivia }) or noTrivia — see Whitespace & trivia → local overrides.
rules() and parser({ trivia }, combinator) take the same option; rules() applies it to the whole grammar, parser() to the one combinator it wraps.
It's fine to return your trivia rule itself from the factory (e.g. rw, so a driver can reach it as g.rw): a trivia() rule is automatically excluded from the grammar-level trivia, so it never recursively skips filler within itself.
The grammar-wide options are:
| Option | What it does |
|---|---|
trivia | Ambient whitespace/comment skipping between terms. See Whitespace & trivia. |
scanSkip | Opaque units that scanTo / balanced skip while scanning. See scanTo & balanced. |
trackLines | Populate startLine / startColumn / endLine / endColumn on spans produced by this grammar. See Line/column spans. |
hostMode | Compile-time AST-vs-CST mode for grammars with direct node(..., build) callbacks. See When the grammar has its OWN builders. |
trackLines is opt-in because offset-only spans are the fast default. When it is enabled on rules({ trackLines: true }, factory), CST nodes built by that grammar receive line/column fields in their span objects as they are created; consumers do not need to run a separate tree annotation pass.
rules() and the macro
The plugin fully compiles rules() factories, including recursive ones. Each rule becomes a named function derived from its rule name (_r_<Name>) and mutual references are direct calls to those names, so the cycle is broken with zero dispatch. Add with { type: 'macro' } to your import and the entire grammar — recursive rules included — is inlined at build time. Both binding forms compile:
const { value } = rules(…) // each rule becomes a top-level function
const grammar = rules(…) // an object literal of compiled rules; grammar.value(…) worksIf the plugin meets a macro-imported declaration it can't compile statically (it closes over a runtime value, or isn't a recognized combinator shape), it leaves that declaration for the interpreter, strips the with { type: 'macro' } attribute so the import stays valid, and emits a build warning pointing at it — so a silent fallback never goes unnoticed. See Macro mode.
One factory, several macro artifacts
When you want the same authored grammar with different settings, keep the factory shared and put the settings at each rules(...) call site. The macro compiles each call site into its own standalone artifact:
import { rules } from 'parseman' with { type: 'macro' }
import { grammarFactory } from './grammar.js'
export const grammar = rules({ trivia: rw }, grammarFactory)
export const grammarWithLines = rules({ trivia: rw, trackLines: true }, grammarFactory)
export const cstGrammar = rules({ trivia: rw, trackLines: true, hostMode: 'cst' }, grammarFactory)That pattern is useful for packages that need an evaluation parser and a language-service parser from one source. The imported factory is build-time input only: when it is static and source-private, the macro inlines it into each output and the generated grammar file does not need the factory import at runtime.
ref<T>() — the low-level primitive
rules() handles forward references automatically. ref<T>() is the lower-level primitive it uses internally, exposed for the rare case where you need a single forward slot outside a rules() call:
import { ref, choice } from 'parseman'
const value = ref<JSON>()
// … build parsers that use value …
value.define(choice(object, array, str, num, bool, nil))Prefer rules() in almost all cases — it's clearer and it's what the macro is tuned to compile.
Reusing one factory with different config
This is a different lever from extending a grammar: there you take an existing grammar and override its rules by name with compose(). Here you have a single factory you want to reuse with a different setting (trivia, document shape) — don't copy it, export what stays the same and pass in (or wrap) what changes. The examples/json/ directory is the template:
| File | What changes |
|---|---|
parser.ts | Base grammar + makeJSONParser(customWs) |
jsonc.ts | Trivia only — makeJSONParser(jsoncWs) |
jsonl.ts | Document shape — sepBy(jsonValue, '\n') with tighter trivia |
// jsonc.ts — same recursive grammar, different trivia
export const jsoncValue = makeJSONParser(jsoncWs)
// jsonl.ts — reuse jsonValue, wrap at the top
export const jsonl = parser({ trivia: lineWs }, sepBy(jsonValue, literal('\n')))Two levers: unchanged core → parameterize (pass trivia into the factory); unchanged rule, different document → wrap the export.
