# grmtools, Yacc grammars in Rust without rewriting them

> grmtools is a suite of Rust libraries for lexing and parsing that lets you keep traditional `.y` and `.l` grammar files, generating the lexer and parser at compile time from a `build.rs`. The catch is that the generated parsers are LR, which means the grammar you can write is not the grammar you could have written in a hand-rolled recursive descent parser.

**softdevteam/grmtools** — Rust grammar tool libraries and binaries

- Repository: https://github.com/softdevteam/grmtools
- Stars: 577 · Forks: 42
- Language: Rust
- License: NOASSERTION
- Published: 2026-10-22 · Updated: 2026-10-22 · Language: en
- Canonical page: https://hysenlabs.com/projects/softdevteam-grmtools

## A Yacc front end for a Rust project

grmtools is described as a suite of Rust libraries and binaries for parsing text, both at compile time and at run time. The project documentation is direct about who will care about it: most users will probably be interested in the compile-time Yacc feature, which allows traditional `.y` files to be used, mostly unchanged, in Rust.

That is the whole pitch. You have a grammar written in Yacc, perhaps inherited from a C codebase or a language implementation, and you want to use it in Rust without rewriting it by hand as a recursive descent parser.

The libraries involved are `lrlex` for the lexer, `lrpar` for the parser, `lrtable` for the parse table machinery, and `cfgrammar` for a grammar representation, all published to crates.io. There is also a `nimbleparse/` directory in the repository, which the workspace manifest lists as a member, and the topics list includes error recovery and generator, which tells you the project also carries a grammar generator.

The repository layout is a single Cargo workspace with those crates as members, plus `lrlex/examples/` and `lrpar/examples/` directories that are themselves workspace members, one per example, along with a `lrpar/cttests` pair for compile-time tests. There is a `doc/` directory, a `deny.toml` for dependency auditing, a `CHANGES.md` changelog, and both `LICENSE-APACHE` and `LICENSE-MIT` files, so the licence is a dual Apache or MIT grant even though the repository metadata does not classify it.

The audience is a Rust developer with a Yacc grammar, or one who wants to write their grammar in a Yacc-like notation and have the parser generated rather than hand-written. The last push to the repository was on 2026-09-21, and there are no GitHub releases.

## The build script is where the grammar becomes Rust

The quickstart describes a minimal example as two files in addition to the grammar and lexing definitions, the first of which is a `build.rs` at the project root.

```rust
use lrlex::CTLexerBuilder;

fn main() {
    CTLexerBuilder::new()
        .lrpar_config(|ctp| {
            ctp.grammar_in_src_dir("calc.y")
                .unwrap()
        })
        .lexer_in_src_dir("calc.l")
        .unwrap()
        .build()
        .unwrap();
}
```

The builder is the whole integration. `CTLexerBuilder` is the compile-time lexer builder, and it takes two things: an `lrpar_config` closure that points at the grammar file, here `calc.y` in the source directory, and a `lexer_in_src_dir` call for the lexer definition, here `calc.l`.

The naming is worth pausing on, because it is where the confusion usually starts for newcomers. The `CT` prefix means compile time, and the thing that makes this work is that the builder emits Rust source rather than a table you load at runtime. The `lrpar_config` closure is where you can pass options to the parser generator, and here it is only used to say which file to read.

The result is that a `cargo build` runs the generator, writes the lexer and parser, and compiles them, and your application sees ordinary Rust modules. There is no runtime code generation and no grammar parsing at startup, which matters for a service where startup time and binary size are both visible.

The alternative, which the project supports, is a runtime parser. The `lrtable` crate and the presence of `lrpar/examples/calc_manual_lex` in the workspace, where the lexer is written by hand, point at a second mode where the grammar is interpreted or the table is loaded at runtime. The documentation in the repository does not spell out the trade-off between the two, so it is worth asking which one you want before you start, because the choice determines whether your binary carries a generated parser or a loader.

## The lexer file is a lex definition, nearly unchanged

The lexer definition is a `.l` file, and the point of the project is that it is the kind of file you already have.

```rust
%%
[0-9]+ "INT"
\+ "+"
\* "*"
\( "("
\) ")"
[\t ]+ ;
```

That is standard lex syntax, and it maps directly onto the conventional `%%` section separator, the regular expression rules and the trailing whitespace rule. The `%%` marker separates the definitions from any user code sections in a full lex file, and the last rule, matching tabs and spaces with a semicolon action, is the conventional way of saying skip whitespace.

The grammar file is where the Yacc heritage shows most clearly, because the file begins with a grmtools-specific prologue.

