Alternates are chosen by their tokens. When two cases look identical to the
lexer, the difference has to come from somewhere else — how deep you are, what
the enclosing rule is, whether something has already been seen.
That is what c is for. This tutorial covers the three ways to write one, and
how counters read before anything has been counted.
c is checked when an alternate’s tokens match. If it returns false, the
alternate is skipped and the next one is tried:
A condition is a predicate
import { Tabnas } from '@tabnas/parser'const tn = new Tabnas()tn.options({ fixed: { token: { '#OP': '(', '#CP': ')' } }, rule: { start: 'val' },})const OP = tn.token('#OP')const setNumber = (r: any) => { r.node = r.o0.val }// `c` is checked when an alternate's tokens match. If it returns false the// alternate is skipped and the next one is tried.tn.rule('val', (rs: any) => rs .open([{ s: '#OP', p: 'val' }, { s: '#NR', a: setNumber }]) .close([ // Only a val that opened on '(' may consume ')'. { s: '#CP', c: (r: any) => OP === r.o0?.tin, a: (r: any) => { r.node = [r.child.node] } }, {}, ]))for (const src of ['1', '(1)', '((1))']) { console.log(src.padEnd(6), '=>', JSON.stringify(tn.parse(src)))}
package mainimport ( "encoding/json" "fmt" tabnas "github.com/tabnas/parser/go")func main() { j := tabnas.Make() j.SetOptions(tabnas.MapToOptions(map[string]any{ "fixed": map[string]any{"token": map[string]any{"#OP": "(", "#CP": ")"}}, "rule": map[string]any{"start": "val"}, })) OP, CP, NR := j.Token("#OP"), j.Token("#CP"), j.Token("#NR") setNumber := func(r *tabnas.Rule, ctx *tabnas.Context) { r.Node = r.O0.Val } // `C` is checked when an alternate's tokens match. If it returns false the // alternate is skipped and the next one is tried. j.Rule("val", func(rs *tabnas.RuleSpec, p *tabnas.Parser) { rs.AddOpen( &tabnas.AltSpec{S: [][]tabnas.Tin{{OP}}, P: "val"}, &tabnas.AltSpec{S: [][]tabnas.Tin{{NR}}, A: setNumber}, ) rs.AddClose( // Only a val that opened on '(' may consume ')'. &tabnas.AltSpec{ S: [][]tabnas.Tin{{CP}}, C: func(r *tabnas.Rule, ctx *tabnas.Context) bool { return OP == r.O0.Tin }, A: func(r *tabnas.Rule, ctx *tabnas.Context) { r.Node = []any{r.Child.Node} }, }, &tabnas.AltSpec{}, ) }) for _, src := range []string{"1", "(1)", "((1))"} { out, err := j.Parse(src) if err != nil { panic(err) } b, _ := json.Marshal(out) fmt.Printf("%-6s => %s\n", src, string(b)) }}
Both val alternates look the same to the lexer at the closing bracket, so the difference has to come from state. c is checked once an alternate's tokens match: only a val that opened on ( may consume ), and a bare number falls through to the empty alternate.
Everything on the rule instance is available inside c — r.o0, r.parent, r.child, r.n, r.u.
output1 => 1 · (1) => [1] · ((1)) => [[1]]
Everything on the rule instance is available: r.o0 for
the token that opened this rule, r.parent, r.child, r.n, r.u.
n on an alternate sets or increments a named counter, and counters
propagate to pushed and repeated rules — so a count made at the top is
visible all the way down:
Add one, or reset to zero
import { Tabnas } from '@tabnas/parser'let seen = 0// `n` on an alternate sets or increments a named counter, and counters// propagate to pushed and repeated rules. Setting 0 RESETS; any other number// adds.function build(step: number) { const tn = new Tabnas() tn.options({ fixed: { token: { '#OP': '(', '#CP': ')' } }, rule: { start: 'val' }, }) tn.rule('val', (rs: any) => rs .open([ { s: '#OP', p: 'val', n: { depth: step } }, { s: '#NR', a: (r: any) => { seen = r.n.depth ?? 0 } }, ]) .close([{ s: '#CP' }, {}])) return tn}function depthAt(step: number, src: string) { build(step).parse(src) return seen}for (const src of ['1', '(1)', '((1))']) { console.log(`n:{depth:1} ${src.padEnd(6)} depth at the number =`, depthAt(1, src))}console.log('n:{depth:0} ((1)) depth at the number =', depthAt(0, '((1))'))
package mainimport ( "fmt" tabnas "github.com/tabnas/parser/go")var seen int// `N` on an alternate sets or increments a named counter, and counters// propagate to pushed and repeated rules. Setting 0 RESETS; any other number// adds.func build(step int) *tabnas.Tabnas { j := tabnas.Make() j.SetOptions(tabnas.MapToOptions(map[string]any{ "fixed": map[string]any{"token": map[string]any{"#OP": "(", "#CP": ")"}}, "rule": map[string]any{"start": "val"}, })) OP, CP, NR := j.Token("#OP"), j.Token("#CP"), j.Token("#NR") j.Rule("val", func(rs *tabnas.RuleSpec, p *tabnas.Parser) { rs.AddOpen( &tabnas.AltSpec{S: [][]tabnas.Tin{{OP}}, P: "val", N: map[string]int{"depth": step}}, &tabnas.AltSpec{S: [][]tabnas.Tin{{NR}}, A: func(r *tabnas.Rule, ctx *tabnas.Context) { seen = r.N["depth"] }}, ) rs.AddClose(&tabnas.AltSpec{S: [][]tabnas.Tin{{CP}}}, &tabnas.AltSpec{}) }) return j}func depthAt(step int, src string) int { if _, err := build(step).Parse(src); err != nil { panic(err) } return seen}func main() { for _, src := range []string{"1", "(1)", "((1))"} { fmt.Printf("n:{depth:1} %-6s depth at the number = %d\n", src, depthAt(1, src)) } fmt.Println("n:{depth:0} ((1)) depth at the number =", depthAt(0, "((1))"))}
n on an alternate sets or increments a named counter, and counters propagate to pushed and repeated rules — so a count made at the top is visible all the way down.
The last line is the trap: setting 0resets rather than adding nothing, so two levels of nesting still report 0. Reading n: { pk: 0 } in the JSON grammar as a no-op will mislead you.
outputn:{depth:1} 1 depth at the number = 0 · n:{depth:1} (1) depth at the number = 1 · n:{depth:1} ((1)) depth at the number = 2 · n:{depth:0} ((1)) depth at the number = 0
Setting 0resets; any other number adds. n: { pk: 0 } in the JSON
grammar is a reset, and reading it as a no-op will mislead you.
A counter that was never incremented has counted nothing, so it compares as 0: below a limit, not past one, and equal to zero. Exactly one of <, =, > holds, so a guard means what it says wherever you put it.
exist is the one question the comparisons cannot answer — a counter set to 0 and one never set compare identically.
That keeps the permissive direction you want — a rule that never counts is not
blocked by a limit it knows nothing about — while leaving exactly one of <,
=, > true, so a guard means what it says wherever you put it.
eq('k', 0) is therefore true both for a counter set to 0 and for one never
set. When the difference matters, ask directly:
Never set, or counted zero?
import { Tabnas } from '@tabnas/parser'let existed = falselet isZero = false// `eq('k', 0)` is true both for a counter set to 0 and for one never set.// `exist` is the only way to tell those two apart.function build(step: number) { const tn = new Tabnas() tn.options({ fixed: { token: { '#OP': '(', '#CP': ')' } }, rule: { start: 'val' }, }) tn.rule('val', (rs: any) => rs .open([ { s: '#OP', p: 'val', n: { depth: step } }, { s: '#NR', a: (r: any) => { existed = r.exist('depth'); isZero = r.eq('depth', 0) } }, ]) .close([{ s: '#CP' }, {}])) return tn}const cases: [string, number, string][] = [ ['never set', 1, '1'], ['set to 0 ', 0, '(1)'], ['counted 1', 1, '(1)'],]for (const [label, step, src] of cases) { build(step).parse(src) console.log(label, ' exist:', String(existed).padEnd(5), ' eq(depth,0):', isZero)}
package mainimport ( "fmt" tabnas "github.com/tabnas/parser/go")var existed, isZero bool// `Eq("k", 0)` is true both for a counter set to 0 and for one never set.// `Exist` is the only way to tell those two apart.func build(step int) *tabnas.Tabnas { j := tabnas.Make() j.SetOptions(tabnas.MapToOptions(map[string]any{ "fixed": map[string]any{"token": map[string]any{"#OP": "(", "#CP": ")"}}, "rule": map[string]any{"start": "val"}, })) OP, CP, NR := j.Token("#OP"), j.Token("#CP"), j.Token("#NR") j.Rule("val", func(rs *tabnas.RuleSpec, p *tabnas.Parser) { rs.AddOpen( &tabnas.AltSpec{S: [][]tabnas.Tin{{OP}}, P: "val", N: map[string]int{"depth": step}}, &tabnas.AltSpec{S: [][]tabnas.Tin{{NR}}, A: func(r *tabnas.Rule, ctx *tabnas.Context) { existed, isZero = r.Exist("depth"), r.Eq("depth", 0) }}, ) rs.AddClose(&tabnas.AltSpec{S: [][]tabnas.Tin{{CP}}}, &tabnas.AltSpec{}) }) return j}func main() { cases := []struct { label string step int src string }{ {"never set", 1, "1"}, {"set to 0 ", 0, "(1)"}, {"counted 1", 1, "(1)"}, } for _, c := range cases { if _, err := build(c.step).Parse(c.src); err != nil { panic(err) } fmt.Printf("%s exist: %-5v eq(depth,0): %v\n", c.label, existed, isZero) }}
The middle two rows are the point: a counter never set and a counter reset to 0 compare identically, so eq('depth', 0) cannot tell them apart. exist can, and it is the only thing that can.
Reach for it whenever you mean "only if this was explicitly set" rather than "whatever it counts to".
outputnever set exist: false eq(depth,0): true · set to 0 exist: true eq(depth,0): true · counted 1 exist: true eq(depth,0): false
Changed in 0.6. Previously an unset counter compared as true against
every helper, so lt('depth',3) and gt('depth',3) were both true and a
$gte guard fired on the very first token, before anything had been counted.
An alternate gated on { 'n.k': { $gt: 0 } } used to match on two
grounds: the counter was positive, or it was unset and the comparison
passed regardless. Only the second is gone. If you relied on both, add the
unset case as its own alternate — do not swap the condition, or you lose the
positive-counter branch you actually wanted:
{ s: '#CB', c: { 'n.k': { $gt: 0 } }, b: 1 } // keep this{ s: '#CB', c: { 'n.k': { $exist: false } }, b: 1 } // add this if you // relied on fail-open
Paths that are not counters are unaffected: an absent o0 or a u.* you
never set is genuine absence, not zero.
Nesting is allowed while depth is below the limit. Past it, neither push
alternate matches, and the unconditional guard behind them is reached:
A depth limit, done properly
import { Tabnas } from '@tabnas/parser'import { json } from '@tabnas/json'const MAX = 3const tn = new Tabnas({ plugins: [json] })tn.options({ error: { too_deep: 'nested deeper than {max} levels' } })// Nesting is allowed while `depth` is below the limit. Past it neither push// alternate matches, and the unconditional guard behind them is reached.tn.rule('val', (rs: any) => rs.open( [{ s: [['#OB', '#OS']], b: 1, e: (r: any) => r.o0.bad('too_deep', { max: MAX }) }], { custom: (alts: any[]) => { const guard = alts.shift() // the alternate just prepended alts[0].n = alts[1].n = { depth: 1 } // map and list push alts[0].c = alts[1].c = { 'n.depth': { $lt: MAX } } alts.splice(2, 0, guard) // guard sits behind them return alts }, },))// Order matters: alternates are tried in order and the first match wins, so a// guard placed first would claim the token before the pushes are considered.for (const src of [ '{"a":1}', '{"a":{"b":{"c":1}}}', '[[[1]]]', '{"a":{"b":{"c":{"d":1}}}}', '[[[[1]]]]',]) { try { console.log(src.padEnd(26), '=>', JSON.stringify(tn.parse(src))) } catch (e: any) { console.log(src.padEnd(26), '=>', e.code) }}
package mainimport ( "encoding/json" "errors" "fmt" tabnasjson "github.com/tabnas/json/go" tabnas "github.com/tabnas/parser/go")const MAX = 3func main() { j := tabnas.Make() if err := j.Use(tabnasjson.Json); err != nil { panic(err) } j.SetOptions(tabnas.MapToOptions(map[string]any{ "error": map[string]any{"too_deep": "nested deeper than {max} levels"}, })) OB, OS := j.Token("#OB"), j.Token("#OS") // Nesting is allowed while `depth` is below the limit. Past it neither push // alternate matches, and the unconditional guard behind them is reached. j.Rule("val", func(rs *tabnas.RuleSpec, p *tabnas.Parser) { guard := &tabnas.AltSpec{ S: [][]tabnas.Tin{{OB, OS}}, B: 1, E: func(r *tabnas.Rule, ctx *tabnas.Context) *tabnas.Token { return r.O0.Bad("too_deep", map[string]any{"max": MAX}) }, } rs.ModifyOpen(&tabnas.AltModListOpts{ Custom: func(alts []*tabnas.AltSpec) []*tabnas.AltSpec { for _, alt := range alts[:2] { // the map and list push alternates alt.N = map[string]int{"depth": 1} alt.CD = map[string]any{"n.depth": tabnas.CLt(MAX)} if err := tabnas.NormAlt(alt); err != nil { panic(err) } } // The guard sits BEHIND them: alternates are tried in order, so // putting it first would claim the token every time. return append(alts[:2:2], append([]*tabnas.AltSpec{guard}, alts[2:]...)...) }, }) }) for _, src := range []string{ `{"a":1}`, `{"a":{"b":{"c":1}}}`, `[[[1]]]`, `{"a":{"b":{"c":{"d":1}}}}`, `[[[[1]]]]`, } { out, err := j.Parse(src) if err != nil { var te *tabnas.TabnasError errors.As(err, &te) fmt.Printf("%-26s => %s\n", src, te.Code) continue } b, _ := json.Marshal(out) fmt.Printf("%-26s => %s\n", src, string(b)) }}
A depth limit done properly: the two push alternates carry the counter and the $lt guard, and an unconditional alternate that raises too_deep sits behind them. Alternates are tried in order, so a guard placed first would claim the opening bracket before either push was ever considered.
Because an unset counter reads as 0, the first bracket passes 0 < 3 and nesting proceeds; the fourth finds 3 < 3 false and falls through to the guard.
Go reaches the same alternates through ModifyOpen and reads the failure code off a returned *TabnasError rather than catching a throw.
Note the order: the two push alternates carry the condition, and the guard is
after them. Alternates are tried in order, so the first one that matches
wins — put the guard first and it claims the token before the push alternates
are ever considered.
A $gte guard first now also works, since an unset depth reads as 0 and
0 >= MAX is false at the opening brace. Before 0.6 it did not: $gte passed
unconditionally while the counter was unset, so every parse failed on the first
token. Ordering the permissive alternates first is still the clearer habit —
it does not depend on how the counter reads when nothing has been counted.
c also takes an object, which is a check against a path on the rule
instance. This keeps a grammar as data — no closure, so it still serialises:
A guard with no closure in it
import { Tabnas } from '@tabnas/parser'// `c` also takes an OBJECT: a check against a dot-path on the rule instance.// No closure, so the grammar is still data — this one has no functions at all.const tn = new Tabnas()tn.grammar({ options: { fixed: { token: { '#OP': '(', '#CP': ')' } }, rule: { start: 'val' }, }, rule: { val: { open: [ { s: '#OP', p: 'val', n: { depth: 1 }, c: { 'n.depth': { $lt: 3 } } }, { s: '#NR' }, ], close: [{ s: '#CP' }, {}], }, },})// Past three levels the push alternate no longer applies, and nothing else// matches an opening bracket.for (const src of ['1', '(1)', '((1))', '(((1)))', '((((1))))']) { let verdict = 'accepted' try { tn.parse(src) } catch { verdict = 'rejected' } console.log(src.padEnd(10), verdict)}
package mainimport ( "fmt" tabnas "github.com/tabnas/parser/go")func main() { // `C` also takes an OBJECT: a check against a dot-path on the rule instance. // No closure, so the grammar is still data — this one has no functions at all. j := tabnas.Make() err := j.Grammar(&tabnas.GrammarSpec{ OptionsMap: map[string]any{ "fixed": map[string]any{"token": map[string]any{"#OP": "(", "#CP": ")"}}, "rule": map[string]any{"start": "val"}, }, Rule: map[string]*tabnas.GrammarRuleSpec{ "val": { Open: []*tabnas.GrammarAltSpec{ {S: "#OP", P: "val", N: map[string]int{"depth": 1}, C: map[string]any{"n.depth": tabnas.CLt(3)}}, {S: "#NR"}, }, Close: []*tabnas.GrammarAltSpec{{S: "#CP"}, {}}, }, }, }) if err != nil { panic(err) } // Past three levels the push alternate no longer applies, and nothing else // matches an opening bracket. for _, src := range []string{"1", "(1)", "((1))", "(((1)))", "((((1))))"} { verdict := "accepted" if _, err := j.Parse(src); err != nil { verdict = "rejected" } fmt.Printf("%-10s %s\n", src, verdict) }}
The object form of c is a check against a dot-path on the rule instance — n.depth is the counter, u.mode your own scratch value, o0.tin the opening token. There is no closure, so the grammar remains data: serialisable, diffable, and checkable before you run it.
Use it for anything that is a comparison against rule state, which is most guards. Anything involving two paths, arithmetic, or a call into your own code needs a function — and the grammar becomes code at that point.
Several keys in one c are ANDed: the alternate applies only when every one holds. Drop either half — the wrong mode, or a counter that has not reached one — and the guarded alternate is skipped and the next one is tried.
u survives from a rule's open phase into its close phase, which is why the mode set on the way down is readable on the way back up.
TypeScript takes a bare value as $eq; Go spells the same comparison out as a CondOp — tabnas.CEq("strict").
This is worth pinning down, because the asymmetry is invisible in the syntax:
The asymmetry, measured
import { Tabnas } from '@tabnas/parser'let mark = ''// One alternate gated on the condition, one unconditional behind it. Nothing// has set `u.flag`, `n.never` or `u.never`, so each row is the unset case.function check(cond: any) { const tn = new Tabnas() tn.grammar({ ref: { '@taken': () => { mark = 'taken' }, '@skipped': () => { mark = 'skipped' }, }, options: { rule: { start: 'val' } }, rule: { val: { open: [ { s: '#NR', c: cond, a: '@taken' }, { s: '#NR', a: '@skipped' }, ], close: [{}], }, }, } as any) tn.parse('1') return mark}console.log("{ 'u.flag': 1 } ", check({ 'u.flag': 1 }))console.log("{ 'n.never': { $gte: 99 } } ", check({ 'n.never': { $gte: 99 } }))console.log("{ 'n.never': { $lt: 1 } } ", check({ 'n.never': { $lt: 1 } }))console.log("{ 'u.never': { $gte: 99 } } ", check({ 'u.never': { $gte: 99 } }))
package mainimport ( "fmt" tabnas "github.com/tabnas/parser/go")var mark string// One alternate gated on the condition, one unconditional behind it. Nothing// has set `u.flag`, `n.never` or `u.never`, so each row is the unset case.func check(cond map[string]any) string { j := tabnas.Make() err := j.Grammar(&tabnas.GrammarSpec{ Ref: map[tabnas.FuncRef]any{ "@taken": tabnas.AltAction(func(r *tabnas.Rule, ctx *tabnas.Context) { mark = "taken" }), "@skipped": tabnas.AltAction(func(r *tabnas.Rule, ctx *tabnas.Context) { mark = "skipped" }), }, OptionsMap: map[string]any{"rule": map[string]any{"start": "val"}}, Rule: map[string]*tabnas.GrammarRuleSpec{ "val": { Open: []*tabnas.GrammarAltSpec{ {S: "#NR", C: cond, A: "@taken"}, {S: "#NR", A: "@skipped"}, }, Close: []*tabnas.GrammarAltSpec{{}}, }, }, }) if err != nil { panic(err) } if _, err := j.Parse("1"); err != nil { panic(err) } return mark}func main() { fmt.Println("{ 'u.flag': 1 } ", check(map[string]any{"u.flag": 1})) fmt.Println("{ 'n.never': { $gte: 99 } } ", check(map[string]any{"n.never": tabnas.CGte(99)})) fmt.Println("{ 'n.never': { $lt: 1 } } ", check(map[string]any{"n.never": tabnas.CLt(1)})) fmt.Println("{ 'u.never': { $gte: 99 } } ", check(map[string]any{"u.never": tabnas.CGte(99)}))}
The asymmetry is invisible in the syntax, so here it is measured. $eq on a path that does not resolve fails closed. A counter compares as a number from zero, so $gte: 99 is a real comparison that fails and $lt: 1 is one that passes. An ordered op on a non-counter path that does not resolve fails open.
Use $exist when you mean "only if this was explicitly set".
TypeScript writes the operators as an object; Go passes the same comparison as a CondOp value — tabnas.CGte(99) — which is data, not a closure, so the grammar still serialises.
So counters compare as numbers from zero, $eq on a non-counter path fails
closed, and the ordered ops fail open only for paths that are not counters.
Use $exist when you mean “only if this was explicitly set” — on a counter
that is the sole way to tell “never counted” from “counted zero”.
A grammar an agent emitted, checkable before running
the object form
Anything the object form can’t express
a function
The object form covers comparisons against rule state, which is most guards.
Anything involving two paths, arithmetic, or a call into your own code needs a
function — and the grammar becomes code at that point.