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::{
43    Lexeme, LexemeInterner, LiteralKind, SourceSpan, Token, TokenKind,
44};
45use codehelion_core::ir::{
46    ByteRange, IR_SCHEMA_VERSION, IrNode, MAX_IR_DEPTH, Shape, StructuralFrontend, SyntaxIrFile,
47};
48use tree_sitter::{Node, Parser};
49
50/// Version tag of this structural frontend, used as a fingerprint input. Bump
51/// it whenever a change alters the token stream or the IR tree for unchanged
52/// input.
53pub const STRUCTURAL_FRONTEND_VERSION: &str = "c-ir-v1";
54
55/// Grammar kinds lexed as one atomic token: the walker emits a single token
56/// for the whole node and never descends into its children (escape sequences,
57/// raw-string delimiters). `raw_string_literal` is C++-only; listing it here
58/// is harmless for C, whose grammar never produces that kind.
59const ATOMIC_TOKEN_KINDS: &[&str] = &[
60    "string_literal",
61    "char_literal",
62    "system_lib_string",
63    "raw_string_literal",
64];
65
66/// Grammar kind of comment nodes, dropped from the token stream entirely.
67const COMMENT_KIND: &str = "comment";
68
69/// How one CST node maps onto the IR.
70#[derive(Debug, Clone)]
71pub enum Mapping {
72    /// Emit a node with this shape and recurse into children.
73    Emit(Shape),
74    /// Emit a [`Shape::Native`] node under this grammar kind name.
75    Native(&'static str),
76    /// A statement wrapper: unwrap when the inner expression emits a node.
77    ExprStmt,
78    /// A parser error region: emit [`Shape::Error`] and record its range.
79    Error,
80    /// No node of its own; children are still visited.
81    Transparent,
82}
83
84/// The per-language part of a C-family structural frontend.
85///
86/// The shared walker owns tokenisation, error recovery and IR assembly; an
87/// implementation of this trait supplies the language's node-mapping table.
88/// The provided methods cover the whole C family — the C++-only grammar kinds
89/// they mention never occur in C trees — so implementations rarely override
90/// them.
91pub trait IrMapping {
92    /// Decide how one CST node maps onto the IR. This table is the
93    /// granularity contract of a frontend; changing it changes fingerprint
94    /// input, which invalidates every result recorded under the old table.
95    /// Before the first release that is settled by rescanning rather than by
96    /// raising the frontend version, which stays at v1.
97    fn classify(&self, node: &Node<'_>) -> Mapping;
98
99    /// Recover the declared name of a node that emits a named shape.
100    fn node_name<'s>(&self, node: &Node<'_>, source: &'s str) -> Option<&'s str> {
101        c_family_node_name(node, source)
102    }
103
104    /// Map one CST leaf onto the shared [`TokenKind`] vocabulary.
105    fn token_kind(&self, kind: &str, is_named: bool, text: &str) -> TokenKind {
106        classify_token(kind, is_named, text)
107    }
108}
109
110/// The C node-mapping table, also the fallthrough table of the C++ frontend.
111///
112/// Everything not listed — type plumbing, patterns and interior expression
113/// detail — is transparent: no node, children visited.
114#[must_use]
115pub fn classify_c(node: &Node<'_>) -> Mapping {
116    match node.kind() {
117        "function_definition" => Mapping::Emit(Shape::Function),
118        "compound_statement" => Mapping::Emit(Shape::Block),
119        "for_statement" | "while_statement" | "do_statement" => Mapping::Emit(Shape::Loop),
120        // Each `else if` is its own `if_statement` inside the transparent
121        // `else_clause`, so a chain nests as Branch nodes without special
122        // handling.
123        "if_statement" => Mapping::Emit(Shape::Branch),
124        "switch_statement" => Mapping::Emit(Shape::Match),
125        // `case_statement` covers `case X:` and `default:` alike.
126        "case_statement" => Mapping::Emit(Shape::MatchArm),
127        "call_expression" => Mapping::Emit(Shape::Call),
128        // The grammar folds compound assignment into `assignment_expression`.
129        "assignment_expression" => Mapping::Emit(Shape::Assign),
130        "declaration" => Mapping::Emit(Shape::VarDecl),
131        "return_statement" => Mapping::Emit(Shape::Return),
132        "break_statement" => Mapping::Emit(Shape::Break),
133        "continue_statement" => Mapping::Emit(Shape::Continue),
134        "expression_statement" => Mapping::ExprStmt,
135        "preproc_def" | "preproc_function_def" => Mapping::Emit(Shape::MacroDef),
136        // `goto` has no cross-language shape; `labeled_statement` stays
137        // transparent so the labelled statement itself is still mapped.
138        "goto_statement" => Mapping::Native("goto_statement"),
139        // Conditional compilation is kept unexpanded: both branches stay in
140        // the IR under native nodes.
141        "preproc_if" | "preproc_ifdef" | "preproc_else" | "preproc_elif" | "preproc_elifdef" => {
142            Mapping::Native(node.kind())
143        }
144        "struct_specifier" | "union_specifier" | "enum_specifier" => record_mapping(node),
145        "ERROR" => Mapping::Error,
146        _ => Mapping::Transparent,
147    }
148}
149
150/// [`Shape::Record`] when a record specifier carries a body; transparent in
151/// type-reference position (`struct foo x;` names a type, it defines
152/// nothing).
153#[must_use]
154pub fn record_mapping(node: &Node<'_>) -> Mapping {
155    if node.child_by_field_name("body").is_some() {
156        Mapping::Emit(Shape::Record)
157    } else {
158        Mapping::Transparent
159    }
160}
161
162/// The shared C-family token classification.
163///
164/// Grammar kind names drive the mapping; anonymous (non-named) tokens are
165/// keywords when their kind is purely alphabetic and punctuation otherwise
166/// (operators, delimiters, and directive introducers like `#include`). Named
167/// leaves outside the known kinds — notably the opaque `preproc_arg`
168/// replacement text — classify as [`TokenKind::Unknown`].
169#[must_use]
170pub fn classify_token(kind: &str, is_named: bool, text: &str) -> TokenKind {
171    match kind {
172        "identifier"
173        | "field_identifier"
174        | "type_identifier"
175        | "statement_identifier"
176        | "namespace_identifier" => TokenKind::Identifier,
177        // Type-naming leaves (`int`, `unsigned long`) and the C++ keyword
178        // leaves the grammar exposes as named nodes (`auto`, `this`) are
179        // lexically keywords, matching the Fast lexer's classification.
180        "primitive_type" | "sized_type_specifier" | "auto" | "this" => TokenKind::Keyword,
181        // `null` covers both spellings: `nullptr` is a keyword while `NULL`
182        // is a macro identifier, matching the Fast lexer.
183        "null" => {
184            if text == "nullptr" {
185                TokenKind::Keyword
186            } else {
187                TokenKind::Identifier
188            }
189        }
190        "number_literal" => TokenKind::Literal(number_literal_kind(text)),
191        "string_literal" | "system_lib_string" | "raw_string_literal" => {
192            TokenKind::Literal(LiteralKind::String)
193        }
194        "char_literal" => TokenKind::Literal(LiteralKind::Char),
195        "true" | "false" => TokenKind::Literal(LiteralKind::Bool),
196        _ if !is_named => {
197            if !kind.is_empty() && kind.chars().all(|c| c.is_ascii_alphabetic() || c == '_') {
198                TokenKind::Keyword
199            } else {
200                TokenKind::Punctuation
201            }
202        }
203        _ => TokenKind::Unknown,
204    }
205}
206
207/// Float/integer split for a `number_literal`, mirroring the Fast lexer's
208/// rule: a decimal point, a decimal (`e`) or hexadecimal (`p`) exponent, or a
209/// float suffix makes it a float.
210fn number_literal_kind(text: &str) -> LiteralKind {
211    let hex = text.starts_with("0x") || text.starts_with("0X");
212    let float = text.contains('.')
213        || if hex {
214            text.contains(['p', 'P'])
215        } else {
216            text.contains(['e', 'E']) || text.ends_with(['f', 'F'])
217        };
218    if float {
219        LiteralKind::Float
220    } else {
221        LiteralKind::Integer
222    }
223}
224
225/// Recover a declared name where the C-family grammars provide one: the
226/// `name` field of record specifiers and macro definitions, or the identifier
227/// buried in a function definition's declarator chain.
228#[must_use]
229pub fn c_family_node_name<'s>(node: &Node<'_>, source: &'s str) -> Option<&'s str> {
230    match node.kind() {
231        "function_definition" => {
232            declarator_identifier(node.child_by_field_name("declarator")?, source)
233        }
234        "struct_specifier"
235        | "union_specifier"
236        | "enum_specifier"
237        | "class_specifier"
238        | "preproc_def"
239        | "preproc_function_def" => node_text(&node.child_by_field_name("name")?, source),
240        _ => None,
241    }
242}
243
244/// Strip a declarator down to the declared identifier: through pointer,
245/// function, parenthesized and reference declarators, and through the `name`
246/// field of C++ qualified identifiers. `None` when no identifier is
247/// recoverable.
248fn declarator_identifier<'s>(declarator: Node<'_>, source: &'s str) -> Option<&'s str> {
249    let mut current = declarator;
250    loop {
251        match current.kind() {
252            "identifier" | "field_identifier" | "type_identifier" | "operator_name"
253            | "destructor_name" => return node_text(&current, source),
254            "qualified_identifier" => current = current.child_by_field_name("name")?,
255            "pointer_declarator"
256            | "function_declarator"
257            | "parenthesized_declarator"
258            | "reference_declarator" => {
259                current = current
260                    .child_by_field_name("declarator")
261                    .or_else(|| current.named_child(0))?;
262            }
263            _ => return None,
264        }
265    }
266}
267
268/// The source text a node covers; empty for a malformed range.
269fn node_text<'s>(node: &Node<'_>, source: &'s str) -> Option<&'s str> {
270    source.get(node.start_byte()..node.end_byte())
271}
272
273/// The byte range a CST node covers.
274fn node_range(node: &Node<'_>) -> ByteRange {
275    ByteRange {
276        start: node.start_byte(),
277        end: node.end_byte(),
278    }
279}
280
281/// Parse `source` with `grammar` and map the tree onto the IR under
282/// `mapping`. This is the shared entry point of the C-family structural
283/// frontends.
284///
285/// When the parser cannot be set up or returns no tree, the result degrades
286/// to an empty token stream and node tree with one error range spanning the
287/// whole file. CST-depth exhaustion instead emits an `Error` leaf over the
288/// unvisited subtree, so the recovered IR stays bounded.
289#[must_use]
290pub fn parse_to_ir(
291    source: &str,
292    grammar: &tree_sitter::Language,
293    mapping: &dyn IrMapping,
294    language: Language,
295    frontend_version: &'static str,
296) -> SyntaxIrFile {
297    let mut parser = Parser::new();
298    let tree = if parser.set_language(grammar).is_ok() {
299        parser.parse(source, None)
300    } else {
301        None
302    };
303    let Some(tree) = tree else {
304        return SyntaxIrFile {
305            language,
306            frontend_version,
307            ir_schema_version: IR_SCHEMA_VERSION,
308            tokens: Vec::new(),
309            roots: Vec::new(),
310            diagnostics: Vec::new(),
311            error_ranges: vec![ByteRange {
312                start: 0,
313                end: source.len(),
314            }],
315            depth_truncated: false,
316            test_module: false,
317        };
318    };
319
320    let root = tree.root_node();
321    let mut builder = IrBuilder::new(source, mapping);
322    builder.collect_tokens(root);
323
324    let mut roots = Vec::new();
325    // The root (`translation_unit`) classifies as transparent, so visiting it
326    // fills `roots` with the file's top-level nodes.
327    builder.visit(root, &mut roots, 0);
328
329    builder
330        .error_ranges
331        .sort_unstable_by_key(|range| (range.start, range.end));
332    builder.error_ranges.dedup();
333
334    SyntaxIrFile {
335        language,
336        frontend_version,
337        ir_schema_version: IR_SCHEMA_VERSION,
338        tokens: builder.tokens,
339        roots,
340        // Lexical diagnostics are a Fast-lexer concept; the structural
341        // frontend reports problems through `error_ranges` only.
342        diagnostics: Vec::new(),
343        error_ranges: builder.error_ranges,
344        depth_truncated: builder.depth_truncated,
345        test_module: false,
346    }
347}
348
349/// Accumulates the token stream and IR tree for one file.
350struct IrBuilder<'s, 'm> {
351    source: &'s str,
352    mapping: &'m dyn IrMapping,
353    interner: LexemeInterner,
354    tokens: Vec<Token>,
355    /// Byte start of each emitted token, for mapping node byte ranges onto
356    /// token index ranges by binary search.
357    token_starts: Vec<usize>,
358    /// Byte offset of the start of each source line.
359    line_starts: Vec<usize>,
360    error_ranges: Vec<ByteRange>,
361    depth_truncated: bool,
362}
363
364impl<'s, 'm> IrBuilder<'s, 'm> {
365    fn new(source: &'s str, mapping: &'m dyn IrMapping) -> Self {
366        let mut line_starts = vec![0];
367        for (index, byte) in source.bytes().enumerate() {
368            if byte == b'\n' {
369                line_starts.push(index + 1);
370            }
371        }
372        Self {
373            source,
374            mapping,
375            interner: LexemeInterner::new(),
376            tokens: Vec::new(),
377            token_starts: Vec::new(),
378            line_starts,
379            error_ranges: Vec::new(),
380            depth_truncated: false,
381        }
382    }
383
384    /// Walk every CST leaf in source order, dropping comments, emitting
385    /// atomic literal nodes as single tokens, and recording zero-width
386    /// `missing` leaves (the parser's recovery insertions) as error ranges.
387    fn collect_tokens(&mut self, root: Node<'_>) {
388        let mut cursor = root.walk();
389        loop {
390            let node = cursor.node();
391            let kind = node.kind();
392            let descend = kind != COMMENT_KIND
393                && !ATOMIC_TOKEN_KINDS.contains(&kind)
394                && node.child_count() > 0;
395            if descend && cursor.goto_first_child() {
396                continue;
397            }
398            if !descend && kind != COMMENT_KIND {
399                if node.is_missing() {
400                    self.error_ranges.push(node_range(&node));
401                } else if node.end_byte() > node.start_byte() {
402                    self.emit_token(&node);
403                }
404            }
405            loop {
406                if cursor.goto_next_sibling() {
407                    break;
408                }
409                if !cursor.goto_parent() {
410                    return;
411                }
412            }
413        }
414    }
415
416    fn emit_token(&mut self, node: &Node<'_>) {
417        let start_byte = node.start_byte();
418        let end_byte = node.end_byte();
419        let text = node_text(node, self.source).unwrap_or("");
420        let kind = self.mapping.token_kind(node.kind(), node.is_named(), text);
421        let (start_line, start_column) = self.line_column(start_byte);
422        let text = self.interner.intern(text);
423        self.token_starts.push(start_byte);
424        self.tokens.push(Token {
425            kind,
426            text,
427            span: SourceSpan {
428                start_byte,
429                end_byte,
430                start_line,
431                start_column,
432            },
433        });
434    }
435
436    /// 1-based line and character column of a byte offset.
437    fn line_column(&self, byte: usize) -> (u32, u32) {
438        let line_index = self
439            .line_starts
440            .partition_point(|&start| start <= byte)
441            .saturating_sub(1);
442        let line_start = self.line_starts.get(line_index).copied().unwrap_or(0);
443        let column_chars = self
444            .source
445            .get(line_start..byte)
446            .map_or(0, |prefix| prefix.chars().count());
447        (
448            u32::try_from(line_index + 1).unwrap_or(u32::MAX),
449            u32::try_from(column_chars + 1).unwrap_or(u32::MAX),
450        )
451    }
452
453    /// Map one CST node onto the IR, appending zero or more nodes to `out`.
454    fn visit(&mut self, cst: Node<'_>, out: &mut Vec<IrNode>, depth: usize) {
455        if depth >= MAX_IR_DEPTH {
456            self.emit_depth_error(cst, out);
457            return;
458        }
459
460        match self.mapping.classify(&cst) {
461            Mapping::Emit(shape) => {
462                let name = self
463                    .mapping
464                    .node_name(&cst, self.source)
465                    .map(|text| self.interner.intern(text));
466                let node = self.build_node(shape, name, cst, depth);
467                out.push(node);
468            }
469            Mapping::Native(kind) => {
470                let shape = Shape::Native(self.interner.intern(kind));
471                let node = self.build_node(shape, None, cst, depth);
472                out.push(node);
473            }
474            Mapping::ExprStmt => {
475                if self.inner_expression_emits(cst) {
476                    // The inner expression's own node is the statement.
477                    self.visit_children(cst, out, depth);
478                } else {
479                    let node = self.build_node(Shape::ExprStmt, None, cst, depth);
480                    out.push(node);
481                }
482            }
483            Mapping::Error => {
484                self.error_ranges.push(node_range(&cst));
485                // Recurse anyway: tree-sitter wraps intact regions in error
486                // nodes, and those descendants must still be recovered.
487                let node = self.build_node(Shape::Error, None, cst, depth);
488                out.push(node);
489            }
490            Mapping::Transparent => self.visit_children(cst, out, depth),
491        }
492    }
493
494    fn visit_children(&mut self, cst: Node<'_>, out: &mut Vec<IrNode>, depth: usize) {
495        let mut cursor = cst.walk();
496        let children: Vec<Node<'_>> = cst.named_children(&mut cursor).collect();
497        for child in children {
498            self.visit(child, out, depth + 1);
499        }
500    }
501
502    /// Build an [`IrNode`] for `cst`, visiting its children first.
503    fn build_node(
504        &mut self,
505        shape: Shape,
506        name: Option<Lexeme>,
507        cst: Node<'_>,
508        depth: usize,
509    ) -> IrNode {
510        let mut children = Vec::new();
511        self.visit_children(cst, &mut children, depth);
512        let range = node_range(&cst);
513        IrNode {
514            shape,
515            name,
516            token_start: self.token_index_at(range.start),
517            token_end: self.token_index_at(range.end),
518            range,
519            children,
520        }
521    }
522
523    /// Preserve an unvisited CST subtree as recoverable truncation data.
524    fn emit_depth_error(&mut self, cst: Node<'_>, out: &mut Vec<IrNode>) {
525        let range = node_range(&cst);
526        self.depth_truncated = true;
527        self.error_ranges.push(range);
528        out.push(IrNode {
529            shape: Shape::Error,
530            name: None,
531            token_start: self.token_index_at(range.start),
532            token_end: self.token_index_at(range.end),
533            range,
534            children: Vec::new(),
535        });
536    }
537
538    /// Index of the first emitted token starting at or after `byte`.
539    fn token_index_at(&self, byte: usize) -> usize {
540        self.token_starts.partition_point(|&start| start < byte)
541    }
542
543    /// Whether a statement's inner expression maps to a shape of its own,
544    /// making the `expression_statement` wrapper redundant.
545    fn inner_expression_emits(&self, stmt: Node<'_>) -> bool {
546        let mut cursor = stmt.walk();
547        stmt.named_children(&mut cursor)
548            .find(|child| child.kind() != COMMENT_KIND)
549            .is_some_and(|inner| {
550                matches!(
551                    self.mapping.classify(&inner),
552                    Mapping::Emit(_) | Mapping::Native(_) | Mapping::Error
553                )
554            })
555    }
556}
557
558/// The C node-mapping table as an [`IrMapping`].
559#[derive(Debug, Clone, Copy, Default)]
560pub struct CMapping;
561
562impl IrMapping for CMapping {
563    fn classify(&self, node: &Node<'_>) -> Mapping {
564        classify_c(node)
565    }
566}
567
568/// The C Structural-mode frontend.
569#[derive(Debug, Clone, Copy, Default)]
570pub struct CStructuralFrontend;
571
572impl StructuralFrontend for CStructuralFrontend {
573    fn language(&self) -> Language {
574        Language::C
575    }
576
577    fn frontend_version(&self) -> &'static str {
578        STRUCTURAL_FRONTEND_VERSION
579    }
580
581    fn parse(&self, source: &str) -> SyntaxIrFile {
582        let grammar = tree_sitter::Language::from(tree_sitter_c::LANGUAGE);
583        parse_to_ir(
584            source,
585            &grammar,
586            &CMapping,
587            Language::C,
588            STRUCTURAL_FRONTEND_VERSION,
589        )
590    }
591}
592
593#[cfg(test)]
594#[allow(clippy::unwrap_used, clippy::expect_used)]
595mod tests;