Lexer and preprocessor API: a lean token boundary for the C frontend
Goal. Redesign the lexer -> preprocessor -> parser boundary so a wholesale
lexer/preprocessor rewrite can be high-performance without fusing the frontend
layers. The lexer, preprocessor, and parser remain separately reusable modules:
cpp / cc -E still use the preprocessor as a standalone component, and the C
parser still consumes a preprocessed token stream. What changes is the data and
the handoff contract.
This is a design note for the new API and boundary types. It is not yet an implementation plan for the scanner algorithm itself.
1. Current boundary
Today the frontend has a simple but heavy token contract.
1.1 C compile path
lang/c/c.c wires the C frontend as:
source bytes
-> lex_open_mem / lex_skip_shebang
-> pp_new / pp_push_input
-> parse_c
-> pcg adapter
-> KitCg
-> CgTarget / NativeDirectTarget
-> NativeTarget
-> MC emit
The current parser-feed path sets pp_set_suppress_lexer_newlines(pp, 1) before
the primary lexer is pushed. Include lexers inherit the same policy. This avoids
materializing non-directive newline tokens for the parser, while still surfacing
directive-terminating newlines so directive reading works.
1.2 Lexer input
lex_open_mem takes:
Compiler*- source name as
const char*or pre-internedSym - borrowed source bytes and byte length
Opening a lexer also registers a fresh compiler source file id. The lexer owns phase-2 line-splice folding. If a file contains no backslash-newline splices, it borrows the original bytes. If splices exist, it builds a folded copy and records fold points for physical line accounting.
That means the true lexer input boundary is not just (char*, len). It is:
- source identity
- source bytes
- ownership/lifetime rule for those bytes
- source registry side effect
- newline policy
- phase-2 splice policy
- optional primary-file shebang handling
1.3 Lexer output
The lexer returns Tok by value:
typedef struct Tok {
u16 kind;
u16 flags;
SrcLoc loc;
Sym spelling;
union {
Sym ident;
Sym str;
u32 punct;
} v;
} Tok;
Important properties:
- Every content token carries a full
SrcLoc(file_id,line,col). - Every token that needs text carries an interned exact
spelling. - Identifiers are represented by interned
Sym. - Punctuators carry both an integer punctuator code and an interned spelling.
- Number/string/char literals carry interned spelling even though parsing later reads the spelling bytes again.
TOK_NEWLINEis a real token for PP mode and directive termination.TOK_PP_HASH,TOK_PP_PASTE, andTOK_HEADERare lexer-visible PP concepts.
1.4 PP internal source stack
PP reads from a stack of token sources:
SRC_LEX: aLexer*SRC_BUF: aTok[]replay buffer
SRC_BUF is used for macro bodies, argument prescan, pushback, #pragma
forwarding, and synthetic token sequences. Hidesets are internal to PP and are
carried either as a per-token side array or as one uniform hideset id for a
whole replay buffer.
Current macro bodies store full Tok arrays. A no-## body can be pointer
replayed with per-invocation loc/first-flag overrides; bodies with paste still
copy/substitute/paste through scratch buffers.
1.5 PP output to parser
pp_next() returns a macro-expanded Tok by value. The parser wraps it with:
- a
pendingtoken slot for string-literal fusion - a one-token lookahead slot
- a replay buffer for recorded braced blocks
The parser should not see non-directive newlines or directives. It does still
see forwarded non-pragma # tokens in invalid cases, and it explicitly swallows
forwarded pragmas.
There are already out-pointer paths inside PP (pp_next_raw_into,
pp_next_into) because returning a 24-byte Tok by value costs a hidden sret
copy. The public boundary has not caught up to that shape.
2. Design decision
Use a lean token as the canonical internal token for the C preprocessor lane:
- identifiers are interned eagerly
- punctuators are represented by integer code only
- exact spelling is a text reference, not always an interned
Sym - source location is a compact location reference, with line/column recovered
lazily when diagnostics or
__LINE__need it - PP writes into caller-owned token slots
- PP owns hidesets; hidesets do not cross to the parser
This does not require control-flow fusion. The scanner can be rewritten as a tcc-class high-throughput scanner, the PP can keep a source stack and macro engine, and the parser can remain recursive descent. The boundary becomes cheap enough that keeping the layers separate is not itself the hot cost.
3. Proposed core types
The names here are provisional. The point is the contract.
3.1 Source input
typedef uint32_t CppSourceId;
typedef enum CppSourceFlag {
CPP_SRC_PRIMARY = 1u << 0, /* allow shebang skip */
CPP_SRC_SYSTEM = 1u << 1, /* diagnostics/deps source property */
CPP_SRC_PARSER_FEED = 1u << 2, /* suppress non-directive newlines */
CPP_SRC_NO_SPLICES = 1u << 3, /* caller proves no backslash-newline */
} CppSourceFlag;
typedef struct CppSourceSpec {
KitSlice name;
const char* bytes;
uint32_t len;
uint32_t flags;
} CppSourceSpec;
The lexer open API should make source identity explicit. It should return, or store in the lexer, the compiler file id registered for this source. Include resolution remains PP-owned; the lexer should not do filesystem work.
CPP_SRC_NO_SPLICES is the safe version of today's paste fast path. It is only
valid for synthetic buffers whose construction proves no line splice can exist.
Normal source files and command-line definitions stay on the safe splice scan.
3.2 Location reference
typedef struct CppLocRef {
uint32_t file_id;
uint32_t byte_off;
} CppLocRef;
The hot token carries a byte offset in the logical source stream, not an eager
line/col pair. The source registry or lexer source object must have enough
line-map data to turn (file_id, byte_off) into KitSrcLoc on demand.
#line complicates this. The PP should keep the current file/line overlay on
the source stack, as it does today, and provide a helper:
KitSrcLoc cpp_pp_materialize_loc(CppPP* pp, CppLocRef loc);
Dynamic __LINE__ expansion should use the overlay-aware helper. Diagnostics
from parser/PP should also route through it. The parser should not know the
details of #line state.
3.3 Text reference
typedef enum CppTextKind {
CPP_TEXT_NONE = 0,
CPP_TEXT_SOURCE = 1,
CPP_TEXT_SYM = 2,
} CppTextKind;
typedef struct CppTextRef {
uint32_t kind;
uint32_t source_id;
uint32_t off;
uint32_t len_or_sym;
} CppTextRef;
For source tokens, CPP_TEXT_SOURCE names a byte span in the source's logical
post-splice buffer. For synthetic tokens, CPP_TEXT_SYM carries an interned
symbol id in len_or_sym.
Helpers:
KitSlice cpp_text_slice(CppPP* pp, CppTextRef text);
Sym cpp_text_intern(CppPP* pp, CppTextRef text);
int cpp_text_eq_cstr(CppPP* pp, CppTextRef text, const char* s);
This keeps exact spelling available for -E, diagnostics, stringize, paste,
literal decode, and macro identity checks without forcing every punctuator and
literal spelling through the global symbol table on first lex.
3.4 Token
typedef struct CppTok {
uint16_t kind;
uint16_t flags;
uint32_t aux;
CppLocRef loc;
CppTextRef text;
} CppTok;
Field meaning:
kind: token kind (EOF,IDENT,NUM,FLT,STR,CHR,PUNCT,PP_HASH,PP_PASTE,HEADER,NEWLINE, plus PP-internal kinds).flags: BOL, leading-space, no-expand, literal suffix/encoding flags, literal-bad.aux: identifierSym, punctuator code, macro parameter index, or small synthetic value depending on kind.loc: compact source location.text: exact spelling reference if the token has one.
For identifiers, aux is the interned Sym. text may also be present as the
exact spelling span, but normal identifier equality and macro lookup use aux.
For punctuators, aux is the Punct code. text is optional for common
canonical punctuators on the parser-feed path. The -E, stringize, and paste
paths can still recover spelling by either source span or canonical punctuator
table. Digraphs need an exact source span.
For literals, text is the source or synthetic spelling. Numeric and string
decoding should read via cpp_text_slice and only intern if a later operation
requires a Sym.
4. New API shape
4.1 Lexer
typedef struct CppLexer CppLexer;
int cpp_lex_open(CppLexer* lx, KitCompiler* c, const CppSourceSpec* src);
void cpp_lex_reset(CppLexer* lx, const CppSourceSpec* src);
void cpp_lex_close(CppLexer* lx);
void cpp_lex_set_mode(CppLexer* lx, uint32_t flags);
void cpp_lex_next(CppLexer* lx, CppTok* out);
uint32_t cpp_lex_file_id(const CppLexer* lx);
KitSrcLoc cpp_lex_materialize_loc(const CppLexer* lx, CppLocRef loc);
KitSlice cpp_lex_text_slice(const CppLexer* lx, CppTextRef text);
The lexer should fill a caller-provided token slot. It should not return token structs by value.
The lexer should not track include keyword state as a hidden state machine. Instead PP should request header-name lexing while reading an include/embed directive. That can be a mode bit scoped to the next token, or an explicit entry point:
void cpp_lex_next_header_name(CppLexer* lx, CppTok* out);
This removes #include knowledge from the normal scanner hot path.
4.2 Preprocessor
typedef struct CppPP CppPP;
int cpp_pp_open(CppPP* pp, KitCompiler* c, const KitPreprocessOptions* opts);
void cpp_pp_close(CppPP* pp);
int cpp_pp_push_source(CppPP* pp, const CppSourceSpec* src);
void cpp_pp_add_include_dir(CppPP* pp, const char* dir, int system);
void cpp_pp_define(CppPP* pp, const char* name, const char* body);
void cpp_pp_undef(CppPP* pp, const char* name);
void cpp_pp_next_raw(CppPP* pp, CppTok* out);
void cpp_pp_next_parse(CppPP* pp, CppTok* out);
void cpp_pp_emit_text(CppPP* pp, KitWriter* out);
KitSrcLoc cpp_pp_materialize_loc(CppPP* pp, CppLocRef loc);
KitSlice cpp_pp_text_slice(CppPP* pp, CppTextRef text);
Sym cpp_pp_text_intern(CppPP* pp, CppTextRef text);
cpp_pp_next_raw is the -E stream: macro-expanded, directives consumed,
newlines preserved as needed for text reconstruction.
cpp_pp_next_parse is the parser stream: macro-expanded, directives consumed,
non-directive newlines absent, forwarded pragmas swallowed or represented by a
parser-ignored event.
Both write into caller-owned slots.
4.3 Parser
The parser should own a small token cursor:
typedef struct CTokenCursor {
CppPP* pp;
CppTok cur;
CppTok next;
CppTok pending;
uint8_t has_next;
uint8_t has_pending;
} CTokenCursor;
The parser's advance, peek1, pending string-literal fusion, and braced-block
replay stay local to the parser. The difference is that they copy CppTok, not
the old eager-interned Tok, and they ask PP/text helpers only when a spelling
or materialized location is actually needed.
5. Layer responsibilities
5.1 Lexer owns
- raw byte scanning
- phase-2 splice folding and source line maps
- physical BOL / leading-space flags
- token kind recognition
- identifier interning
- numeric/string/char literal extent and suffix/encoding flags
- punctuator code classification
- header-name tokenization only when PP explicitly requests it
5.2 PP owns
- include search, include cache, include graph edges
- source stack and source lifetime
- directive recognition and directive-line reading
- conditional inclusion stack and skipped-section scanning
- macro table
- hidesets
- macro argument collection and prescan
- macro replay buffers
- token paste and stringize
- dynamic predefined macros
#lineoverlays- raw stream vs parser stream policy
- text emission for
-E
5.3 Parser owns
- C keyword classification
- token lookahead
- string-literal fusion
- braced-block replay used by current parser logic
- literal value decoding
- diagnostics through PP materialization helpers
- semantic/type resolution and CG driving
Hidesets must not cross into the parser. Include state must not cross into the lexer. C semantic keyword/type state must not cross into PP.
6. Important invariants
Exact spelling remains available.
#,##,-E, diagnostics, literal decode, macro redefinition checks, and header parsing all require exact spelling. The change is lazy access, not lossy tokens.Parser-feed tokens do not require newline materialization. The parser stream should never allocate or return non-directive newline tokens. Directive termination remains an internal PP/lexer concern.
Identifier lookup stays
Sym-based. Macro lookup and parser binding/keyword lookup should be one indexed or table lookup on an interned identifier.Punctuator spelling is not a hot-path symbol-table operation. Punctuators are roughly half the stream on large C files. The parser needs the punctuator code, not a symbol table entry. Exact text is only needed for
-E, stringize, paste, and diagnostics.Line/column is lazy. Hot tokens should not pay column arithmetic and
SrcLocconstruction unless a consumer needs a materialized location.Synthetic tokens have stable text. Macro expansions, dynamic predefined macros, paste output, stringize output, and command-line definitions must have text refs with lifetime at least until the returned token is consumed or until the owning PP arena is reset.
Macro replay can remain pointer-based. Macro bodies should store
CppTok[]plus compact metadata. Object-like no-paste bodies can still be pointer-replayed with loc/first-flag override.The public compatibility API can exist during migration. Old
Tok pp_next(Pp*)can be implemented as an adapter while parser code is converted. This allows byte-identical gates at each step.
7. Migration plan
Step 1: introduce the types and adapters
Add CppTok, CppTextRef, CppLocRef, and text/location helper APIs next to
the current Tok. Keep old Tok as the public compatibility type.
Build adapter helpers:
void cpp_tok_to_old_tok(CppPP* pp, const CppTok* in, Tok* out);
void old_tok_to_cpp_tok(CppPP* pp, const Tok* in, CppTok* out);
The old adapter will eagerly intern text and materialize locations. That is acceptable as a temporary compatibility layer.
Step 2: make PP public pull slot-based
Add slot-based public APIs over the existing implementation:
void pp_next_into_public(Pp* pp, Tok* out);
void pp_next_raw_into_public(Pp* pp, Tok* out);
Convert parser fetch to use the slot API before changing representation. This is a low-risk cleanup because PP already has internal out-pointer machinery.
Step 3: rewrite lexer behind a compatibility adapter
Implement the new scanner to fill CppTok. Convert to old Tok at the lexer
or PP boundary. Gate on:
- PP golden tests
- parser tests
- sqlite object byte identity
- sqlite
-Etoken/text identity - diagnostic location parity on focused cases
At this stage performance will understate the final win because the adapter still eagerly interns/materializes.
Step 4: convert PP macro storage and source stack to CppTok
Move macro bodies, replay buffers, argument vectors, and directive lines to
CppTok. Keep pp_next() compatibility at the outside edge.
This is where lazy spelling starts to matter: macro expansion should carry source text refs until a PP operation demands interned text.
Step 5: convert parser cursor to CppTok
Replace parser Tok slots with CppTok slots. Parser helpers should use:
tok_ident(t)for identifierSymtok_punct(t)for punctuator codecpp_pp_text_slicefor literal spellingcpp_pp_materialize_locfor diagnostics and CG source loc
After this step, the old Tok adapter is no longer on the compile hot path.
Step 6: delete or narrow the old token contract
Keep an old-style token only where external API compatibility requires it, or
delete it if no public API promises it. The C frontend hot path should be
CppTok end to end through lexer, PP, and parser.
8. Open questions
Line-map owner. Should line maps live in the compiler source registry, the lexer source object, or PP's source cache? The answer affects how parser diagnostics materialize
CppLocRefafter the lexer for an include has been popped.Text lifetime for folded source. If a source needed splice folding,
CppTextRefmust point at the folded logical buffer, not the original bytes. That folded buffer must live long enough for any token text refs in macro bodies that outlive the source file.Macro body text retention. A macro body defined in an include may survive after that include source is popped. Either macro-body tokens must intern/copy their text at definition time, or source buffers referenced by macro bodies must be retained until
pp_free.Canonical punctuator text. For canonical punctuators, can
CppTextRefbe omitted and reconstructed from the punctuator code? That is attractive for parser-feed and most PP paths, but-Emust preserve digraph spelling where relevant.Header-name mode. The current lexer has directive state to emit
TOK_HEADER. The new design should move this to PP, but the exact API should be chosen when directive reading is rewritten.Token-paste validation. Paste currently concatenates spellings and re-lexes a tiny buffer. The new scanner should preserve that validation path but route it through
CPP_SRC_NO_SPLICESand a reusable lexer object.Diagnostics parity. Lazy location must preserve user-visible line/column behavior, including
#line, includes, splices, comments, and shebang handling.Public names. The final names should probably not expose
Cppif this becomes the common C-family preprocessor substrate. The design usesCpp*only to avoid colliding with currentTok/Ppnames.
9. Initial right-sized target
The first useful deliverable is not the full PP rewrite. It is a measured compatibility lane:
- New source spec, loc ref, text ref, and token definitions.
- Slot-based public PP pull API over the current code.
- New scanner filling the lean token.
- Adapter to old
Tok. - Head-to-head sqlite measurement:
- raw lex instructions per byte
- compile
-cinstructions -Ebyte identity- object byte identity
If the adapter lane cannot produce a scanner win in isolation, the scanner algorithm is not yet good enough. If it does win in isolation but not through compile, the next bottleneck is PP token storage/replay and parser conversion.