codehelion_frontend_c/ir.rs
1//! Structural-mode C frontend and the shared C-family CST walking machinery.
2//!
3//! The file is parsed with the tree-sitter C grammar and the resulting
4//! error-tolerant concrete syntax tree is mapped onto the language-neutral
5//! [`SyntaxIrFile`]: a comment-free token stream plus a tree of [`IrNode`]s
6//! built from structurally meaningful grammar nodes only. Interior expression
7//! detail (member accesses, casts, non-assignment binary operators,
8//! parentheses) stays token-only under the nearest ancestor node. Statement
9//! wrappers add no node of their own when their inner expression already maps
10//! to a shape: `f();` is one [`Shape::Call`] node, not an `ExprStmt(Call)`
11//! pair.
12//!
13//! The walking machinery is language-parameterized through [`IrMapping`] and
14//! shared with the C++ structural frontend, which layers its own mapping
15//! table on top of the C one (`cpp → c → core` is the fixed dependency
16//! direction, so the shared code lives here).
17//!
18//! # Granularity decisions specific to C
19//!
20//! - `declaration` maps to [`Shape::VarDecl`] uniformly — locals, file-scope
21//! variables and function prototypes alike. C declarations have no lexical
22//! marker separating those roles, and prototype-vs-variable disambiguation
23//! is a semantic judgement Structural mode does not make.
24//! - Macro invocations are structurally indistinguishable from
25//! `call_expression` (the grammar has no separate node for them), so they
26//! surface as [`Shape::Call`]; [`Shape::MacroCall`] is never produced.
27//! - Preprocessor conditionals (`preproc_if`, `preproc_ifdef`, ...) become
28//! [`Shape::Native`] nodes and both branches stay in the IR unexpanded.
29//! `#include` and other non-defining directives produce tokens only.
30//! - Macro replacement text is a single opaque `preproc_arg` leaf in the
31//! grammar; it becomes one [`TokenKind::Unknown`] token.
32//!
33//! # Degradation
34//!
35//! Malformed regions and CST-depth truncation become [`Shape::Error`] nodes
36//! plus byte ranges in [`SyntaxIrFile::error_ranges`]. If the parser itself
37//! cannot be set up (grammar version mismatch) or returns no tree, the file
38//! degrades to an empty token stream and node tree with one error range
39//! spanning the whole file.
40
41use codehelion_core::discovery::Language;
42use codehelion_core::frontend::{IrAssembly, Lexeme, LiteralKind, TokenKind};
43use codehelion_core::ir::{
44 ByteRange, IR_SCHEMA_VERSION, IrNode, MAX_IR_DEPTH, Shape, Signature, StructuralFrontend,
45 SyntaxIrFile, canonicalize_signatures,
46};
47use tree_sitter::{Node, Parser};
48
49mod emit;
50mod generics;
51mod navigate;
52mod signature;
53
54use navigate::{declarator_identifier, node_range, node_text};
55use signature::c_family_signature;
56
57use crate::declarator::canonical_declared_name;
58
59/// Version tag of this structural frontend, used as a fingerprint input. Bump
60/// it whenever a change alters the token stream or the IR tree for unchanged
61/// input.
62pub const STRUCTURAL_FRONTEND_VERSION: &str = "c-ir-v2";
63
64/// Grammar kinds lexed as one atomic token: the walker emits a single token
65/// for the whole node and never descends into its children (escape sequences,
66/// raw-string delimiters). `raw_string_literal` is C++-only; listing it here
67/// is harmless for C, whose grammar never produces that kind.
68const ATOMIC_TOKEN_KINDS: &[&str] = &[
69 "string_literal",
70 "char_literal",
71 "system_lib_string",
72 "raw_string_literal",
73];
74
75/// Grammar kind of comment nodes, dropped from the token stream entirely.
76const COMMENT_KIND: &str = "comment";
77
78/// How one CST node maps onto the IR.
79#[derive(Debug, Clone)]
80pub enum Mapping {
81 /// Emit a node with this shape and recurse into children.
82 Emit(Shape),
83 /// Emit a [`Shape::Native`] node under this grammar kind name.
84 Native(&'static str),
85 /// A statement wrapper: unwrap when the inner expression emits a node.
86 ExprStmt,
87 /// A parser error region: emit [`Shape::Error`] and record its range.
88 Error,
89 /// No node of its own; children are still visited.
90 Transparent,
91}
92
93/// The per-language part of a C-family structural frontend.
94///
95/// The shared walker owns tokenisation, error recovery and IR assembly; an
96/// implementation of this trait supplies the language's node-mapping table.
97/// The provided methods cover the whole C family — the C++-only grammar kinds
98/// they mention never occur in C trees — so implementations rarely override
99/// them.
100pub trait IrMapping {
101 /// Decide how one CST node maps onto the IR. This table is the
102 /// granularity contract of a frontend; changing it changes fingerprint
103 /// input, which invalidates every result recorded under the old table.
104 /// Such a change raises the structural frontend version, so results from
105 /// different tables never share a fingerprint space.
106 fn classify(&self, node: &Node<'_>) -> Mapping;
107
108 /// Recover the declared name of a node that emits a named shape.
109 fn node_name<'s>(&self, node: &Node<'_>, source: &'s str) -> Option<&'s str> {
110 c_family_node_name(node, source)
111 }
112
113 /// The dialect's reserved words: the same set the Fast lexer reads a
114 /// keyword off. [`classify_token`] reconciles the grammar's view of a leaf
115 /// with this set, so the two modes cannot disagree about what a reserved
116 /// word is.
117 fn keywords(&self) -> &'static [&'static str] {
118 crate::dialect::C.keywords
119 }
120
121 /// Map one CST leaf onto the shared [`TokenKind`] vocabulary.
122 fn token_kind(&self, kind: &str, is_named: bool, text: &str) -> TokenKind {
123 classify_token(kind, is_named, text, self.keywords())
124 }
125
126 /// Build the function/method signature side-table entry for this node.
127 fn signature(&self, node: &Node<'_>, source: &str, language: Language) -> Option<Signature> {
128 c_family_signature(node, source, language)
129 }
130}
131
132/// The C node-mapping table, also the fallthrough table of the C++ frontend.
133///
134/// Everything not listed — type plumbing, patterns and interior expression
135/// detail — is transparent: no node, children visited.
136#[must_use]
137pub fn classify_c(node: &Node<'_>) -> Mapping {
138 match node.kind() {
139 "function_definition" => Mapping::Emit(Shape::Function),
140 "compound_statement" => Mapping::Emit(Shape::Block),
141 "for_statement" | "while_statement" | "do_statement" => Mapping::Emit(Shape::Loop),
142 // Each `else if` is its own `if_statement` inside the transparent
143 // `else_clause`, so a chain nests as Branch nodes without special
144 // handling.
145 "if_statement" => Mapping::Emit(Shape::Branch),
146 "switch_statement" => Mapping::Emit(Shape::Match),
147 // `case_statement` covers `case X:` and `default:` alike.
148 "case_statement" => Mapping::Emit(Shape::MatchArm),
149 "call_expression" => Mapping::Emit(Shape::Call),
150 // The grammar folds compound assignment into `assignment_expression`.
151 "assignment_expression" => Mapping::Emit(Shape::Assign),
152 "declaration" => Mapping::Emit(Shape::VarDecl),
153 "return_statement" => Mapping::Emit(Shape::Return),
154 "break_statement" => Mapping::Emit(Shape::Break),
155 "continue_statement" => Mapping::Emit(Shape::Continue),
156 "expression_statement" => Mapping::ExprStmt,
157 "preproc_def" | "preproc_function_def" => Mapping::Emit(Shape::MacroDef),
158 // `goto` has no cross-language shape; `labeled_statement` stays
159 // transparent so the labelled statement itself is still mapped.
160 "goto_statement" => Mapping::Native("goto_statement"),
161 // Conditional compilation is kept unexpanded: both branches stay in
162 // the IR under native nodes. Each kind names itself rather than being
163 // read back off the node, because a node's kind borrows from the tree
164 // while a native node's name outlives it.
165 "preproc_if" => Mapping::Native("preproc_if"),
166 "preproc_ifdef" => Mapping::Native("preproc_ifdef"),
167 "preproc_else" => Mapping::Native("preproc_else"),
168 "preproc_elif" => Mapping::Native("preproc_elif"),
169 "preproc_elifdef" => Mapping::Native("preproc_elifdef"),
170 "struct_specifier" | "union_specifier" | "enum_specifier" => record_mapping(node),
171 "ERROR" => Mapping::Error,
172 _ => Mapping::Transparent,
173 }
174}
175
176/// [`Shape::Record`] when a record specifier carries a body; transparent in
177/// type-reference position (`struct foo x;` names a type, it defines
178/// nothing).
179#[must_use]
180pub fn record_mapping(node: &Node<'_>) -> Mapping {
181 if node.child_by_field_name("body").is_some() {
182 Mapping::Emit(Shape::Record)
183 } else {
184 Mapping::Transparent
185 }
186}
187
188/// The shared C-family token classification.
189///
190/// Grammar kind names drive the mapping; anonymous (non-named) tokens are
191/// keywords when their kind is purely alphabetic and punctuation otherwise
192/// (operators, delimiters, and directive introducers like `#include`). Named
193/// leaves outside the known kinds — notably the opaque `preproc_arg`
194/// replacement text — classify as [`TokenKind::Unknown`].
195///
196/// A leaf the grammar calls an identifier is then checked against `keywords`,
197/// the dialect's reserved words. Both C-family grammars spell words their
198/// syntax does not model as plain identifier leaves — `static_cast<T>(x)`
199/// parses as a call whose callee is the identifier `static_cast`, and a C
200/// spelling the grammar predates (`_Bool`, `typeof`, `static_assert`) reaches
201/// the walker the same way. Left at that, the structural token stream
202/// disagrees with the Fast lexer, which reads the same word off the same
203/// keyword set — and a keyword read as an identifier is then taken for a
204/// callee name, so a cast or a type enters the API-call profile as though the
205/// code called something. Nothing legitimate is caught: a reserved word cannot
206/// also be a declared name, so an identifier leaf spelling one is the
207/// grammar's artefact and not the program's.
208#[must_use]
209pub fn classify_token(kind: &str, is_named: bool, text: &str, keywords: &[&str]) -> TokenKind {
210 match classify_grammar_kind(kind, is_named, text) {
211 TokenKind::Identifier if keywords.contains(&text) => TokenKind::Keyword,
212 other => other,
213 }
214}
215
216/// Map a leaf onto a token kind from its grammar kind alone.
217fn classify_grammar_kind(kind: &str, is_named: bool, text: &str) -> TokenKind {
218 match kind {
219 "identifier"
220 | "field_identifier"
221 | "type_identifier"
222 | "statement_identifier"
223 | "namespace_identifier" => TokenKind::Identifier,
224 // Type-naming leaves (`int`, `unsigned long`) and the C++ keyword
225 // leaves the grammar exposes as named nodes (`auto`, `this`) are
226 // lexically keywords, matching the Fast lexer's classification.
227 //
228 // `true` and `false` belong here for the same reason: they are
229 // reserved words of both dialects and the Fast lexer reads them off
230 // the same keyword set. Calling them boolean literals instead would
231 // hand the shared literal normalization a difference it is meant to
232 // erase, so two units disagreeing only in a boolean constant would
233 // normalize alike in Structural mode while Fast mode kept them apart.
234 "primitive_type" | "sized_type_specifier" | "auto" | "this" | "true" | "false" => {
235 TokenKind::Keyword
236 }
237 // `null` covers both spellings: `nullptr` is a keyword while `NULL`
238 // is a macro identifier, matching the Fast lexer.
239 "null" => {
240 if text == "nullptr" {
241 TokenKind::Keyword
242 } else {
243 TokenKind::Identifier
244 }
245 }
246 "number_literal" => TokenKind::Literal(number_literal_kind(text)),
247 "string_literal" | "system_lib_string" | "raw_string_literal" => {
248 TokenKind::Literal(LiteralKind::String)
249 }
250 "char_literal" => TokenKind::Literal(LiteralKind::Char),
251 _ if !is_named => {
252 if !kind.is_empty() && kind.chars().all(|c| c.is_ascii_alphabetic() || c == '_') {
253 TokenKind::Keyword
254 } else {
255 TokenKind::Punctuation
256 }
257 }
258 _ => TokenKind::Unknown,
259 }
260}
261
262/// Float/integer split for a `number_literal`, mirroring the Fast lexer's
263/// rule: a decimal point or a decimal (`e`) or hexadecimal (`p`) exponent in
264/// the numeric part makes it a float. A user-defined suffix, which starts at
265/// `_`, does not take part.
266fn number_literal_kind(text: &str) -> LiteralKind {
267 let text = text.split('_').next().unwrap_or(text);
268 let hex = text.starts_with("0x") || text.starts_with("0X");
269 let float = text.contains('.')
270 || if hex {
271 text.contains(['p', 'P'])
272 } else {
273 text.contains(['e', 'E'])
274 };
275 if float {
276 LiteralKind::Float
277 } else {
278 LiteralKind::Integer
279 }
280}
281
282/// Recover a declared name where the C-family grammars provide one: the
283/// `name` field of record specifiers and macro definitions, or the identifier
284/// buried in a function definition's declarator chain.
285#[must_use]
286pub fn c_family_node_name<'s>(node: &Node<'_>, source: &'s str) -> Option<&'s str> {
287 match node.kind() {
288 "function_definition" => {
289 declarator_identifier(node.child_by_field_name("declarator")?, source)
290 }
291 "struct_specifier"
292 | "union_specifier"
293 | "enum_specifier"
294 | "class_specifier"
295 | "preproc_def"
296 | "preproc_function_def" => node_text(&node.child_by_field_name("name")?, source),
297 _ => None,
298 }
299}
300
301/// Parse `source` with `grammar` and map the tree onto the IR under
302/// `mapping`. This is the shared entry point of the C-family structural
303/// frontends.
304///
305/// When the parser cannot be set up or returns no tree, the result degrades
306/// to an empty token stream and node tree with one error range spanning the
307/// whole file. CST-depth exhaustion instead emits an `Error` leaf over the
308/// unvisited subtree, so the recovered IR stays bounded.
309#[must_use]
310pub fn parse_to_ir(
311 source: &str,
312 grammar: &tree_sitter::Language,
313 mapping: &dyn IrMapping,
314 language: Language,
315 frontend_version: &'static str,
316) -> SyntaxIrFile {
317 let mut parser = Parser::new();
318 let tree = if parser.set_language(grammar).is_ok() {
319 parser.parse(source, None)
320 } else {
321 None
322 };
323 let Some(tree) = tree else {
324 return SyntaxIrFile {
325 language,
326 frontend_version,
327 ir_schema_version: IR_SCHEMA_VERSION,
328 tokens: Vec::new(),
329 signatures: Vec::new(),
330 roots: Vec::new(),
331 diagnostics: Vec::new(),
332 error_ranges: vec![ByteRange {
333 start: 0,
334 end: source.len(),
335 }],
336 depth_truncated: false,
337 test_module: false,
338 };
339 };
340
341 let root = tree.root_node();
342 let mut builder = IrBuilder::new(source, mapping, language);
343 builder.collect_tokens(root);
344
345 let mut roots = Vec::new();
346 // The root (`translation_unit`) classifies as transparent, so visiting it
347 // fills `roots` with the file's top-level nodes.
348 builder.visit(root, &mut roots, 0);
349
350 let signatures = canonicalize_signatures(builder.signatures);
351 let assembled = builder.assembly.finish();
352
353 SyntaxIrFile {
354 language,
355 frontend_version,
356 ir_schema_version: IR_SCHEMA_VERSION,
357 tokens: assembled.tokens,
358 signatures,
359 roots,
360 // Lexical diagnostics are a Fast-lexer concept; the structural
361 // frontend reports problems through `error_ranges` only.
362 diagnostics: Vec::new(),
363 error_ranges: assembled.error_ranges,
364 depth_truncated: assembled.depth_truncated,
365 test_module: false,
366 }
367}
368
369/// Accumulates the token stream and IR tree for one file.
370///
371/// Everything that does not read the tree-sitter CST — interning, line
372/// mapping, byte-to-token lookup, depth-budget recovery — is delegated to the
373/// shared [`IrAssembly`], so those behaviours cannot drift from the other
374/// languages.
375struct IrBuilder<'s, 'm> {
376 assembly: IrAssembly<'s>,
377 mapping: &'m dyn IrMapping,
378 language: Language,
379 signatures: Vec<(ByteRange, Signature)>,
380}
381
382impl<'s, 'm> IrBuilder<'s, 'm> {
383 fn new(source: &'s str, mapping: &'m dyn IrMapping, language: Language) -> Self {
384 Self {
385 assembly: IrAssembly::new(source),
386 mapping,
387 language,
388 signatures: Vec::new(),
389 }
390 }
391
392 /// Walk every CST leaf in source order, dropping comments, emitting
393 /// atomic literal nodes as single tokens, and recording zero-width
394 /// `missing` leaves (the parser's recovery insertions) as error ranges.
395 fn collect_tokens(&mut self, root: Node<'_>) {
396 let mut cursor = root.walk();
397 loop {
398 let node = cursor.node();
399 let kind = node.kind();
400 let descend = kind != COMMENT_KIND
401 && !ATOMIC_TOKEN_KINDS.contains(&kind)
402 && node.child_count() > 0;
403 if descend && cursor.goto_first_child() {
404 continue;
405 }
406 if !descend && kind != COMMENT_KIND {
407 if node.is_missing() {
408 self.assembly.record_error_range(node_range(&node));
409 } else if node.end_byte() > node.start_byte() {
410 self.emit_token(&node);
411 }
412 }
413 loop {
414 if cursor.goto_next_sibling() {
415 break;
416 }
417 if !cursor.goto_parent() {
418 return;
419 }
420 }
421 }
422 }
423
424 fn emit_token(&mut self, node: &Node<'_>) {
425 let text = node_text(node, self.assembly.source()).unwrap_or("");
426 let kind = self.mapping.token_kind(node.kind(), node.is_named(), text);
427 self.assembly
428 .push_token(kind, text, node.start_byte(), node.end_byte());
429 }
430
431 /// Map one CST node onto the IR, appending zero or more nodes to `out`.
432 fn visit(&mut self, cst: Node<'_>, out: &mut Vec<IrNode>, depth: usize) {
433 if depth >= MAX_IR_DEPTH {
434 self.emit_depth_error(cst, out);
435 return;
436 }
437
438 match self.mapping.classify(&cst) {
439 Mapping::Emit(shape) => {
440 let source = self.assembly.source();
441 let name = self
442 .mapping
443 .node_name(&cst, source)
444 .map(|text| self.assembly.intern(&canonical_declared_name(text)));
445 if matches!(shape, Shape::Function | Shape::Method)
446 && cst.kind() == "function_definition"
447 && let Some(signature) = self.mapping.signature(&cst, source, self.language)
448 {
449 self.signatures.push((node_range(&cst), signature));
450 }
451 let node = self.build_node(shape, name, cst, depth);
452 out.push(node);
453 }
454 Mapping::Native(kind) => {
455 let shape = Shape::Native(self.assembly.intern(kind));
456 let node = self.build_node(shape, None, cst, depth);
457 out.push(node);
458 }
459 Mapping::ExprStmt => {
460 if self.inner_expression_emits(cst) {
461 // The inner expression's own node is the statement.
462 self.visit_children(cst, out, depth);
463 } else {
464 let node = self.build_node(Shape::ExprStmt, None, cst, depth);
465 out.push(node);
466 }
467 }
468 Mapping::Error => {
469 self.assembly.record_error_range(node_range(&cst));
470 // Recurse anyway: tree-sitter wraps intact regions in error
471 // nodes, and those descendants must still be recovered.
472 let node = self.build_node(Shape::Error, None, cst, depth);
473 out.push(node);
474 }
475 Mapping::Transparent => self.visit_children(cst, out, depth),
476 }
477 }
478
479 fn visit_children(&mut self, cst: Node<'_>, out: &mut Vec<IrNode>, depth: usize) {
480 let mut cursor = cst.walk();
481 let children: Vec<Node<'_>> = cst.named_children(&mut cursor).collect();
482 for child in children {
483 self.visit(child, out, depth + 1);
484 }
485 }
486
487 /// Build an [`IrNode`] for `cst`, visiting its children first.
488 fn build_node(
489 &mut self,
490 shape: Shape,
491 name: Option<Lexeme>,
492 cst: Node<'_>,
493 depth: usize,
494 ) -> IrNode {
495 let mut children = Vec::new();
496 self.visit_children(cst, &mut children, depth);
497 let range = node_range(&cst);
498 let (token_start, token_end) = self.assembly.token_bounds(range);
499 IrNode {
500 shape,
501 name,
502 token_start,
503 token_end,
504 range,
505 children,
506 }
507 }
508
509 /// Preserve an unvisited CST subtree as recoverable truncation data.
510 fn emit_depth_error(&mut self, cst: Node<'_>, out: &mut Vec<IrNode>) {
511 let node = self.assembly.truncate_at_depth(node_range(&cst));
512 out.push(node);
513 }
514
515 /// Whether a statement's inner expression maps to a shape of its own,
516 /// making the `expression_statement` wrapper redundant.
517 fn inner_expression_emits(&self, stmt: Node<'_>) -> bool {
518 let mut cursor = stmt.walk();
519 stmt.named_children(&mut cursor)
520 .find(|child| child.kind() != COMMENT_KIND)
521 .is_some_and(|inner| {
522 matches!(
523 self.mapping.classify(&inner),
524 Mapping::Emit(_) | Mapping::Native(_) | Mapping::Error
525 )
526 })
527 }
528}
529
530/// The C node-mapping table as an [`IrMapping`].
531#[derive(Debug, Clone, Copy, Default)]
532pub struct CMapping;
533
534impl IrMapping for CMapping {
535 fn classify(&self, node: &Node<'_>) -> Mapping {
536 classify_c(node)
537 }
538}
539
540/// The C Structural-mode frontend.
541#[derive(Debug, Clone, Copy, Default)]
542pub struct CStructuralFrontend;
543
544impl StructuralFrontend for CStructuralFrontend {
545 fn language(&self) -> Language {
546 Language::C
547 }
548
549 fn frontend_version(&self) -> &'static str {
550 STRUCTURAL_FRONTEND_VERSION
551 }
552
553 fn parse(&self, source: &str) -> SyntaxIrFile {
554 let grammar = tree_sitter::Language::from(tree_sitter_c::LANGUAGE);
555 parse_to_ir(
556 source,
557 &grammar,
558 &CMapping,
559 Language::C,
560 STRUCTURAL_FRONTEND_VERSION,
561 )
562 }
563}
564
565#[cfg(test)]
566#[allow(clippy::unwrap_used, clippy::expect_used)]
567mod tests;