Your first grammar

The quickstart parsed arithmetic. This one builds a grammar that depends on nothing but the engine — no existing language underneath it — and takes it as far as a usable tree. It should take about ten minutes.

We’ll parse a comma-separated list: a,bc,def.

1 · Install

npm install @tabnas/parser @tabnas/abnf

2 · Write the grammar

Three rules, in ABNF:

Three rules, in ABNF

import { Tabnas } from '@tabnas/parser'import { abnf } from '@tabnas/abnf'const tn = new Tabnas({ plugins: [abnf] })tn.abnf(`  list  = item *( COMMA item )  item  = 1*ALPHA  COMMA = ","`)// `ALPHA` is an RFC 5234 core rule, pulled in because the grammar refers to it.// `*( … )` is sugar: it desugars into generated rules you did not write, which// is what to remember when attaching actions later.const rules = Object.keys(tn.rule() as object)console.log('ALPHA pulled in:', rules.includes('ALPHA'))console.log('rules generated by desugaring:', rules.filter((n) => n.startsWith('_gen')).length)
package mainimport (	"fmt"	"strings"	abnf "github.com/tabnas/abnf/go"	tabnas "github.com/tabnas/parser/go")func main() {	j := tabnas.Make()	_, err := abnf.Install(j, `  list  = item *( COMMA item )  item  = 1*ALPHA  COMMA = ","`, nil, nil)	if err != nil {		panic(err)	}	// `ALPHA` is an RFC 5234 core rule, pulled in because the grammar refers to	// it. `*( … )` is sugar: it desugars into generated rules you did not write,	// which is what to remember when attaching actions later.	alpha, generated := false, 0	for name := range j.RSM() {		if "ALPHA" == name {			alpha = true		}		if strings.HasPrefix(name, "_gen") {			generated++		}	}	fmt.Println("ALPHA pulled in:", alpha)	fmt.Println("rules generated by desugaring:", generated)}

Three rules and the language exists. ALPHA is one of the RFC 5234 core rules, included automatically because the grammar refers to it, and COMMA is declared as a rule so the comma becomes a named token rather than an anonymous literal.

Asking the live instance for its rules also shows the cost of sugar: *( … ) and 1* desugar into generated rules, so the runtime rule names are not only the ones you wrote.

output ALPHA pulled in: true · rules generated by desugaring: 14

Reading it: a list is an item, followed by zero or more (*) groups of a comma and another item. An item is one or more (1*) letters. ALPHA is one of the RFC 5234 core rules, included automatically when you refer to it.

COMMA is declared as its own rule so the comma becomes a named token rather than an anonymous literal — which means it shows up in the tree, and you can attach behaviour to it later.

3 · Parse

The same shape, whatever the grammar

import { Tabnas } from '@tabnas/parser'import { abnf } from '@tabnas/abnf'const tn = new Tabnas({ plugins: [abnf] })tn.abnf(`  list  = item *( COMMA item )  item  = 1*ALPHA  COMMA = ","`)// Whatever the grammar, a parse returns the same { rule, src, kids } node —// which is why a walker written once works for every language you define.for (const src of ['a', 'a,bc,def']) {  const n = tn.parse(src) as any  console.log(`rule=${n.rule} src=${JSON.stringify(n.src)}`)}
package mainimport (	"fmt"	abnf "github.com/tabnas/abnf/go"	tabnas "github.com/tabnas/parser/go")func main() {	j := tabnas.Make()	_, err := abnf.Install(j, `  list  = item *( COMMA item )  item  = 1*ALPHA  COMMA = ","`, nil, nil)	if err != nil {		panic(err)	}	// Whatever the grammar, a parse returns the same { rule, src, kids } node —	// which is why a walker written once works for every language you define.	for _, src := range []string{"a", "a,bc,def"} {		out, err := j.Parse(src)		if err != nil {			panic(err)		}		n := out.(map[string]any)		fmt.Printf("rule=%s src=%q\n", n["rule"], n["src"])	}}

The grammar recognises the input and returns the same { rule, src, kids } node whether the list has one item or three. rule is what matched and src is the text it covered, so a walker written once applies to every language you define.

Nothing here is grammar-specific: the two runtimes read the identical fields off the identical shape.

output rule=list src="a" · rule=list src="a,bc,def"

Every parse returns the same shape — { rule, src, kids } — whatever the grammar. Walk it once and the walker works for every language you define.

4 · Check that it rejects

This is the step people skip, and it’s the one that catches a broken grammar. A grammar that accepts everything looks exactly like a grammar that works.

Try the malformed input

import { Tabnas } from '@tabnas/parser'import { abnf } from '@tabnas/abnf'const tn = new Tabnas({ plugins: [abnf] })tn.abnf(`  list  = item *( COMMA item )  item  = 1*ALPHA  COMMA = ","`)// The step people skip. A grammar that accepts everything looks exactly like a// grammar that works, so try the malformed input too.for (const src of ['a', 'a,bc,def', 'a,,b', ',a', 'a,']) {  let verdict = 'accepted'  try { tn.parse(src) } catch { verdict = 'rejected' }  console.log(JSON.stringify(src).padEnd(11), verdict)}
package mainimport (	"fmt"	abnf "github.com/tabnas/abnf/go"	tabnas "github.com/tabnas/parser/go")func main() {	j := tabnas.Make()	_, err := abnf.Install(j, `  list  = item *( COMMA item )  item  = 1*ALPHA  COMMA = ","`, nil, nil)	if err != nil {		panic(err)	}	// The step people skip. A grammar that accepts everything looks exactly like	// a grammar that works, so try the malformed input too.	for _, src := range []string{"a", "a,bc,def", "a,,b", ",a", "a,"} {		verdict := "accepted"		if _, err := j.Parse(src); err != nil {			verdict = "rejected"		}		fmt.Printf("%-11q %s\n", src, verdict)	}}

Recognising good input proves very little. If a,,b had parsed, the *( COMMA item ) group would be matching a comma without requiring an item after it — and the grammar would look fine while being wrong.

A leading or trailing comma is refused for the same reason. Always run the malformed cases; they are what tells a working grammar from a permissive one.

output "a" accepted · "a,bc,def" accepted · "a,,b" rejected · ",a" rejected · "a," rejected

If a,,b had parsed, the *( COMMA item ) group would be matching a comma without requiring an item after it. Always try the malformed input.

5 · See inside it

Two packages read a live grammar, which is possible because the grammar is still data at runtime rather than generated code:

npm install @tabnas/debug @tabnas/railroad

@tabnas/debug can describe the grammar and print it back as ABNF — if the round-trip isn’t what you wrote, the grammar isn’t what you meant. @tabnas/railroad draws it as a syntax diagram.

You can also paste the grammar straight into the playground and watch the tree change as you type.

What you skipped

Two things this grammar quietly relies on, worth knowing before you write a bigger one:

  • The lexer. ALPHA and the number token NR are built in, and COMMA is a fixed token. Beyond that you configure or write matchers yourself — tabnas separates lexing from parsing and is mostly about the parsing half.
  • Repetition is desugared. *( … ) compiles to generated group rules, so the runtime rule names aren’t only the ones you wrote. That matters when you attach actions — see attaching actions.

Next

Describes @tabnas/parser 0.8.10 · all pinned versions