[TOC]
FE is a C++23 toolkit for building handwritten compiler and interpreter frontends.
Most of it is header-only; the handful of components that need a translation unit of their own come with FE_LIB, which is on by default.
Rather than generating lexers or parsers for you, FE focuses on the infrastructure that every frontend needs anyway: source locations, diagnostics, interning, parsing support, command-line handling, and efficient memory management. The goal is simple: keep handwritten frontends lightweight, explicit, and pleasant to maintain.
FE is a good fit if you want to build:
- a small programming language or DSL,
- a hand-written recursive-descent parser,
- a lexer with precise UTF-8-aware source tracking,
- a frontend with high-quality diagnostics,
- a prototype compiler or interpreter that should stay easy to evolve.
It is especially useful when you want the flexibility of handwritten code without repeatedly rebuilding the same frontend infrastructure from scratch.
Let is a complete little language: lexer, parser, AST, arena-allocated nodes, evaluator, printer, CLI, and a golden-file test suite.
sloccount src include says 598 lines.
| SLOC | |
|---|---|
| lexer + parser | 203 |
| token type: tag list and precedences | 128 |
| AST, evaluator, printer | 201 |
| driver + CLI | 66 |
A .l/.y pair for that grammar would not come out much shorter than those 203 lines - and Bison would additionally check the grammar for conflicts, which recursive descent never will.
What a generator does not write for you is the other 395: command-line parsing, the AST, the arena, the interning, the evaluator, the printer.
Nor does it write the diagnostics, and that is where the difference actually shows up.
Those 203 lines already produce this.
The error line and its snippet come out of expect; the note that points back at the ( is a three-line syntax_err override plus one fe::Restore to remember which ( it was:
test/error/unclosed_paren.let:1:13: error: expected `)`, got `;` while parsing parenthesized expression
1 | print (1 + 2;
| ^
test/error/unclosed_paren.let:1:7: note: unmatched `(` opened here
1 | print (1 + 2;
| ^
1 error(s) encountered
An anchor is a token an enclosing context is still waiting for, so a nested parser bails out instead of swallowing it.
That is what lets a stray ) be a message the parser recovers from - three times in one run - instead of the end of the parse:
test/error/stray_paren.let:1:12: error: ignoring unmatched `)` while parsing right-hand side of binary expression
1 | print 3 + 4) + 5;
| ^
test/error/stray_paren.let:2:14: error: ignoring unmatched `)` while parsing print-statement
2 | print (1 + 2)) * 2;
| ^
test/error/stray_paren.let:3:7: error: ignoring unmatched `)` while parsing print-statement
3 | print ) + 5;
| ^
3 error(s) encountered
Every message above is FE's own wording, summary line included; the only text Let contributes is that one note.
A generator hands you the parse and yyerror("syntax error") - the snippets, the notes, the recovery, and the --max-errors truncation are yours to build.
There is no code generation step, so there is no generated code to debug and no build-time dependency on a tool.
parse_expr is a function that says what it does, in the language the rest of your compiler is written in.
Handwritten frontends are often the right choice when you want full control over syntax, diagnostics, recovery, and architecture. FE embraces that style.
It provides a compact set of reusable, well-integrated components:
Header-only, except for what Requires FE_LIB lists below.
fe::Driverfor shared frontend state: the SymPool, the SrcMap, the internedDbgs, theDiagthat lays a diagnostic out, and theErroreverything reports into. Global variables in all but name - which is the point: they live in one object you own and pass around, not in the global namespace.fe::Arenafor fast arena allocation and arena-backed ownership.fe::Symandfe::SymPoolfor string interning and cheap identifier comparison.
fe::Lexer<K, S>for UTF-8-aware lexing with lookahead and token text accumulation.fe::Parser<Tok, Tag, K, S>for recursive-descent-style parsing with token lookahead, span tracking, and anchor-based error recovery. Both blueprints ask their child for afe::Driver& driver()and report their default diagnostics into itsError; a header-only setup words all of them itself and never touches aDriver.fe::utf8for lightweight UTF-8 handling.
fe::Posandfe::Locfor source positions and source spans.fe::Srcandfe::SrcMapfor owning source text and resolving a position back topath:row:col.fe::Dbgfor theLoc/Sympair every named entity drags along, interned in theDriveras aDbgKey.fe::Errorfor collecting diagnostics - errors, warnings and their notes - and rendering each with its source snippet;Error::ackthrows what it collected as a self-containedError::Bail. TheDriverowns the one everything reports into:Driver::error, withDriver::{error,warn,note}as shorthands.fe::Diagfor how a diagnostic lays out:Diag::loc_style(aLoc::Style),Diag::no_snippet, and friends cover the usual adjustments, and one virtual per piece (loc,header,snippet,note,summary,render) covers the rest. Derive andDriver::diag(std::make_unique<MyDiag>())to lay one out entirely your own way.fe::Logfor leveled logging with acronym, color, and origin prefix.Log::error/Log::warn/... shorthands point at their call site viastd::source_location; no macros involved.
fe::termfor lightweight terminal colors in diagnostics and CLI output.
fe::clifor parsingargc/argvof a single command: pipefe::cli::opt/fe::cli::arginto anfe::cli::Cliand bind each switch to a variable of yours - abool, astd::string, an integral, astd::vectorof those, or a callable. It understands--name value,--name=value,-n value,-nvalue, clustered short flags, and--.Cli::helplays those switches out for a terminal - grouped into sections byfe::cli::group, wrapped to the terminal width, and colored viafe::term.Cli::mdrenders the very same information as Markdown tables, so--helpand the manual cannot drift apart.Cli::sectionfor titledterm/description rows that aren'tOpts -ENVIRONMENT, plugin arguments, and the like - rendered below the options in both backends.
fe::Span/fe::Viewandfe::Vectorfor spans with structured binding and small-buffer vectors.fe::Bitsetfor a dynamically growing bit set that keeps small sets inline and only allocates once they grow.fe::XTriefor interned, immutable sets - an IndexedTrie that is space-efficient and answers intersection tests fast.fe::BFSWorklist/fe::DFSWorklistfor worklist traversals that visit each element at most once.- Optional
FE_ABSLsupport for Abseil hash containers.
fe::hashand friends for cheap,constexprhash mixing/combining.fe::Restorefor RAII save/restore of a variable - or of anything a getter/setter pair reaches, liketerm::ScopedMode- across a scope.fe/algo.handfe/container.hfor the odds and ends every frontend rewrites otherwise.
These need a translation unit of their own and hence live in src/fe/:
-
fe::Diagfor the diagnostic layout - and hencefe::Driver, whose ctor/dtor own one, andError::diag, which reads it back out of theDriver. The defaultsyntax_err/unanchored_err/utf8_err/char_erroffe::Parser/fe::Lexergo through this, so a header-only frontend has to supply its own. -
fe::Snippetfor the underlined source excerpt below a diagnostic. -
fe::dlandfe::sysfor loading dynamic libraries and locating/running external commands. -
fe::Profilerfor nested wall-clock spans reported as a flat table, a tree, or Chrome Trace JSON. -
The default
operator<</dumpoffe::Pos/fe::Loc.fe/loc.hmerely declares these. So in a header-only setup you have to hand-roll your own rendering - as a hidden friend, it must be defined in namespacefe:namespace fe { std::ostream& operator<<(std::ostream& os, Loc loc) { /* ... */ } std::ostream& operator<<(std::ostream& os, Pos pos) { /* ... */ } void Loc::dump() const { std::cout << *this << std::endl; } void Pos::dump() const { std::cout << *this << std::endl; } } // namespace fe
Otherwise you will run into a link error for
operator<<(std::ostream&, fe::Loc)and friends.
FE does not try to hide frontend construction behind a generator. Instead, it gives you sharp, reusable tools so you can build exactly the frontend you want.
For a complete end-to-end example, see Let, a small toy language built on FE.
The easiest way to get going is through Let.
You can either:
- π¦ create a new repository from the Let template, or
- π΄ fork Let directly.
That gives you a concrete, working example of how FE is intended to be used in practice.
Add FE as a subdirectory and link the fe target:
add_subdirectory(submodules/fe)
target_link_libraries(my_compiler PRIVATE fe)Set any of the options below before adding the subdirectory:
set(FE_LIB OFF) # header-only building blocks only - no fe::Driver, fe::Error, or fe::Diag
set(FE_ABSL ON) # use Abseil-backed hash containers
add_subdirectory(submodules/fe)
target_link_libraries(my_compiler PRIVATE fe)FE_LIB compiles src/fe/ along with the headers and is on by default.
Turn it off to get only the header-only building blocks; you lose the components listed under Requires FE_LIB.
fe-lib is an OBJECT library, so its symbols land inside a shared library of yours.
On Windows that shared library has to export them, which CMake cannot infer: compile everything that goes into it with fe_lib_EXPORTS, or FE_STATIC_DEFINE if there is no shared library in play.
target_compile_definitions(my_compiler PRIVATE fe_lib_EXPORTS)You can also vendor include/fe/ directly into your project and add the src/fe/*.cpp you need to your build - fe::dl additionally wants ${CMAKE_DL_LIBS}.
If you want Abseil support in that setup, compile with:
-DFE_ABSLA typical FE-based frontend looks roughly like this:
- Define a token type exposing
tag()andloc(). - Implement your lexer by deriving from
fe::Lexer<K, S>. - Implement your parser by deriving from
fe::Parser<Tok, Tag, K, S>. - Use
fe::Driverto centralize shared state; itsfe::Errorcollects the diagnostics. - Register each source file with
fe::Driver::src()so afe::Loccan resolve itself topath:row:col. - Thread
fe::Locthrough tokens and AST nodes for precise error reporting. - Use
fe::Arenaand symbol interning where allocation cost and identifier handling matter.
If you want a concrete model to copy from, start with tests/lexer.cpp.
To configure, build, and run the test suite:
cmake -S . -B build -DBUILD_TESTING=ON
cmake --build build
ctest --test-dir build --output-on-failureThe tests need FE_LIB, which is on by default.
To run one discovered test:
ctest --test-dir build -R '^Lexer$' --output-on-failureTo run a doctest case directly:
./build/bin/fe-test --test-case=LexerTo build the documentation:
cmake -S . -B build -DFE_BUILD_DOCS=ON
cmake --build build --target docsThis requires Doxygen and Graphviz (dot).
FE is developed against three frontends of very different scale, and every change has to work for all three:
- Let - the 619-line demo language above, and the template to fork.
- SQL - a SQL parser: two-token lookahead, reserved versus non-reserved words, and anchor-based recovery through comma-separated lists.
- MimIR - the author's compiler IR: three-token lookahead, a Unicode-heavy surface syntax, and plugins loaded mid-parse that bring their own vocabulary.
In the same spirit:
- GraphTool - a DOT-language tool using FE-style frontend infrastructure.
Issues and pull requests are welcome - whether that's a bug report, a new frontend building block, or a documentation fix. If you're unsure where to start, open an issue to discuss the idea first.
FE is licensed under the MIT License.