Frontend#
The frontend transforms C source text into a type-annotated abstract syntax tree suitable for IR lowering. It is organized as a four-phase pipeline where each phase has a single, well-defined responsibility and communicates with the next through an explicit interface type.
Pipeline Overview#
+-----------------+
Source text (.c) --> | 1. Preprocessor | --> Expanded text (String)
+-----------------+
|
v
+-----------------+
Expanded text --> | 2. Lexer | --> Token stream (Vec<Token>)
+-----------------+
|
v
+-----------------+
Token stream --> | 3. Parser | --> AST (TranslationUnit)
+-----------------+
|
v
+-----------------+
AST --> | 4. Sema | --> AST + SemaResult
+-----------------+
|
v
(consumed by IR lowerer)
Each phase is a self-contained Rust module under src/frontend/. The top-level
mod.rs re-exports the four submodules with pub(crate) visibility so that the
rest of the compiler can access them without reaching into internal details.
Phase 1: Preprocessor (preprocessor/)#
The preprocessor is a text-to-text pass. It consumes raw C source code as a
&str and produces a fully expanded String with all directives resolved,
macros expanded, and comments removed. The expanded output contains embedded
line markers (# line "file") so that downstream phases can map byte offsets
back to original source locations.
Public interface#
Preprocessor::new()-- creates a preprocessor instance with predefined macros (__GNUC__,__x86_64__,__LINE__,__FILE__, etc.) and builtin function-like macros already registered.Preprocessor::preprocess(&mut self, source: &str) -> String-- the main entry point. Strips comments, joins line continuations, processes directives (#include,#define,#if/#ifdef/#elif/#else/#endif,#pragma,#error,#warning,#line), and expands macros.- Configuration methods (
add_include_path,set_target,define_macro, etc.) allow the driver to set up search paths and target-specific state before preprocessing begins.
Output contract#
The preprocessor produces a single String of expanded C source code. This
string is syntactically valid C (modulo line markers) and is ready to be
tokenized. It also populates side-channel data:
| Field | Type | Purpose |
|---|---|---|
errors |
Vec<PreprocessorDiagnostic> |
Errors from #error, missing includes, etc. |
warnings |
Vec<PreprocessorDiagnostic> |
Warnings from #warning directives |
weak_pragmas |
Vec<(String, Option<String>)> |
#pragma weak directives for the linker |
redefine_extname_pragmas |
Vec<(String, String)> |
#pragma redefine_extname directives |
macro_expansion_info |
Vec<MacroExpansionInfo> |
Per-line macro expansion metadata for diagnostics |
Files#
pipeline.rs, macro_defs.rs, conditionals.rs, expr_eval.rs,
includes.rs, builtin_macros.rs, predefined_macros.rs, pragmas.rs,
text_processing.rs, utils.rs.
For detailed per-file documentation, see preprocessor/README.md.
Phase 2: Lexer (lexer/)#
The lexer tokenizes the preprocessed text into a flat Vec<Token>. Each token
carries a TokenKind discriminant and a Span (byte-offset range plus file
ID) for source location tracking.
Public interface#
Lexer::new(input: &str, file_id: u32) -> Self-- creates a lexer over the preprocessed text. Thefile_idis embedded in everySpanso the source manager can map offsets back to file names.Lexer::tokenize(&mut self) -> Vec<Token>-- scans the entire input and returns all tokens, terminated byTokenKind::Eof. Uses a capacity heuristic of one token per five bytes to minimize reallocation.Lexer::set_gnu_extensions(&mut self, enabled: bool)-- toggles GNU extension support (enabled by default). When enabled, the bare keywordstypeofandasm(without double-underscore prefix) are recognized. Note:$in identifiers is always permitted regardless of this flag.
Output contract#
The token stream is a Vec<Token> where each Token is a struct with
kind: TokenKind and span: Span fields. TokenKind covers:
- Integer literals: six variants distinguished by suffix --
IntLiteral,UIntLiteral,LongLiteral,ULongLiteral,LongLongLiteral,ULongLongLiteral. - Floating-point literals:
FloatLiteral(double),FloatLiteralF32(float withf/Fsuffix),FloatLiteralLongDouble(long double withl/Lsuffix, carrying both anf64approximation and full IEEE 754 binary128 bytes). - Imaginary literals (GCC extension):
ImaginaryLiteral(double imaginary),ImaginaryLiteralF32(float imaginary),ImaginaryLiteralLongDouble(long double imaginary). - String literals:
StringLiteral(narrow),WideStringLiteral(L"..."),Char16StringLiteral(u"..."). Character literals useCharLiteral. - Identifiers and keywords: all C11 keywords plus GNU/GCC extensions
(
__attribute__,__builtin_*,__extension__,__int128,__seg_gs,__seg_fs,__auto_type,__label__, etc.). - Pragma tokens: the preprocessor injects synthetic
TokenKindvariants for pragma directives that affect parsing:PragmaPackSet,PragmaPackPush,PragmaPackPushOnly,PragmaPackPop,PragmaPackReset,PragmaVisibilityPush, andPragmaVisibilityPop. These appear inline in the token stream and are consumed by the parser to adjust struct packing and symbol visibility state. - Operators and punctuation: the full set of C operators, braces, and delimiters.
Non-UTF-8 source bytes are handled through a private-use-area (PUA) encoding
scheme (see common/encoding.rs) so that the lexer can operate on the input as
a byte slice without rejecting arbitrary source files.
Files#
scan.rs, token.rs.
For detailed per-file documentation, see lexer/README.md.
Phase 3: Parser (parser/)#
The parser is a hand-written recursive descent parser that consumes the token
stream and produces a typed AST rooted at TranslationUnit.
Public interface#
Parser::new(tokens: Vec<Token>) -> Self-- takes ownership of the token stream. TheDiagnosticEngineis configured after construction via a separateset_diagnostics()method. Typedef names are seeded from a hardcodedbuiltin_typedefs()list of approximately 90 common C standard library and system types (size_t,int32_t,FILE,pthread_t,va_list, etc.), not from the preprocessor's macro table. This is necessary because the compiler does not always process real system headers.Parser::parse(&mut self) -> TranslationUnit-- the main entry point. Parses the entire translation unit and returns the AST.
Internal organization#
The parser is split across six impl Parser blocks: the core in parse.rs
and five extension files, each extending the same Parser struct with
pub(super) methods:
| Module | Scope |
|---|---|
expressions.rs |
Operator precedence climbing from comma through primary expressions |
types.rs |
Type specifier collection and resolution, struct/union/enum bodies |
statements.rs |
All statement forms including inline assembly (GCC extended syntax) |
declarations.rs |
External and local declarations, K&R parameters, initializers |
declarators.rs |
C declarator syntax (the inside-out rule), parameter lists |
Output contract#
The parser produces a TranslationUnit -- a list of ExternalDecl nodes, each
of which is one of:
FunctionDef-- a function definition with return type, parameters, body (as aCompoundStmt), and a packedFunctionAttributesbitfield carrying storage class, inline hints, and GCC__attribute__flags.Declaration-- a variable, typedef, struct/union/enum definition, or forward declaration._Static_assertis also handled within declaration processing. Uses a packedflags: u16bitfield for storage class and qualifier booleans.TopLevelAsm-- a top-levelasm("...")directive passed through verbatim.
AST nodes use Span annotations for source locations and packed bitfields
(rather than individual bool fields) for memory efficiency. Attributes like
section, visibility, and alignment that carry non-boolean payloads remain
as Option<String> or Option<usize>.
Files#
parse.rs, expressions.rs, types.rs, statements.rs, declarations.rs,
declarators.rs, ast.rs.
For detailed per-file documentation, see parser/README.md.
Phase 4: Semantic Analysis (sema/)#
Sema walks the AST to collect type information, build a scoped symbol table,
resolve typedefs and typeof(expr), evaluate compile-time constants, and check
for common errors. It does not transform the AST; instead, it produces a
SemaResult structure that accompanies the AST into the IR lowering phase.
SemanticAnalyzer implements the TypeConvertContext trait (defined in
common/type_builder), which provides the shared resolve_type_spec_to_ctype
method for converting AST TypeSpecifier nodes to CType values. The trait
callbacks delegate typedef, struct/union/enum, and constant-expression
resolution back into sema's own state.
Public interface#
SemanticAnalyzer::new() -> Self-- creates an analyzer with a fresh symbol table and pre-populated implicit function declarations (common libc functions likememcpy,printf, etc.).SemanticAnalyzer::analyze(&mut self, tu: &TranslationUnit) -> Result<(), usize>-- walks the entire translation unit. ReturnsOk(())on success orErr(error_count)if hard errors were emitted through the diagnostic engine.SemanticAnalyzer::into_result(self) -> SemaResult-- consumes the analyzer and returns the collected semantic information.
Output contract#
SemaResult bundles everything the IR lowerer needs:
| Field | Type | Contents |
|---|---|---|
functions |
FxHashMap<String, FunctionInfo> |
Return type, parameter types, variadic flag, definition status, and noreturn attribute for every function encountered |
type_context |
TypeContext |
Struct/union layouts, typedef resolutions, enum constants, function typedef metadata, packed enum types, and a type cache |
expr_types |
ExprTypeMap |
Per-expression type annotations keyed by ExprId (AST node identity) |
const_values |
ConstMap |
Pre-evaluated compile-time constants (float literals, sizeof, cast chains, binary ops with correct signedness) |
The TypeContext is the central type-system data structure shared between sema
and the lowerer. It holds:
- Struct/union layouts (
Rc<StructLayout>) indexed by tag name - Typedef mappings (name to resolved
CType) - Per-typedef alignment overrides from
__attribute__((aligned(N))) - Enum constant values
- Function typedef info (bare function typedefs and function pointer typedefs)
- Packed enum type info for forward references
- A per-function return-type cache
- A
CTypecache for named struct/union types
Files#
analysis.rs, builtins.rs, type_context.rs, type_checker.rs, const_eval.rs.
For detailed per-file documentation, see sema/README.md.
Data Flow Between Phases#
The following diagram shows the concrete Rust types that flow between phases:
[Source: &str]
|
| Preprocessor::preprocess()
v
[Expanded: String] + [PreprocessorDiagnostic] + [#pragma side data]
|
| Lexer::tokenize()
v
[Tokens: Vec<Token>] Token = struct { kind: TokenKind, span: Span }
|
| Parser::parse()
v
[AST: TranslationUnit] TranslationUnit = struct { decls: Vec<ExternalDecl> }
|
| SemanticAnalyzer::analyze()
v
[AST: &TranslationUnit] + [SemaResult]
| |--- functions: FxHashMap<String, FunctionInfo>
| |--- type_context: TypeContext
| |--- expr_types: FxHashMap<ExprId, CType>
| '--- const_values: FxHashMap<ExprId, IrConst>
v
(IR lowering consumes both the AST and SemaResult by ownership)
Two points of cross-phase information flow deserve special mention:
-
Typedef names in the parser. The C grammar is ambiguous:
T * x;is either a multiplication expression or a pointer declaration, depending on whetherTis a typedef name. The parser maintains its own set of typedef names, seeded from a hardcodedbuiltin_typedefs()list of approximately 90 common C standard library and system types (size_t,int32_t,FILE,pthread_t,va_list,__Float32x4_t, etc.) and extended during parsing astypedefdeclarations are encountered. No preprocessor state flows to the parser -- only the expanded source text (indirectly, through the lexer's token stream). -
Expression identity from parser to sema. Sema annotates expressions by keying on
ExprId, which is derived from the identity (address) of each ASTExprnode. This works because the AST is heap-allocated during parsing and is never moved or reallocated before the lowerer consumes it, so node addresses are stable across the entire compilation pipeline.
Key Design Decisions#
Text-based preprocessor#
The preprocessor operates on raw text rather than tokens. This is the simpler
approach (matching GCC's historical architecture) but means that precise source
locations for macro-expanded code are approximations. The line markers embedded
in the output (# line "file") provide file-level and line-level accuracy, but
column information within macro expansions is lost. This trade-off was chosen
for implementation simplicity: a token-based preprocessor (like Clang's) would
provide better diagnostics at the cost of significantly more complex
architecture.
Eager tokenization#
The lexer produces a complete Vec<Token> rather than exposing an iterator or
lazy stream. This simplifies the parser, which needs arbitrary lookahead and
backtracking for C's notoriously context-sensitive grammar (declarators,
type names, K&R parameter lists). The memory cost is modest -- the capacity
heuristic of one token per five bytes of source keeps allocation tight.
Recursive descent with split modules#
The parser uses recursive descent with operator precedence climbing for
expressions, rather than a parser generator. This gives full control over error
recovery and makes it straightforward to handle C's context-sensitive parsing
(e.g., the typedef/identifier ambiguity, declarator syntax, statement
expressions). The parser is split across multiple files that each add methods to
the same Parser struct via impl Parser blocks with pub(super) visibility.
This avoids trait dispatch overhead while keeping each file focused on a single
syntactic domain.
Packed bitfields for AST attributes#
Attribute booleans on FunctionAttributes, Declaration, and related AST nodes
are stored as packed bitfields (u16 or u32) rather than individual bool
fields. FunctionAttributes packs 13 boolean flags into a u16;
Declaration packs 9 boolean flags into a u16. Accessor methods
(getter/setter pairs) provide the same ergonomic API as named struct fields.
Sema as an information-gathering pass#
Sema is primarily an information-gathering pass rather than a strict type
checker. It walks the AST to populate the SemaResult (function signatures,
type context, expression types, constant values) that the lowerer needs. While
it does emit diagnostics for detectable errors (e.g., undeclared identifiers,
incompatible types in return statements, -Wreturn-type warnings), it does not
yet reject all type errors. The lowerer still performs some type resolution as a
fallback. This is a pragmatic choice that allowed the compiler to handle
real-world C code early; the intent is to move more checking into sema over
time.
Undo-log scoping in TypeContext#
TypeContext uses an undo-log pattern for scope management rather than cloning
entire hash maps. Each scope push creates a TypeScopeFrame that records newly
added keys and shadowed key-value pairs. Scope pop replays the undo log to
restore the previous state. This gives O(changes-in-scope) cost instead of
O(total-map-size), which matters for deeply nested scopes in large translation
units. Rc<StructLayout> is used for struct layouts so that save/restore
operations are cheap reference-count bumps.
Multi-line macro argument accumulation#
When the preprocessor encounters a line with unbalanced parentheses during macro
invocation, it accumulates subsequent lines before expanding. This handles
macro calls spanning many lines, including extreme cases like QEMU's generated
QLIT_QLIST() invocations that span tens of thousands of lines. A safety limit
(MAX_PENDING_NEWLINES = 100_000) prevents runaway accumulation from genuinely
unbalanced parentheses.
Include guard optimization#
After preprocessing an included file, the preprocessor scans for the classic
include guard pattern (#ifndef GUARD / #define GUARD / #endif). On
subsequent #include of the same file, if the guard macro is still defined, the
file is skipped entirely without re-reading or re-processing. This matches the
optimization performed by GCC and Clang and significantly reduces preprocessing
time for header-heavy translation units.
Known Limitations#
-
Source locations in macro expansions. Because the preprocessor is text-based, column-level source locations within macro expansions are approximate. Diagnostics point to the correct file and line but may not pinpoint the exact column within an expanded macro.
-
_Atomicqualifier._Atomic(type)is parsed and resolved to the underlying type, but the atomic qualifier itself is not tracked in the type system. Atomic operations are not enforced. -
_Genericconst tracking._Genericselection can distinguish const-qualified pointer types viais_constflags on associations and parameters, butconston global variables and complex expressions (casts, array subscripts) is not yet tracked in the type system.CTypedoes not carry qualifiers. -
Incomplete type checking. Sema does not yet perform full C type checking. Many type errors are caught during IR lowering rather than during semantic analysis. The
expr_typesmap may not cover every expression node. -
Conservative
-Wreturn-typeanalysis. The control-flow analysis for detecting non-void functions that may not return a value is intraprocedural and conservative. It may produce false positives in complex control flow patterns involving multiple gotos or computed jumps, and does not perform value-range analysis on conditions. -
Preprocessor macro location loss. The text-to-text design means that the preprocessor cannot provide a "macro expansion backtrace" showing which macro produced a given token. The
macro_expansion_infoside channel provides per-line expansion metadata, but it is less precise than what a token-based preprocessor could offer.
Module Index#
| Submodule | Entry point | README |
|---|---|---|
preprocessor/ |
Preprocessor::preprocess() |
preprocessor/README.md |
lexer/ |
Lexer::tokenize() |
lexer/README.md |
parser/ |
Parser::parse() |
parser/README.md |
sema/ |
SemanticAnalyzer::analyze() |
sema/README.md |