@tabnas/gbnf does this text match your grammar?

GBNF constrains a model while it generates. This answers the same question afterwards, offline and with no model in the loop. From Python, TypeScript, Go, or a shell.

MIT no model required python · typescript · go · c abi

Your grammar can be wrong and still compile

Constrained decoding guarantees your model conforms to the grammar as written. It doesn't guarantee the grammar says what you meant. And it does nothing at all for text produced without it.

GBNF is the notation llama.cpp uses for constrained decoding. At each step the sampler masks every vocabulary token that would leave the grammar's language, so the model physically can't emit malformed output. llama.cpp reads it, and so do XGrammar (and therefore vLLM and SGLang), KoboldCpp, LocalAI and node-llama-cpp.

In every one of those the grammar runs inside generation and nowhere else. None of them can answer “does this string match my grammar?” without spinning up a model. The ecosystem's one offline checker, llama.cpp's llama-gbnf-validator, is a C++ example binary that answers accept or reject and nothing else: no library form, no AST, no structured errors.

That gap is what this fills.

Try it here

Both boxes below are editable and the verdicts re-run as you type. The grammar is compiled by the same package you would install, running in this page.

Every character counts, because GBNF is scannerless: yes  (90%) is rejected for the second space. Break the grammar itself and you get a compile error in place of verdicts, which is the distinction the tool exists to keep. Widen it and watch a rejected sample turn green.

Start here

Give it a grammar and a sample. The exit code alone is the answer, so this drops straight into a shell script or a CI step:

# npm i -g @tabnas/gbnf
$ gbnf-check json.gbnf --text '{"a": 1}' --text '{"a": 1,}'
grammar json.gbnf: ok
sample text#0: accept
sample text#1: REJECT — [tabnas/unexpected]: unexpected character(s): , (line 1, column 8)
$ echo $?
1

From Python, where models are usually driven:

import gbnf

with gbnf.Grammar.from_file("json.gbnf") as g:
    g.accepts('{"a": 1}')      # True
    g.accepts('{"a": 1,}')     # False

    v = g.check('{"a": 1,}')
    v.accept                   # False
    v.error["message"]        # '[tabnas/unexpected]: unexpected character(s): ,'

From TypeScript and Go, the same grammar on a parser instance:

TypeScript

const { Tabnas } = require('@tabnas/parser')
const { gbnf } = require('@tabnas/gbnf')

const tn = new Tabnas({ plugins: [gbnf] })
tn.gbnf(src)

tn.parse('{"a": 1}')  // => the AST

Go

tn := tabnas.Make()
if _, err := gbnf.Install(tn, src, nil); err != nil {
    // the grammar is broken
}

if _, err := tn.Parse(`{"a": 1}`); err != nil {
    // outside the language, err says where
}

Four things you'd use it for

Unit-test a grammar

Run known-good samples through it before a run. A grammar narrower than you intended would otherwise stay invisible until real input fails.

Check unconstrained output

Cached completions, another provider, hand-written fixtures, fine-tuning targets. Constrained decoding never touched any of it.

Gate a grammar in CI

Exit codes and --json mean editing a grammar can fail a build the way editing code does.

Close an agent's repair loop

A rejection comes back as a code, a line and a column. A grammar that won't compile can name the rule. Enough to fix either without a human reading stderr.

The first is the one people underestimate. A grammar is code. It can be wrong in ways that never throw. Nothing else in the ecosystem lets you assert what it accepts:

def test_response_grammar():
    with gbnf.Grammar.from_file("response.gbnf") as g:
        for good in GOLDEN_OUTPUTS:
            assert g.accepts(good)      # the grammar admits what you expect
        for bad in NEAR_MISSES:
            assert not g.accepts(bad)  # and excludes what it should

Both directions matter. A grammar that accepted everything would pass a suite that only ever checked the happy path. That's why this repo's own corpus is graded in both directions.

Three outcomes, not two

A rejection is an answer, not a failure. An out-of-language string comes back as a falsy verdict and never raises. Invalid calls and operational failures do raise, as they should: a grammar that won't compile, a closed handle, a bad argument type, an unreadable file, a shared library that can't be found.

situationPythonCLI exit
grammar doesn't compile GbnfError raised2
input outside the language falsy Verdict1
input in the language truthy Verdict0
bad arguments, unreadable file TypeError / OSError3

