What is a Grammar?
A grammar is a set of structural rules. Every language has one.
For example, plain English grammar follows a Subject–Verb–Object (SVO) order:
✅ I am Pablo
❌ Am Pablo I
This is everywhere. In mathematics we also defined a grammar Number symbol Number:
✅ 5 + 6
❌ + 5 6
A snippet of the JSON grammar:
value = object / array / string / number / "true" / "false" / "null"
member = string : value
object = {[ member *( , member ) ]}
array = [[ value *( , value ) ]]
In computing this is very important. If you are going to write a parser of a given format, first it must be clear what the grammar is.
There are thousands of JSON parsers and you can easily write your own since it's well documented.
But what if you're creating your own grammar? Or you want to build a parser of a proprietary and undocumented format?
The Abaqus .inp files
Abaqus is proprietary but widely used software that runs finite element simulations in aerospace, automotive, etc.
Abaqus .inp project files are plain text. Anyone can read them in a simple text editor.
For example, this .inp file describes this 🧊 cube:
*HEADING
Single C3D8 hexahedral element - 100 mm cube
*NODE, NSET=NALL
1, 0.0, 0.0, 0.0
2, 100.0, 0.0, 0.0
3, 100.0, 100.0, 0.0
4, 0.0, 100.0, 0.0
5, 0.0, 0.0, 100.0
6, 100.0, 0.0, 100.0
7, 100.0, 100.0, 100.0
8, 0.0, 100.0, 100.0
*ELEMENT, TYPE=C3D8, ELSET=ECUBE
1, 1, 2, 3, 4, 5, 6, 7, 8
*MATERIAL, NAME=STEEL
*ELASTIC
210000., 0.3
*BOUNDARY
1, 1, 3
2, 1, 3
5, 1, 3
6, 1, 3
7, 1, 3
8, 1, 3
*STEP
*STATIC
*CLOAD
3, 3, -1000.
4, 3, -1000.
*END STEP
And yet their language has no official parser or API documentation. There isn't even an official manual we can link: Abaqus keeps theirs behind a paywall, so we work with the documentation of CalculiX, an open-source Abaqus clone that reads the same .inp files. Hundreds of pages of prose, and no grammar in sight: https://dhondt.de/ccx_2.23.pdf
Abaqus .inp parser
We're going to write our own open source .inp files parser library, so anyone can use it to read .inp files and deal with them in their own software.
We take as example our 🧊 cube.inp which starts with the *HEADING. What is this?
Page 527 of the CalculiX manual
OK! Here it is. Now we understand what a HEADING looks like. We can parse it.
Now we do the same with NODE, with ELEMENT, MATERIAL ...
New keyword, open the 800-page PDF manual, define it in the parser, repeat ...
And this with all the keywords, good luck with it and with your tokens.
And what if we want someone else to expand or collaborate with this project? Is this huge PDF manual really the only source of truth for our .inp parsing project? That looks really overkill and costly.
So you know what this project needs, right? A grammar
Tabnas
With Tabnas, we can write the grammar of 🧊 cube.inp in a .abnf file
deck = *( kwcard / cmtcard / valuecard / blank )
kwcard = %x2A KWNAME [ KWITEMS ] crlf
KWNAME = TX
KWITEMS = %x2C [ KWITEM [ KWITEMS ] ]
KWITEM = TX
cmtcard = %x2A %x2A [ 1*( TXTCH ) ] crlf
valuecard = 1*( TXTCH ) crlf
TXTCH = TX / NR / ST / %x2C / %x3A / %x2A
blank = crlf
crlf = [ %x0D ] %x0A
Every .inp line you will ever see is one of four shapes:
- keyword card (
*NODE...) - comment card (
**...) - data line (
1, 0.0, 0.0, 0.0) - empty line.
Check the Tabnas documentation; it explains that block of code completely.
What you can do with your Tabnas Grammar?
1. Validate an .inp file
Does this Abaqus .inp file have a valid format?
We run our read-deck-keywords.mjs script against two files: cube.inp, and truncated.inp, which is broken on purpose.
✅ Valid cube.inp file
If we run it against our valid 🧊 cube.inp
node read-deck-keywords.mjs cube.inp
Output:
ACCEPTED cube.inp — 29 cards, 9 keywords:
*HEADING
*NODE,NSET=NALL
*ELEMENT,TYPE=C3D8,ELSET=ECUBE
*MATERIAL,NAME=STEEL
*ELASTIC
*BOUNDARY
*STEP
*STATIC
*CLOAD
❌ Invalid truncated.inp
But if we run it against the invalid truncated.inp
truncated.inp:
*HEADING
Single C3D8 hexahedral element - 100 mm cube
*NODE, NSET=NALL
1
node read-deck-keywords.mjs truncated.inp
Output:
REJECTED truncated.inp
SyntaxError: [tabnas/unexpected]: unexpected character(s): 1
--> <no-file>:1:72
1 | Single C3D8 hexahedral element - 100 mm cube
2 | *NODE, NSET=NALL
3 | 1
^ unexpected character(s): 1
4 |
5 |
The character(s) 1 do not match any rule alternative active at
this position.
--internal: tag=-; rule=_gen1_group~o; token=#NR; plugins=abnf--
The deck is rejected right away and Tabnas tells us exactly where the problem is.
2. Inspect your file keywords
An .inp file can have a lot of noise: data, comments, sets... Maybe for your parser it's interesting to know beforehand which keywords are being used in that .inp file.
With a grammar, extracting every keyword your .inp file uses is a piece of cake:
const tn = new Tabnas({ tokenSet: { IGNORE: ['#SP'] }, comment: { lex: false } })
tn.use(abnf)
// the grammar is in cube.abnf
const abnfSrc = readFileSync('cube.abnf', 'utf8')
const compiled = abnfSrc
.split('\n')
.filter((line) => !/^\s*;/.test(line))
.join('\n')
tn.abnf(compiled)
const file = process.argv[2] ?? 'cube.inp'
try {
// Filter tabnas parse to detect the different keywords
const deck = tn.parse(readFileSync(file, 'utf8'))
const cards = deck.kids.filter(...)
const keywords = cards.filter(...)
console.log('ACCEPTED', file, '—', cards.length, 'cards,', keywords.length, 'keywords:')
} catch (e) {
...
}
We run it again and the output is the same as demo 1: ACCEPTED cube.inp — 29 cards, 9 keywords:
With this information, anyone can easily tell that:
- There are 9 different keywords and 29 cards (recognized lines)
-
*NODE,NSET=NALL: this deck has a group of nodes, NALL -
*ELEMENT,TYPE=C3D8,ELSET=ECUBE: an element of type C3D8 with its connectivity, kept in the set ECUBE -
*MATERIAL,NAME=STEEL: a material called STEEL
3. Turn the file into JSON
There's a parser already in Tabnas. We can use it directly with our grammar:
const tn = new Tabnas({ tokenSet: { IGNORE: ['#SP'] }, comment: { lex: false } })
tn.use(abnf)
tn.abnf(compiled) // the grammar, loaded from cube.abnf like in the section above
const file = process.argv[2] ?? 'cube.inp'
const json = tn.parse(readFileSync(file, 'utf8'))
console.log(JSON.stringify(json, null, 1))
The output is very verbose, because the tree is built straight from the grammar: every rule in the .abnf becomes a node ("rule"), every node repeats its raw source text ("src"), and every reused category (TXTCH, crlf) brings its own kid.
node parse-deck.mjs cube.inp
Output
{
"rule": "deck",
"src": "*HEADING\nSingleC3D8hexahedralelement-100mmcube\n*NODE,NSET=NALL\n1,0.0,0.0,0.0\n2,100.0,0.0,0.0\n3,100.0,100.0,0.0\n4,0.0,100.0,0.0\n5,0.0,0.0,100.0\n6,100.0,0.0,100.0\n7,100.0,100.0,100.0\n8,0.0,100.0,100.0\n",
"kids": [
{
"rule": "kwcard",
"src": "*HEADING\n",
"kids": [
{"rule": "KWNAME", "src": "HEADING", "kids": []},
{"rule": "crlf","src": "\n","kids": []}
]
},
{
"rule": "valuecard","src": "SingleC3D8hexahedralelement-100mmcube\n","kids": [
{"rule": "TXTCH","src": "Single","kids": []},
{"rule": "TXTCH","src": "C3D8","kids": []},
{"rule": "TXTCH","src": "hexahedral","kids": []},
...
Why a grammar?
Why not just write a parser from the PDF documentation?
Because the parser you would write from the PDF is exactly what a grammar gives you:
Lightweight and reliable source of truth
If you write a parser, you don't want yourself or your agents to keep reopening the 800-page PDF for every keyword. It's a mess: your agents might hallucinate, and they will burn a lot of tokens to rebuild context that a 10-line grammar file provides in one shot.
In this project, the same cube.abnf is the backbone of all three tools you saw above: validation, keyword inspection and JSON parsing.
Error proof
This adds a security layer because the grammar is executable: validation is a one-liner, tn.parse(...).
In a pipeline (or an agent session) that touches hundreds of .inp files, this mechanism detects errors early and in a deterministic way.
Adding a keyword does not touch the grammar
The grammar is keyword-agnostic by design: *ANYTHING, PARAM=VALUE is already a valid keyword card, whatever the name is.
Production .inp files use dozens of keywords and new ones show up every release; a new keyword becomes a new case, not a grammar update.
Are there other tools to generate a grammar?
There are many: ANTLR, PEG.js and its maintained fork Peggy ... I checked Ohm and spotted very interesting things in this library.
Ohm vs Tabnas
Maturity
To begin with, this project has 11 more years of history behind it: the repository dates to May 2015, it has 5.5k stars and 48 open issues, and it was last updated this June. Tabnas is the opposite case: its first published npm package appeared in June 2026, so the Tabnas ecosystem is a few months old.
The webpage
Their webpage explains well what it does. I did not completely understand what the Tabnas project is and what a grammar does for it until I saw the awesome Ohm editor.
At a glance you can clearly see what a grammar is and how this library works. Clear and simple.
The Tabnas homepage, on the other hand... it's just hard.
It asks the reader to handle complex phrases like "behaviour binds out of band, through names the compiler already assigns"
Every term is eventually defined, but the buzzwords are just too much at once. If you are not already familiar with this style of grammar tool, you read the docs very slowly.
The Docs and How to sections don't help much at the beginning; they just add more noise.
Something I have to say is, after understanding the grammar context, and especially after learning from Ohm's webpage, I came back to Tabnas and everything was much clearer. So I'd say this is not for beginners.
Another positive thing to say is that the agents understood Tabnas quickly and could build the grammar and the examples of the Abaqus .inp file in seconds, and they helped me to understand what this is.
The syntax
The syntax of Ohm is really clear. This is a definition of some basic arithmetic operations:
In Ohm:
Arithmetic {
Exp
= AddExp
AddExp
= AddExp "+" MulExp -- plus
| AddExp "-" MulExp -- minus
| MulExp
MulExp
= MulExp "*" ExpExp -- times
| MulExp "/" ExpExp -- divide
| ExpExp
ExpExp
= PriExp "^" ExpExp -- power
| PriExp
PriExp
= "(" Exp ")" -- paren
| "+" PriExp -- pos
| "-" PriExp -- neg
| ident
| number
ident (an identifier)
= letter alnum*
number (a number)
= digit* "." digit+ -- fract
| digit+ -- whole
}
The same in abnf:
Exp = AddExp
AddExp = AddExp PLUS MulExp / AddExp MINUS MulExp / MulExp ; plus | minus | mul-level
MulExp = MulExp STAR Exps / MulExp SLASH Exps / Exps ; times | divide | pow-level
Exps = PriExp CARET Exps / PriExp ; power (tightest, right-assoc)
PriExp = LP Exp RP / PLUS PriExp / MINUS PriExp / name / number
name = TX ; an identifier (engine text token)
number = NR
PLUS = %x2B ; +
MINUS = %x2D ; -
STAR = %x2A ; *
SLASH = %x2F ; /
CARET = %x5E ; ^
LP = %x28 ; (
RP = %x29 ; )
In my opinion, Ohm is friendlier to humans because its grammar has more readable labels (-- plus, -- times) and the editor runs a live parse next to your grammar, which makes the feedback loop instant.
Conclusion
The idea behind Tabnas holds up, and the engine works. But there's too much friction from the beginning; I would not hand it to a beginner.
My coding agents, on the other hand, were happy with the tool: they understood it fast and built the grammar and the demos in no time.
That is exactly the project's own thesis:
"An extensible parsing engine, and a compile target for agents."





Top comments (1)
동일한 .abnf를 검증·키워드 추출·JSON 변환의 기준으로 쓰는 흐름이 이해하기 좋습니다. 키워드 이름을 폭넓게 허용하는 문법이라면, 존재하지 않는 키워드나 노드 참조 같은 의미 검증은 별도 단계로 둔다는 경계도 예시로 보여주면 ‘파싱 성공’을 ‘유효한 해석 모델’로 오해하지 않을 것 같습니다.