Frontend shared tagged cell
Goal. Define the end-state shape for the C front half around one shared, compact cell used by the lexer, preprocessor, and parser input ring. This is a design document, not a phasing plan.
The value-stack redesign lives in doc/plan/CG-STACK-API.md: the C parser drives
KitCg directly, KitCg owns the single liveness-authoritative value stack, C
type/value facts live in the CG language sidecar, and constant values live in the
CG constant payload. This document deliberately stops at the front-half cell and
the handoff from parser primary to CG stack.
In one line: one compact lexeme cell is shared by lexer, preprocessor, and the parser input ring; parser-only semantic tagging happens only on parser-owned cells; the preprocessor remains independently drainable; the CG stack remains the only live expression stack.
Non-Goals
- No parser-owned shadow value stack.
Parser.cg_slot_stack,PcgSlot, and thepcg_*adapter are removed byCG-STACK-API.md, not replaced here. - No
ApiSValuestored inside the frontend cell. A tagged cell is a short-lived lowering seed, not a durable value. - No scalar int-bitmask type lane in lex/pp cells. If scalar classification needs
caching after measurement, the cache belongs in the CG language sidecar or a
C type cache keyed by
const Type*, not in every token. - No C parser dependency from the lexer or preprocessor headers. Shared cell
helpers use
void*and integer ids; C-specific wrappers live underlang/c. - No global state. Source registries, macro tables, macro-disabled state, parser
rings, and CG side payloads hang off
Pp,Parser, orKitCg.
Ownership Boundaries
- Lexer and pp own the lexeme contract. A lexeme cell is complete enough for
macro expansion, directives,
-Eserialization, diagnostics, and replay. It carries lazyLocRef/TextRef, eagerly interned identifiers, punctuator codes inaux, and no C parser state. - The pp is independently drainable.
cpp,cc -E, andKIT_PP_DRAINpull cells into caller-owned storage with no parser attached. The pp reads and writes only the lexeme view. - The parser owns semantic tagging. The parser may tag only cells in its own input ring or short-lived scratch. It must never tag macro-definition storage, directive buffers, or pp-owned source buffers.
- Macro replay copies before tagging. Macro bodies are immutable lexeme-cell arrays. When replay emits a token, pp copies one lexeme into the caller's output cell, applies loc/first-token flag overrides, and returns. The parser may then tag that caller-owned copy.
KitCgowns values. Once a primary is accepted, the parser lowers throughkit_cg_*, stamps the CG top withkit_cg_retag_*, and advances. The cell can carry debug/lowering facts until the nextadvance, but expression lifetime is the CG stack slot.
Shared Cell
The durable stream/storage unit stays the current lean-token shape: 32 bytes on
LP64, trivially copyable, and without heap ownership. If the implementation
renames the type, today's Tok should become an alias of this cell rather than a
second representation.
The shared header must not include C parser or CG internals.
typedef enum CFeKind {
/* 0..0x11ff remain TokKind / pp-internal token kinds. */
CFE_SEM_FIRST = 0x8000,
CFE_SEM_VALUE = CFE_SEM_FIRST, /* parser-only: current token lowered as value */
CFE_SEM_PLACE, /* parser-only: current token lowered as place */
} CFeKind;
typedef struct CFeSem {
const void* lang_type; /* C parser: const Type*; opaque to lex/pp */
u32 cg_type; /* KitCgTypeId value, represented without cg includes */
u32 lang_flags; /* same C value flags passed to kit_cg_retag_* */
} CFeSem;
typedef struct CFeCell {
u16 kind; /* TokKind in lexeme view; CFeKind >= CFE_SEM_FIRST after tagging */
u16 flags; /* lexeme: TF_*; semantic: parser-private flags */
u32 aux; /* lexeme: Sym/Punct/PP_PARAM; semantic: small discriminator */
LocRef loc; /* survives both views; line/col materialize lazily through Pp */
union {
TextRef text; /* lexeme spelling ref; lexer/pp/drain view */
CFeSem sem; /* parser-only semantic seed; pp never reads this */
} u;
} CFeCell;
_Static_assert(sizeof(CFeSem) <= sizeof(TextRef),
"semantic overlay must not widen the lexeme cell");
_Static_assert(sizeof(CFeCell) == 32,
"cell must stay the compact token-stream storage unit");
Cell rules:
kind < CFE_SEM_FIRSTmeans lexeme view. All pp helpers assert this when reading a cell.kind >= CFE_SEM_FIRSTmeans parser-private semantic view. Such a cell is illegal to feed back into pp, macro storage, directive evaluation, or replay.- The lexeme and semantic views do not co-live. The parser must finish all
spelling, suffix, and encoding reads before overwriting
u.text. locsurvives tagging. Diagnostics and debug locations can materialize fullSrcLoclazily throughPp.- Wide integer constants, float payloads, aggregate initializers, static relocation constants, and expression lifetime never live in the cell. They go to the CG constant payload or the existing initializer/static-data machinery.
Lexer Contract
The lexer writes one caller-owned cell and returns nothing by value:
void lex_next(Lexer*, CFeCell* out);
Lexeme shape:
TOK_IDENT:aux = Sym,u.text = TEXT_SRCfor exact spelling. Identifiers remain eagerly interned becauseSymis the macro, keyword, and binding key.TOK_PUNCT,TOK_PP_HASH,TOK_PP_PASTE:aux = Punctor character code. Canonical punctuators useTEXT_NONE; digraphs may useTEXT_SRC.TOK_NUM,TOK_FLT,TOK_STR,TOK_CHR,TOK_HEADER:u.textis a source span or synthetic symbol. Literal suffix and encoding facts stay inflagsuntil the parser decodes the literal.TOK_NEWLINE: produced only on raw/preprocessor-drain paths that need it for text reconstruction. Parser-feed mode usesTF_AT_BOLplus directive state.loc:(file_id, byte_off)into the pp source registry. The lexer does not stamp line/column per token.TF_NO_EXPANDis clear on lexer-produced identifiers. Only the pp sets it when an identifier token becomes unavailable for macro expansion.
Materialization remains pp-owned because only Pp retains source buffers, splice
tables, and #line overlays:
SrcLoc pp_materialize_loc(Pp*, LocRef loc);
KitSlice pp_text_slice(Pp*, const CFeCell* cell);
Sym pp_text_intern(Pp*, const CFeCell* cell);
int pp_text_eq_cstr(Pp*, const CFeCell* cell, const char* s);
Preprocessor Contract
The pp exposes two drainable pulls, both out-pointer only:
void pp_next_parse(Pp*, CFeCell* out); /* expanded, directives consumed,
* non-directive newlines suppressed */
void pp_next_raw(Pp*, CFeCell* out); /* expanded, directives consumed,
* TOK_NEWLINE preserved for -E/cpp */
void pp_emit_text(Pp*, Writer* out); /* drains pp_next_raw */
Internal pp storage is lexeme-only:
typedef struct CFeReplay {
const CFeCell* cells;
u32 n;
Macro* disabled_owner; /* non-NULL while rescanning this macro replacement */
LocRef loc_override;
u16 first_flags_or;
} CFeReplay;
Requirements:
- Macro bodies, directive-line buffers, replay buffers, and argument slices store
CFeCell[]in lexeme view only. - A live lexer source should write directly into the caller's output cell where possible. A replay source copies one immutable lexeme into the caller's output cell.
- Directive processing,
#ifexpression evaluation, paste, stringize,__LINE__,__FILE__,_Pragma, and#includeconsume and produce lexeme cells only. - Synthesized tokens use
TEXT_SYMorTEXT_NONE; they never point at storage whose lifetime is shorter thanpp_free. - The parser-feed path must not require newline tokens.
TF_AT_BOLplus directive state is the line-boundary contract; raw-EkeepsTOK_NEWLINEfor text reconstruction.
Macro Expansion Availability
Use the simpler cpplib-style availability model instead of a per-token hideset table: a macro is disabled while its own replacement-list frame is being rescanned, and an identifier token has one permanent unavailable bit.
The unavailable bit is TF_NO_EXPAND in lexeme view. It means "this identifier
token must not be macro-expanded if it is seen again." One bit is sufficient
because the token's spelling names at most one macro at the time it is tested;
the bit does not need to encode a set of macro names.
Suggested pp-owned state:
typedef struct Macro {
Sym name;
u16 disabled_depth;
/* existing macro definition fields... */
} Macro;
typedef struct TokSrc {
u8 kind;
CFeCell* cells;
u32 i;
u32 n;
Macro* disabled_owner; /* non-NULL for macro replacement-list frames */
/* existing source/replay fields... */
} TokSrc;
Frame rules:
- Pushing a macro replacement-list frame increments
disabled_owner->disabled_depth. Popping that frame decrements it. - The invoking macro is not disabled while its arguments are collected or pre-expanded. It becomes disabled only while the substituted replacement list is rescanned.
- Nested expansions stack naturally: parent macro frames remain on the source stack while child macro frames are scanned, so parent macros remain disabled through nested replacement-list rescans.
- Object-like no-copy replay, function-like substituted bodies, and paste results all use the same frame rule. Empty replacement lists need no frame.
Identifier test in pp_next_*:
if (cell.kind == TOK_IDENT && (cell.flags & TF_NO_EXPAND) == 0) {
Macro* m = mt_get(pp, tok_ident(&cell));
if (m && m->disabled_depth != 0) {
cell.flags |= TF_NO_EXPAND;
*out = cell;
return;
}
if (m && macro_invocation_is_present(pp, m, &cell)) {
push_replacement_frame(pp, m, &cell);
continue;
}
}
Details that matter:
- A token returned because its macro is disabled keeps
TF_NO_EXPANDif it is later captured as an argument, substituted into another macro, or replayed. This is what makesfoo(foo)(1)andid(foo(foo)(1))stayfoo(1). - A function-like macro name that is not followed by
(is returned as a plain identifier without settingTF_NO_EXPAND. If it is later rescanned in a context where another macro has supplied the(, it may expand. This keeps cases likeid(f L 1)), with#define L (, able to become1. definedoperands in#ifexpansion can reuseTF_NO_EXPAND: the source of the mark is different, but the effect is the same.- Tokens created by
##are fresh lexeme cells withTF_NO_EXPANDclear. They are then rescanned under the active disabled-frame stack; if a pasted token spells a currently disabled macro, the normal identifier test marks it unavailable before returning it. - This removes the canonical hideset table and the per-token
HidesetIdside arrays from replay sources. The only per-token availability state that flows through arguments and replay is theTF_NO_EXPANDbit already in the cell.
Parser Input Ring
The parser consumes pp output through a small fixed ring of parser-owned cells. APIs return pointers to parser-owned cells, never cells by value:
typedef struct ParserInput {
CFeCell cells[3]; /* fixed cur + LL(2) lookahead ring; no VLA */
u8 cur; /* index of the current token in cells[] */
u8 nvalid; /* number of valid slots from cur, 0..3 */
CFeCell pending; /* string-literal fusion / one-token pushback */
u8 has_pending;
} ParserInput;
void parser_advance(Parser*);
void parser_fill_to(Parser*, u32 depth); /* cold path: fills through depth 0..2 */
static inline u8 parser_ring_idx(const ParserInput* in, u32 depth) {
return (u8)((in->cur + depth) % 3u); /* depth 0 == current */
}
static inline CFeCell* parser_cur(ParserInput* in) {
return &in->cells[in->cur];
}
static inline const CFeCell* parser_cur_const(const ParserInput* in) {
return &in->cells[in->cur];
}
static inline const CFeCell* parser_peek(Parser* p, u32 depth) {
ParserInput* in = &p->input;
if (in->nvalid <= depth) parser_fill_to(p, depth);
return &in->cells[parser_ring_idx(in, depth)]; /* depth 1..2 */
}
Rules:
parser_advancerotatescur = (cur + 1) % 3and fills the newly-free tail slot when needed. It does not copy or move liveCFeCellvalues between current/lookahead slots.parser_peek(p, depth)does the cheap inline validity check (nvalid <= depth) and callsparser_fill_toonly on miss.parser_fill_tofills the ring tail withpp_next_parse(p->pp, slot).parser_peekreturns aconst CFeCell*valid until the nextadvanceor replay source switch. Callers that need persistence copy the cell explicitly.- Adjacent string literal fusion writes the fused result into the destination
parser cell in place:
TEXT_SYM, combined encoding flags, and the preserved location of the first literal. - Parser replay sources store lexeme cells only. Before a cell is recorded for replay, it must be in lexeme view; tagged semantic cells are not replayable.
- There are no VLAs. Fixed lookahead stays fixed; any variable replay buffer uses the parser arena or an existing vector helper.
Parser Tagging
Tagging is local to a consumed primary. It is not a pp feature and it is not an expression stack.
The shared-cell helper has no C or CG header dependency:
static inline void cfe_tag_primary_raw(CFeCell* c, u16 sem_kind,
const void* lang_type,
u32 cg_type,
u32 lang_flags) {
c->kind = sem_kind; /* CFE_SEM_VALUE or CFE_SEM_PLACE */
c->flags = 0; /* parser-private flags if needed */
c->u.sem.lang_type = lang_type;
c->u.sem.cg_type = cg_type;
c->u.sem.lang_flags = lang_flags;
}
The C parser may wrap it with C/CG-specific types:
static inline void c_tag_primary(CFeCell* c, u16 sem_kind,
const Type* type,
KitCgTypeId cg_type,
u32 c_flags) {
cfe_tag_primary_raw(c, sem_kind, type, (u32)cg_type, c_flags);
}
Identifier primary:
TOK_IDENTcarriesSyminaux.- The parser checks
kw_map; otherwise it readsBindingTabonce. - The parser emits the corresponding CG seed: local place, global/function address, enum constant, integer constant, or other binding-specific form.
- The parser calls
kit_cg_retag_top(p->cg, type, flags)with the same semantic facts it wrote into the cell. - The parser advances; the tagged cell is no longer live.
Literal primary:
- The parser reads spelling, suffix, and encoding from lexeme view.
- The parser decodes only when the literal becomes a value.
- The parser pushes through the CG constant/value API. Width-complete integer
constants use
KitCgConstIntfromCG-STACK-API.md. - The parser retags the CG top with C type/value flags.
- The parser may tag the current cell as a debug/lowering seed until
advance.
Operators never tag tokens. They consume CG stack entries via kit_cg_*, query
KitCgSlotInfo, and retag results according to CG-STACK-API.md. The parser
input cell can be tagged; the live value is still the CG stack slot.
CG-Facing Dependencies
This design depends on the CG stack contract from CG-STACK-API.md:
void kit_cg_lang_enable(KitCg*);
KitCgSlotInfo kit_cg_slot_info(KitCg*, u32 depth_from_top);
void kit_cg_retag_top(KitCg*, const void* lang_type, u32 lang_flags);
void kit_cg_retag_at(KitCg*, u32 depth_from_top,
const void* lang_type, u32 lang_flags);
void kit_cg_set_top_flags(KitCg*, u32 set, u32 clear);
int kit_cg_top_const_int_ex(KitCg*, KitCgConstInt* out);
void kit_cg_push_const_int(KitCg*, KitCgTypeId type,
const KitCgConstInt* value);
CFeSem deliberately mirrors the non-constant part of KitCgSlotInfo:
{lang_type, cg_type, lang_flags}. That keeps parser tagging and CG retagging
the same shape without making the pp pay for parser-only facts.
End-State Data Flow
source bytes
-> lex_next(Lexer*, CFeCell*) writes lexeme view
-> pp source/replay stack reads/writes lexeme view only
-> pp_next_parse/raw(Pp*, CFeCell*) fills caller-owned cell
-> cpp / cc -E / KIT_PP_DRAIN drains lexeme cells; no parser
-> ParserInput ring parser-owned cells
-> primary resolution optional in-place semantic tag
-> kit_cg_* push/op + retag CG stack owns values
-> CgTarget -> NativeTarget -> MC backend seam unchanged
The fusion point is the front-half representation and handoff: eliminate
by-value token returns and duplicate token shapes, preserve pp drainability, and
do not create a second value representation beside KitCg.
Correctness Checklist
- A pp drain can run with no parser and never observes
CFE_SEM_*. - Macro bodies and replay buffers are immutable lexeme-cell streams.
- Parser tagging is limited to caller-owned parser cells.
- Tagged cells are never recorded for replay or passed back to pp helpers.
- Text and location materialization work after source pop and macro replay.
- String-literal fusion produces a single lexeme cell without a second token type.
- Parser current/lookahead is a three-cell ring with index rotation;
advancedoes not copy or move liveCFeCellvalues. TF_AT_BOLis sufficient for parser-feed directive recognition; raw mode keepsTOK_NEWLINEfor-E.- Macro replacement frames disable their owner only while the replacement list is being rescanned, and re-enable it exactly when that frame is popped.
- A token skipped because its macro is disabled is returned with
TF_NO_EXPAND, and that bit survives argument pre-expansion, substitution, replay, and later rescans. - A function-like macro name not followed by
(is not markedTF_NO_EXPAND; it can expand if a later rescan sees a(supplied by another macro. - Pasted identifier tokens start with
TF_NO_EXPANDclear and are judged by the active disabled-frame stack during normal rescan. - Identifier resolution does one
Sym-keyed lookup after keyword classification. - Literal decoding is lazy and occurs before
u.textis overwritten. - The CG stack, not
CFeCell, owns expression lifetime, constants, liveness, and C type/value flags after primary lowering. - No lexer/pp header includes C parser or CG internals.
- No VLAs and no global state.