Collapsing the first two is the mistake worth avoiding. It tells you your model's output was wrong when the real problem was your grammar. So you go and debug the wrong thing.

The report is machine-readable

--json writes a stable document to stdout, in the shape tooling and agents consume, so nothing has to parse prose:

$ gbnf-check json.gbnf --text '{"a": 1,}' --json
{
  "tool": "gbnf-check",
  "version": "0.1.4",
  "ok": false,
  "exit": 1,
  "grammar": {
    "source": "json.gbnf",
    "ok": true,
    "error": null
  },
  "samples": [
    {
      "source": "text#0",
      "ok": false,
      "length": 9,
      "error": {
        "name": "SyntaxError",
        "message": "[tabnas/unexpected]: unexpected character(s): ,",
        "code": "unexpected",
        "line": 1,
        "column": 8
      }
    }
  ],
  "caveats": [
    {
      "code": "rejection-may-be-engine-limit",
      "message": "a rejection is this engine's answer, not always the grammar's: a grammar that needs backtracking can reject here and still constrain a sampler correctly",
      "url": "https://github.com/tabnas/gbnf/blob/main/ts/doc/known-gaps.md"
    }
  ]
}

That caveats entry is deliberate, and it's worth reading. A rejection here is this engine's answer, not always the grammar's: a grammar that needs backtracking can reject offline and still constrain a sampler correctly. The report says so, rather than let you decide your output was invalid.

Compile once, validate anywhere

Your inference workers don't need the GBNF front-end at all. Compile the grammar at build time into a pure-data recognition spec, ship that, and check against it with the engine alone:

GBNF text root ::= … libtabnasgbnf recognition spec pure data · JSON libtabnas verdicts accept / reject build time ship this run time
The front-end is only needed where the grammar gets compiled. After that, anything that can load the engine can answer accept or reject, in any language with an FFI.
spec = gbnf.compile_spec(open("json.gbnf").read(), as_text=True)
# ship `spec` to a service that has the engine and has never heard of GBNF

This one shipped late, on purpose. The serialized form used to drop GBNF's lexing configuration, so a reloaded arithmetic.gbnf lexed a+b as one token and rejected a+b=c. It loaded cleanly and it answered differently. For a validator that's the worst failure there is, because you'd have trusted it. compile_spec shipped only once the round trip agreed with a native install on every corpus sample, in both directions.

So how does it read the notation?

Skip ahead if you only wanted the tool. This part matters if you care whether the answers can be trusted.

GBNF is scannerless. Its grammar describes the input one character at a time, while the engine underneath is a tokenising parser that ships JSON-shaped matchers and an ignore set. Left alone, root ::= "a" would happily accept " a ", and a # in your input would vanish as a comment.

So the compiled grammar carries an empty ignore set and switches every default matcher off. That configuration is part of the accepted language. It looks like a performance knob and it isn't one. That's why dropping it from the serialized form was a correctness bug.

The front-end that reads GBNF is itself a tabnas grammar: a rule table the engine runs, in both TypeScript and Go, rather than a hand-written parser in each. One definition of the notation. And the engine proves itself on its own front-end.

What it's graded against

The corpus grammars are upstream bytes, never tidied to make a test pass. Exactly one sample is recorded as an expected failure: chess.gbnf with Nf3, which needs backtracking. If it ever starts working the suite goes red, because an expected failure that quietly turns into a pass is a documentation bug.

What it won't do

Where the rest of it lives

tabnas/gbnf

The front-end, the CLI, the C ABI and the Python binding. Also renderGbnf, which goes the other way: grammar IR back to GBNF text. Put @tabnas/abnf in front of that and you have an ABNF → GBNF bridge.

tabnas.dev

The parsing engine underneath, and the shared BNF-family compiler that GBNF, ABNF and EBNF all front. Grammars are data there, which is what makes a serialized spec possible at all.

Install

# the CLI and the TypeScript library
npm i @tabnas/gbnf

# the Go module
go get github.com/tabnas/gbnf/go

# the Python binding: build the shared library, then put py/ on the path
# (no packaging yet – see “what it won't do”)
git clone https://github.com/tabnas/gbnf && cd gbnf
(cd go/clib && ./build.sh)
PYTHONPATH=$PWD/py python3 -c 'import gbnf; print(gbnf.version())'