```rust
%grmtools{yacckind: Grmtools}
%start Expr
%avoid_insert "INT"
%%
Expr -> Result<u64, ()>:
      Expr '+' Term { Ok($1? + $3?) }
    | Term { $1 }
    ;
```

The `%grmtools{yacckind: Grmtools}` line tells the generator which dialect it is parsing, `%start Expr` names the start symbol, and `%avoid_insert "INT"` is an instruction about how the lexer should be driven, specifically to avoid the token insertion that Yacc's lexer and parser pair normally performs for terminal symbols. That is a real difference from classic Yacc, and it is the kind of detail that shows up as a conflict warning or an unexpected token in the input when you port a grammar.

The action code is Rust in the braces, with the usual Yacc dollar notation, so `$1?` is the first symbol of the rule, matched and unwrapped to its value. The `Result<u64, ()>` type on the rule is also a departure from classic Yacc, where the value stack is untyped.

There is a trailing code section after the second `%%` in the file, and the grammar comments note that any functions there are in scope for all the grammar actions above, which is the classic Yacc epilogue behaviour preserved.

## Using the generated parser from your own code

The generated code is pulled into scope by two macros, one for the lexer and one for the parser, and the default module naming is derived from the file name.

```rust
use lrlex::lrlex_mod;
use lrpar::lrpar_mod;

// Using `lrlex_mod!` brings the lexer for `calc.l` into scope. By default the
// module name will be `calc_l` (i.e. the file name, minus any extensions,
// with a suffix of `_l`).
lrlex_mod!("calc.l");
// Using `lrpar_mod!` brings the parser for `calc.y` into scope. By default the
// module name will be `calc_y` (i.e. the file name, minus any extensions,
// with a suffix of `_y`).
lrpar_mod!("calc.y");
```

So `calc.l` becomes the module `calc_l` and `calc.y` becomes `calc_y`, and the file name minus any extension with a suffix appended is the convention. The macros take the path of the definition file, so the compiler can find the generated code and rebuild it when the grammar changes.

The runtime API is the conventional one for a generated LR parser. You get the `LexerDef` from the lexer module, build a lexer over the input, pass it to the generated parse function, and get back a result and a list of errors.

```rust
fn main() {
    // Get the `LexerDef` for the `calc` language.
    let lexerdef = calc_l::lexerdef();
    let args: Vec<String> = env::args().collect();
    // Now we create a lexer with the `lexer` method with which we can lex an
    // input.
    let lexer = lexerdef.lexer(&args[1]);
    // Pass the lexer to the parser and lex and parse the input.
    let (res, errs) = calc_y::parse(&lexer);
    for e in errs {
        println!("{}", e.pp(&lexer, &calc_y::token_epp));
    }
    match res {
        Some(r) => println!("Result: {:?}", r),
        _ => eprintln!("Unable to evaluate expression.")
    }
}
```

Two details in that are worth pointing out. The parse function returns a tuple of an optional result and a list of errors, so the error handling is yours to structure, and the errors are printed with a pretty-printer method that takes the lexer and the token's end position, which is what produces the caret-under-the-input diagnostic. That pretty-printing path depends on the token table, which is why the parser module has a `token_epp` associated with it.

The examples directory in `lrpar/examples` shows the variations the library supports, with separate directories for parse trees, ASTs, start conditions and states, executing code while parsing, and a clone of the parameter, and the `lrlex/examples` directory holds a calculation lexer and one with a hand-written lexer, which is the alternative to the generated one.

## What the LR constraint means for your grammar

The honest limitation of this project is structural, and it is worth understanding before you port a grammar.

The generated parsers are LR, which means bottom-up parsing with a fixed token lookahead. That buys speed and it buys a parser that can tell you precisely where the input went wrong, and it constrains what grammar you can write. An LR parser cannot handle the left recursion that a recursive descent parser handles naturally, and any grammar that requires a lookahead beyond what the tables encode has to be restructured rather than patched.

The classic practical symptom is the shift-reduce conflict. If your existing `.y` file was written for a different generator, or was written with hand-written lookahead tricks in the actions, the generated tables may report conflicts, and the resolution behaviour may differ from what the original tool did. The `%avoid_insert` directive in the example grammar is evidence of this class of problem, since it changes how the lexer and parser cooperate over token values.

The second limitation is the size of the generated code. An LR table for a large grammar compiles into a large amount of Rust, and the `Cargo.toml` release profile shows the project taking that seriously with `opt-level = 3`, `lto = true`, `codegen-units = 1` and `debug-assertions = false`. A single codegen unit and link-time optimisation across a workspace of generated parsers is a build-time cost that a team with several grammars will feel.

