Your config format needs a date literal, {"due": 2026-09-02}, and every stock JSON parser refuses
it. Copying a JSON grammar to add one rule leaves you maintaining a parser that no longer receives
upstream’s fixes. Add the literal carelessly and it reads 2026 as a year, so documents that parsed
before change their meaning.
What you get
You will end up with a plugin that adds a date literal to @tabnas/json without touching its
rules, and a corpus that checks the stock and extended parsers side by side. This is for you if
your format is JSON plus one or two literals.
Short answer
Add the literal to @tabnas/json with a lexer plugin on a derived instance, so the grammar’s rules
stay as upstream wrote them. A match.value pattern for four digits, two, and two turns
2026-09-02 into a value token, which the val rule already accepts. Keep the pattern tight enough
to leave 2026 a number, and run an accept and reject corpus against the stock and extended
parsers.
You will need
Node 22 or later. Verified 2026-10-21 against Node 22.22.0, @tabnas/json 0.5.18,
@tabnas/parser 0.12.14, peggy 5.1.1, and chevrotain 13.2.0. The sample adds one literal, a
calendar date such as 2026-09-02, three ways, and runs the same eight documents through each. It
does not cover times or time zones. Binding behavior to rule names is covered in
attach parse actions to grammar rules by name.
Approaches compared
| Approach | When it fits | What it costs you | When to pick something else |
|---|---|---|---|
| chevrotain OVERRIDE_RULE | The parser is a chevrotain class written for subclasses to extend | Restating every alternative of the overridden rule, and a base class that must defer its self-analysis | The upstream parser analyzes itself in its constructor, as the chevrotain JSON example does |
| peggy, a copied grammar | You expect the dialect to drift further from JSON and want full control | A 150-line copy that you now maintain, which receives no upstream fix unless you merge it by hand | The change is one literal and upstream is still moving |
| @tabnas/json plugin | The literal is a scalar the lexer can recognize by its pattern | A pattern you must keep from overlapping numbers, and errors that say unexpected instead of your converter’s message | The literal has structure of its own, which needs tokens and alternates |
Voxgig maintains tabnas. This page compares it with peggy and chevrotain.
A fork is immune to upstream changes, fixes included, because it no longer follows upstream.
The chevrotain override keeps the base class but restates the whole rule, so an alternative added
upstream never reaches your subclass. The tabnas plugin names no rule. It relies on the val rule
accepting value tokens and on the order in which the lexer tries its patterns, which costs less to
keep but still needs the corpus.
Teach the lexer the literal, not the rules
@tabnas/json is a plugin for the tabnas engine, and its val rule already opens on any value
token. A match.value entry in the engine’s options turns text that matches a pattern into a value
token, so a plugin that registers one adds the literal without naming a rule. The
options reference uses a date
for its own example. The pattern here is the full-date of
RFC 3339, four digits, two, and two.
// Four digits, two, and two. Anchored, as the engine requires, and tight
// enough to leave a bare year such as 2026 to the number matcher.
export const DATE = /^\d{4}-\d{2}-\d{2}/
// The plugin. match.value runs before the number matcher and emits a value
// token, and the stock val rule already takes any value token, so no rule is
// named or changed. Return nothing: use() hands back what a plugin returns.
export function dateLiteral(tn) {
tn.options({
match: {
value: {
date: { match: DATE, val: (m) => toDate(m[0]) },
},
},
})
}
// make() derives a fresh instance each time, so the stock parser is untouched.
export const stock = make()
export const extended = make().use(dateLiteral)
node compare.mjs tabnas
engines: @tabnas/json 0.5.18
document stock extended
{"due": 2026-09-02} rejected {"due":date(2026-09-02)}
[2026-09-02, 2026] rejected [date(2026-09-02),2026]
{"leap": 2024-02-29} rejected {"leap":date(2024-02-29)}
{"year": 2026} {"year":2026} {"year":2026}
{"id": "2026-09-02"} {"id":"2026-09-02"} {"id":"2026-09-02"}
{"due": 2026-9-2} rejected rejected
{"due": 2026-02-30} rejected rejected
{"due": 2026-09-02T10:00} rejected rejected
16 of 16 results as the corpus expects
The stock parser refuses every date, and the extended one builds a Date for each, in an object
and as the first element of an array. 2026 stays a number and the quoted "2026-09-02" stays a
string. Both refuse the last three documents: a month and day without their leading zeros, the
thirtieth of February, and a date with a time after it. toDate in date.mjs refuses the February date,
because Date itself turns 2026-02-30 into the second of March.1
Fork the grammar in peggy
peggy compiles a grammar file into a parser, so a new literal means a new rule in the grammar, and
the grammar is upstream’s. Copy peggy’s JSON example
and add a date rule to the value choice.2
value
= false
/ null
/ true
/ object
/ array
/ date // FORK: before number, which would take the year and stop
/ number
/ string
false = "false" { return false; }
null = "null" { return null; }
true = "true" { return true; }
// FORK: a date literal such as 2026-09-02, built by the caller's toDate.
date "date"
= DIGIT DIGIT DIGIT DIGIT "-" DIGIT DIGIT "-" DIGIT DIGIT {
try {
return options.toDate(text());
} catch (err) {
error(err.message);
}
}
diff json.pegjs json-date.pegjs
37a38
> / date // FORK: before number, which would take the year and stop
44a46,55
> // FORK: a date literal such as 2026-09-02, built by the caller's toDate.
> date "date"
> = DIGIT DIGIT DIGIT DIGIT "-" DIGIT DIGIT "-" DIGIT DIGIT {
> try {
> return options.toDate(text());
> } catch (err) {
> error(err.message);
> }
> }
>
56c67,68
< result[element.name] = element.value;
---
> // FORK: define the key, as JSON.parse does. Assigning to __proto__ sets the prototype.
> Object.defineProperty(result, element.name, { value: element.value, enumerable: true, writable: true, configurable: true });
The fork adds 13 lines, rewrites 1, and carries the other 149 with it. The order inside value matters,
because a peggy choice
returns the first alternative that matches
and number matches 2026 before date is tried. The action calls peggy’s error(), which
reports the converter’s message at the literal’s line and column.
The rewritten line fixes a bug that came with the copy. Upstream’s object action assigns each member
with result[name] = value, and an assignment to __proto__ sets the object’s prototype. So the
copy parses {"__proto__": {"admin": true}} to {} whose admin reads true, where JSON.parse
keeps __proto__ as a key. The fork defines the key instead.
Override one rule in chevrotain
A chevrotain grammar is a class, and grammar inheritance replaces a rule with OVERRIDE_RULE in a
subclass. The JSON example in the chevrotain repository runs performSelfAnalysis in its own
constructor, after which no rule can be overridden. chevrotain-json.mjs makes two changes to it,
marked CHANGED, so that a subclass can make that call itself.
// The lexer tries patterns in list order, so the date goes in front of the
// number, which would otherwise take the year and stop.
export const dateTokens = jsonTokens.flatMap((t) => (t === NumberLiteral ? [DateLiteral, t] : [t]))
export class DateJsonParser extends JsonParser {
constructor() {
super(dateTokens)
const $ = this
// OVERRIDE_RULE replaces the whole rule, so every alternative the base
// had is restated here beside the new one.
$.OVERRIDE_RULE('value', () => {
$.OR([
{ ALT: () => $.CONSUME(DateLiteral) },
{ ALT: () => $.CONSUME(StringLiteral) },
{ ALT: () => $.CONSUME(NumberLiteral) },
{ ALT: () => $.SUBRULE($.object) },
{ ALT: () => $.SUBRULE($.array) },
{ ALT: () => $.CONSUME(True) },
{ ALT: () => $.CONSUME(False) },
{ ALT: () => $.CONSUME(Null) },
])
})
this.performSelfAnalysis()
}
}
node compare.mjs
engines: @tabnas/json 0.5.18, peggy 5.1.1, chevrotain 13.2.0
document stock extended
{"due": 2026-09-02} rejected {"due":date(2026-09-02)}
[2026-09-02, 2026] rejected [date(2026-09-02),2026]
{"leap": 2024-02-29} rejected {"leap":date(2024-02-29)}
{"year": 2026} {"year":2026} {"year":2026}
{"id": "2026-09-02"} {"id":"2026-09-02"} {"id":"2026-09-02"}
{"due": 2026-9-2} rejected rejected
{"due": 2026-02-30} rejected rejected
{"due": 2026-09-02T10:00} rejected rejected
48 of 48 results as the corpus expects
Every document gets the same result from all three engines, in the stock grammars as in the
extended ones. The subclass restates seven of the eight alternatives in value, because
OVERRIDE_RULE replaces the rule’s whole body. The token order matters as much as the rule. The chevrotain lexer
takes the first pattern that matches, not the longest,
so DateLiteral sits in front of NumberLiteral.
Check it worked
extend.test.mjs runs the corpus through each engine’s stock and extended parsers and asserts
every result. It also checks that the tabnas plugin leaves the shape of the rule table alone, and
that chevrotain refuses an override on a base class that has already analyzed itself. Two more check
that every extended parser keeps __proto__ as a key, and that compare.mjs exits 1 when a result
departs from the corpus, so it can gate a merge.
node --test extend.test.mjs
1..16
# tests 16
# suites 0
# pass 16
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 578.318689
Sixteen tests pass. The six whose names start with mistake pin the failures below, so a change
that silently fixes or worsens one of them fails the run.
When it goes wrong
Each engine fails in its own way when the new literal and an old token compete for the same text. The script runs each mistake for real.
node pitfall.mjs
mistake document result
tabnas: pattern also takes a bare year {"year": 2026} {"year":date(2026-01-01)}
tabnas: same pattern, converted by toDate {"year": 2026} rejected: [tabnas/unexpected]: unexpected character(s): 2
peggy: date listed after number {"due": 2026-09-02} rejected: Expected "," or "}" but "-" found.
chevrotain: DateLiteral after NumberLiteral {"due": 2026-09-02} rejected: Expecting token of type --> RCurly <-- but found --> '-0' <--
tabnas: a token and a val alternate {"due": 2026-09-02} {"due":date(2026-09-02)}
tabnas: a token and a val alternate [2026-09-02] rejected: [tabnas/unexpected]: unexpected character(s): 2
A number turns into a date. ISO 8601 allows a date cut down to its year,3 so a pattern written
to the standard also matches 2026, and tabnas runs match patterns before its number matcher.
With a plain new Date the year becomes the first of January; with toDate the document fails.
Keep the pattern to the full form, and keep {"year": 2026} in the corpus.
The date never parses. peggy tries its choice and chevrotain its token list in order, so a number
listed first takes 2026 and leaves -09-02 behind. List the longer literal first.
A date parses in an object and fails as the first element of an array. A token registered with
match.token is
recognized only where the active rule expects it,
and the token after [ is read during the list rule’s two-token lookahead. Register a scalar
literal with match.value, which the lexer tries in every position.
When not to do this
Do not extend JSON for data that crosses a boundary you do not own. Every reader of the file then
needs your parser, and a reader on JSON.parse fails at the first date. A string with a documented
format such as "due": "2026-09-02" costs one validation step and keeps the file JSON.
Do not use the tabnas plugin for a literal with structure, such as a tagged value with arguments.
A pattern recognizes a token, not a nested form, and that needs tokens of its own and alternates on
val, which is how @tabnas/expr, @tabnas/directive, and @tabnas/hoover extend a grammar.
Expect its errors to say unexpected, because a converter’s own message does not reach the caller.
Do not fork for one literal. The 150 copied lines are where upstream’s next fix fails to arrive. A fork earns its keep once the dialect has drifted so far that upstream’s fixes no longer apply.
Related how-tos
Last verified
Verified 2026-10-21 against Node 22.22.0, @tabnas/json 0.5.18, @tabnas/parser 0.12.14, peggy
5.1.1, and chevrotain 13.2.0. Every output block is what the command preceding it printed.
json.pegjs is peggy’s JSON example, copied unchanged. The Go port, github.com/tabnas/json/go
0.5.18, takes the same option as tabnas.Options{Match: &tabnas.MatchOptions{Value: ...}}; run by
hand against the eight documents, it gave the same verdicts, and it reported a converter’s panic as
an internal error. It is not part of the checked output.
- The chevrotain example accepts only an object or an array at the top, the rule of RFC 4627, which RFC 7159 dropped in March 2014. The corpus here wraps every document in braces or brackets, so the older rule decides nothing.
Footnotes
-
The ECMAScript date-time string format that
Datereads includes the date-only formsYYYY,YYYY-MM, andYYYY-MM-DD, and it boundsDDat 01 to 31 in every month. By that bound2026-02-30is a well-formed date, and V8 counts on from the first of February to the second of March. The same format makesnew Date('2026')the first of January 2026, which is the reading the loose pattern in the failure section gives a bare year. ↩︎ Back to text -
The two upstream examples are dated by the standards they follow. peggy’s grammar says it is based on RFC 7159, which RFC 8259 obsoleted in December ↩︎ Back to text
-
RFC 3339 collects an ABNF for ISO 8601 in an appendix, because ISO 8601 “does not specify a formal grammar” for the formats it defines. A bare year is one of the dates in it. The appendix calls itself informational and allows that it may contain errors. It also settles a conflict inside the standard by assuming that section 5.3.1.3 is correct and that Annex B.2 is in error. ↩︎ Back to text