Skip to main content

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;