The third is the compile-time story in general. The build script runs the generator on every build, or at least whenever the grammar changes, and the workspace has dedicated members for `cttests` and `cttests_macro`, which are compile-time tests of exactly that mechanism.

None of this makes the project a poor fit. It makes it a fit for a specific situation: you have an LR-compatible grammar, usually already in Yacc form, and you want it in Rust with the diagnostics an LR parser gives you.

## Documentation, licensing and the shape of the project

The documentation arrangement is worth a note, because it is unusual and useful.

There is a table in the project documentation that maps the latest release against master for every crate: the grmtools book, cfgrammar, lrpar, lrlex and lrtable each get a link to docs.rs for the latest release and a link to the master-branch API documentation on the project's own site. And there is a separate link to the documentation for all past and present releases, which suggests the project keeps per-version documentation online rather than only the current one.

That matters for a parsing library, because the generated API is the product. A grammar tool whose API changes between releases leaves you unable to upgrade without regenerating everything, so per-version docs and a migration path are what make a tool like this adoptable at all.

The main guide is the grmtools book, which includes a more detailed quickstart guide, and the `lrpar` examples directory is linked from the project documentation as the place to see parse trees, ASTs, start conditions and states, and executing code while parsing.

The licence is the one detail the repository metadata does not classify cleanly, but the tree answers it: there is a `LICENSE-APACHE` and a `LICENSE-MIT`, which is the standard dual grant of Apache-2.0 or MIT, and a `COPYRIGHT` file alongside them. Either licence, so commercial use and modification are both unencumbered, and there is no copyleft obligation on your own code.

The release story is the soft spot. There are no GitHub releases recorded, the crates carry their own versions on crates.io with the badges for `lrpar`, `lrlex`, `lrtable` and `cfgrammar` all linking to their crates.io pages, and the `CHANGES.md` file at the root is the changelog. A project whose value is in its parser tables and its generated API is easier to trust with tagged releases, so that is a reasonable question to ask the maintainers.

The audience, in the end, is narrow and the tool is well suited to it: a Rust developer who has a Yacc grammar and wants it to work.

## Conclusion

grmtools suits a team that already has a Yacc grammar, or that wants to share one between a Rust service and a C or Python component, and that is comfortable with LR grammar constraints. It does not suit a team that wants a hand-written parser with arbitrary error recovery, since the generated parser is LR and the grammar shape follows from that. Verify first that your existing `.y` file uses only the constructs the tool supports, that the runtime library choice matches how you want to embed the parser, and that the generated code size is acceptable for your build times before you commit a large grammar to it.

## FAQ

### What is grmtools?

It is a suite of Rust libraries and binaries for parsing text, at compile time and at run time, built around the lrlex, lrpar, lrtable and cfgrammar crates. Its main feature lets traditional .y Yacc grammar files be used, mostly unchanged, in Rust.

### How do I build a parser from a Yacc grammar with grmtools?

Add a build.rs to the project root that uses CTLexerBuilder, pointing the lrpar_config closure at your .y grammar file and calling lexer_in_src_dir with your .l file, then call build. The generator runs during cargo build and emits the lexer and parser as ordinary Rust modules.

### How do I use the generated grmtools parser in my code?

Bring the modules into scope with lrlex_mod! and lrpar_mod!, passing the paths of the .l and .y files. The module names default to the file name with the extension replaced by a dash and a suffix, so calc.l becomes calc_l and calc.y becomes calc_y.

### What licence does grmtools use?

The repository ships both a LICENSE-APACHE and a LICENSE-MIT file with a COPYRIGHT file beside them, which is the dual Apache or MIT grant, so commercial use and modification are unencumbered. The repository metadata itself does not classify the licence type.

### What are the limits of the grammars grmtools can handle?

The generated parsers are LR, so the grammar shape is constrained by bottom-up parsing with fixed lookahead, and a grammar that relies on left recursion or unusual lookahead will need restructuring rather than patching. The example grammar also uses a %avoid_insert directive, which shows the lexer and parser cooperation differs from classic Yacc token insertion.

## Sources

- [Issues](https://github.com/softdevteam/grmtools/issues)
- [README](https://github.com/softdevteam/grmtools/blob/master/README.md)
- [softdevteam/grmtools on GitHub](https://github.com/softdevteam/grmtools)

---

Hysen Labs editorial analysis, written from the project's own repository and release notes. Cite the canonical page: https://hysenlabs.com/projects/softdevteam-grmtools
