Conditions and counters

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.

A condition is a predicate

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 cr.o0, r.parent, r.child, r.n, r.u.

output 1 => 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.

Counters

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 0 resets 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.

output n:{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 0 resets; any other number adds. n: { pk: 0 } in the JSON grammar is a reset, and reading it as a no-op will mislead you.

The rule instance has comparison helpers:

HelperTrue when
r.eq('k', n)the counter equals n
r.lt('k', n) r.ltebelow / at most n
r.gt('k', n) r.gteabove / at least n

An unset counter reads as zero

A counter that has never been incremented has counted nothing, so it compares as 0:

Nothing counted yet

import { Tabnas } from '@tabnas/parser'const tn = new Tabnas()tn.options({ rule: { start: 'val' } })// Nothing has ever incremented `depth`, so it has counted nothing.tn.rule('val', (rs: any) => rs.open([{ s: '#NR', a: (r: any) => {  console.log("lt('depth', 3)   ", r.lt('depth', 3))  console.log("eq('depth', 0)   ", r.eq('depth', 0))  console.log("gt('depth', 3)   ", r.gt('depth', 3))  console.log("exist('depth')   ", r.exist('depth'))} }]))tn.parse('42')
package mainimport (	"fmt"	tabnas "github.com/tabnas/parser/go")func main() {	j := tabnas.Make()	j.SetOptions(tabnas.MapToOptions(map[string]any{		"rule": map[string]any{"start": "val"},	}))	NR := j.Token("#NR")	// Nothing has ever incremented `depth`, so it has counted nothing.	j.Rule("val", func(rs *tabnas.RuleSpec, p *tabnas.Parser) {		rs.AddOpen(&tabnas.AltSpec{S: [][]tabnas.Tin{{NR}}, A: func(r *tabnas.Rule, ctx *tabnas.Context) {			fmt.Println("lt('depth', 3)   ", r.Lt("depth", 3))			fmt.Println("eq('depth', 0)   ", r.Eq("depth", 0))			fmt.Println("gt('depth', 3)   ", r.Gt("depth", 3))			fmt.Println("exist('depth')   ", r.Exist("depth"))		}})	})	if _, err := j.Parse("42"); err != nil {		panic(err)	}}

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.

output lt('depth', 3) true · eq('depth', 0) true · gt('depth', 3) false · exist('depth') false

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".

output never 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.

A depth limit, done properly

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.

output {"a":1} => {"a":1} · {"a":{"b":{"c":1}}} => {"a":{"b":{"c":1}}} · [[[1]]] => [[[1]]] · {"a":{"b":{"c":{"d":1}}}} => too_deep · [[[[1]]]] => too_deep

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.

Declarative conditions

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.

output 1 accepted · (1) accepted · ((1)) accepted · (((1))) accepted · ((((1)))) rejected

The key is a dot-path resolved against r, so n.depth is the counter, u.mode is your own scratch value, o0.tin is the opening token.

FormMeaning
{ 'n.depth': { $lt: 3 } }below
$lte $gt $gteat most / above / at least
{ 'u.mode': 'strict' }a bare value is $eq
{ 'u.mode': { $ne: 'loose' } }not equal

Several keys are ANDed — every one must hold:

Every key must hold

import { Tabnas } from '@tabnas/parser'let mark = ''// Several keys in one `c` are ANDed — every one must hold.function check(depth: number, mode: string) {  const tn = new Tabnas()  tn.grammar({    ref: {      '@taken': () => { mark = 'taken' },      '@skipped': () => { mark = 'skipped' },    },    options: { rule: { start: 'val' } },    rule: {      val: {        open: [{ s: '#NR', n: { depth }, u: { mode } }],        close: [          { c: { 'n.depth': { $gte: 1 }, 'u.mode': 'strict' }, a: '@taken' },          { a: '@skipped' },        ],      },    },  } as any)  tn.parse('1')  return mark}console.log('depth=1 mode=strict ', check(1, 'strict'))console.log('depth=1 mode=loose  ', check(1, 'loose'))console.log('depth=0 mode=strict ', check(0, 'strict'))
package mainimport (	"fmt"	tabnas "github.com/tabnas/parser/go")var mark string// Several keys in one `C` are ANDed — every one must hold.func check(depth int, mode string) 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", N: map[string]int{"depth": depth}, U: map[string]any{"mode": mode}},				},				Close: []*tabnas.GrammarAltSpec{					{C: map[string]any{"n.depth": tabnas.CGte(1), "u.mode": tabnas.CEq("strict")}, A: "@taken"},					{A: "@skipped"},				},			},		},	})	if err != nil {		panic(err)	}	if _, err := j.Parse("1"); err != nil {		panic(err)	}	return mark}func main() {	fmt.Println("depth=1 mode=strict ", check(1, "strict"))	fmt.Println("depth=1 mode=loose  ", check(1, "loose"))	fmt.Println("depth=0 mode=strict ", check(0, "strict"))}

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 CondOptabnas.CEq("strict").

output depth=1 mode=strict taken · depth=1 mode=loose skipped · depth=0 mode=strict skipped

The two halves behave differently

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.

output { 'u.flag': 1 } skipped · { 'n.never': { $gte: 99 } } skipped · { 'n.never': { $lt: 1 } } taken · { 'u.never': { $gte: 99 } } taken

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”.

Which form to use

If you wantUse
A grammar that stays serialisablethe object form
A grammar an agent emitted, checkable before runningthe object form
Anything the object form can’t expressa 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.

Next

Describes @tabnas/parser 0.8.10 · all pinned versions