Skip to main content

codehelion_frontend_rust/
ir.rs

1//! Structural-mode Rust frontend: real-parser CST to Syntax-IR conversion.
2//!
3//! The file is parsed with `ra_ap_syntax` (rust-analyzer's error-tolerant
4//! parser) and the resulting lossless CST is mapped onto the language-neutral
5//! [`SyntaxIrFile`]: a comment- and whitespace-free token stream plus a tree
6//! of [`IrNode`]s built from structurally meaningful grammar nodes only.
7//! Interior expression detail (paths, literals, parentheses, non-assignment
8//! binary operators, field accesses) stays token-only under the nearest
9//! ancestor node, keeping the tree at the granularity structural comparison
10//! works on. Statement wrappers add no node of their own when their inner
11//! expression already maps to a shape: `f();` is one [`Shape::Call`] node,
12//! not an `ExprStmt(Call)` pair.
13//!
14//! Nothing is executed or expanded: macro definitions and invocations are
15//! recorded as nodes over their raw token trees. Malformed regions and
16//! CST-depth truncation become [`Shape::Error`] nodes plus byte ranges in
17//! [`SyntaxIrFile::error_ranges`].
18//! Delimiter nesting is checked with the nonrecursive lexer before the Rust
19//! parser constructs its CST, so excessive nesting also becomes explicit
20//! truncation data.
21
22use codehelion_core::discovery::Language;
23use codehelion_core::frontend::{
24    Lexeme, LexemeInterner, LiteralKind, SourceSpan, Token, TokenKind,
25};
26use codehelion_core::ir::{
27    ByteRange, IR_SCHEMA_VERSION, IrNode, MAX_IR_DEPTH, Shape, StructuralFrontend, SyntaxIrFile,
28};
29use ra_ap_syntax::{Edition, SourceFile, SyntaxKind, SyntaxNode};
30
31/// Version tag of this structural frontend, used as a fingerprint input. Bump
32/// it whenever a change alters the token stream or the IR tree for unchanged
33/// input.
34pub const STRUCTURAL_FRONTEND_VERSION: &str = "rust-ir-v1";
35
36/// Edition the parser assumes. Parsing is edition-tolerant enough for audit
37/// purposes; a wrong guess degrades to error ranges, never to a lost file.
38const PARSE_EDITION: Edition = Edition::CURRENT;
39
40/// Binary operator tokens that make a `BIN_EXPR` an assignment.
41const ASSIGN_OPS: &[SyntaxKind] = &[
42    SyntaxKind::EQ,
43    SyntaxKind::PLUSEQ,
44    SyntaxKind::MINUSEQ,
45    SyntaxKind::STAREQ,
46    SyntaxKind::SLASHEQ,
47    SyntaxKind::PERCENTEQ,
48    SyntaxKind::AMPEQ,
49    SyntaxKind::PIPEEQ,
50    SyntaxKind::CARETEQ,
51    SyntaxKind::SHLEQ,
52    SyntaxKind::SHREQ,
53];
54
55/// Return the source range that must not enter the recursive Rust CST parser.
56///
57/// The Rust lexer is nonrecursive and already treats comments and literals as
58/// atomic tokens, so delimiter text inside either cannot be mistaken for
59/// syntax here. The parser is only entered while this same nesting budget can
60/// still bound the structural IR it would produce.
61fn delimiter_nesting_overflow(tokens: &[Token], source_len: usize) -> Option<ByteRange> {
62    let mut expected_closers = Vec::new();
63    for token in tokens {
64        match token.text.as_str() {
65            "{" => expected_closers.push("}"),
66            "(" => expected_closers.push(")"),
67            "[" => expected_closers.push("]"),
68            "}" | ")" | "]" if expected_closers.last() == Some(&token.text.as_str()) => {
69                expected_closers.pop();
70            }
71            _ => continue,
72        }
73
74        if expected_closers.len() > MAX_IR_DEPTH {
75            return Some(ByteRange {
76                start: token.span.start_byte,
77                end: source_len,
78            });
79        }
80    }
81    None
82}
83
84/// Build the explicit partial result returned when preflight blocks CST
85/// construction for excessive delimiter nesting.
86fn depth_error_file(tokens: Vec<Token>, range: ByteRange) -> SyntaxIrFile {
87    let token_start = tokens.partition_point(|token| token.span.start_byte < range.start);
88    let token_end = tokens.partition_point(|token| token.span.start_byte < range.end);
89    SyntaxIrFile {
90        language: Language::Rust,
91        frontend_version: STRUCTURAL_FRONTEND_VERSION,
92        ir_schema_version: IR_SCHEMA_VERSION,
93        tokens,
94        roots: vec![IrNode {
95            shape: Shape::Error,
96            name: None,
97            token_start,
98            token_end,
99            range,
100            children: Vec::new(),
101        }],
102        diagnostics: Vec::new(),
103        error_ranges: vec![range],
104        depth_truncated: true,
105        test_module: false,
106    }
107}
108
109/// The Rust Structural-mode frontend.
110#[derive(Debug, Clone, Copy, Default)]
111pub struct RustStructuralFrontend;
112
113impl StructuralFrontend for RustStructuralFrontend {
114    fn language(&self) -> Language {
115        Language::Rust
116    }
117
118    fn frontend_version(&self) -> &'static str {
119        STRUCTURAL_FRONTEND_VERSION
120    }
121
122    fn parse(&self, source: &str) -> SyntaxIrFile {
123        let (preflight_tokens, _) = crate::lexer::lex(source);
124        if let Some(range) = delimiter_nesting_overflow(&preflight_tokens, source.len()) {
125            return depth_error_file(preflight_tokens, range);
126        }
127
128        let parse = SourceFile::parse(source, PARSE_EDITION);
129        let root = parse.syntax_node();
130
131        let mut builder = IrBuilder::new(source);
132        builder.collect_tokens(&root);
133
134        let mut roots = Vec::new();
135        for child in root.children() {
136            builder.visit(&child, &mut roots, 1);
137        }
138
139        for error in parse.errors() {
140            let range = error.range();
141            builder.error_ranges.push(ByteRange {
142                start: usize::from(range.start()),
143                end: usize::from(range.end()),
144            });
145        }
146        builder
147            .error_ranges
148            .sort_unstable_by_key(|range| (range.start, range.end));
149        builder.error_ranges.dedup();
150
151        SyntaxIrFile {
152            language: Language::Rust,
153            frontend_version: STRUCTURAL_FRONTEND_VERSION,
154            ir_schema_version: IR_SCHEMA_VERSION,
155            tokens: builder.tokens,
156            roots,
157            // Lexical diagnostics are a Fast-lexer concept; the structural
158            // frontend reports problems through `error_ranges` only.
159            diagnostics: Vec::new(),
160            error_ranges: builder.error_ranges,
161            depth_truncated: builder.depth_truncated,
162            test_module: false,
163        }
164    }
165}
166
167/// How one CST node maps onto the IR.
168enum Mapping {
169    /// Emit a node with this shape and recurse into children.
170    Emit(Shape),
171    /// Emit a [`Shape::Native`] node under this grammar kind name.
172    Native(&'static str),
173    /// A statement wrapper: unwrap when the inner expression emits a node.
174    ExprStmt,
175    /// A parser error region: emit [`Shape::Error`] and record its range.
176    Error,
177    /// No node of its own; children are still visited.
178    Transparent,
179}
180
181/// Decide how `node` maps onto the IR. This table is the granularity
182/// contract of the Rust structural frontend; changing it changes fingerprint
183/// input, which invalidates every result recorded under the old table. Before
184/// the first release that is settled by rescanning rather than by raising
185/// [`STRUCTURAL_FRONTEND_VERSION`], which stays at v1.
186fn classify(node: &SyntaxNode) -> Mapping {
187    match node.kind() {
188        SyntaxKind::FN => Mapping::Emit(fn_shape(node)),
189        SyntaxKind::CLOSURE_EXPR => Mapping::Emit(Shape::Closure),
190        SyntaxKind::STRUCT | SyntaxKind::ENUM | SyntaxKind::UNION => Mapping::Emit(Shape::Record),
191        SyntaxKind::IMPL => Mapping::Emit(Shape::Impl),
192        SyntaxKind::TRAIT => Mapping::Native("trait"),
193        // `BLOCK_EXPR` and the `STMT_LIST` inside it collapse into one Block:
194        // the block emits, the statement list stays transparent.
195        SyntaxKind::BLOCK_EXPR => Mapping::Emit(Shape::Block),
196        SyntaxKind::LOOP_EXPR | SyntaxKind::WHILE_EXPR | SyntaxKind::FOR_EXPR => {
197            Mapping::Emit(Shape::Loop)
198        }
199        // Each `else if` is its own `IF_EXPR` child, so a chain nests as
200        // Branch nodes without special handling.
201        SyntaxKind::IF_EXPR => Mapping::Emit(Shape::Branch),
202        SyntaxKind::MATCH_EXPR => Mapping::Emit(Shape::Match),
203        SyntaxKind::MATCH_ARM => Mapping::Emit(Shape::MatchArm),
204        SyntaxKind::CALL_EXPR | SyntaxKind::METHOD_CALL_EXPR => Mapping::Emit(Shape::Call),
205        SyntaxKind::AWAIT_EXPR => Mapping::Native("await_expr"),
206        SyntaxKind::BIN_EXPR if is_assignment(node) => Mapping::Emit(Shape::Assign),
207        SyntaxKind::BIN_EXPR => binary_operator(node).map_or(Mapping::Transparent, Mapping::Native),
208        SyntaxKind::LET_STMT => Mapping::Emit(Shape::VarDecl),
209        SyntaxKind::RETURN_EXPR => Mapping::Emit(Shape::Return),
210        SyntaxKind::BREAK_EXPR => Mapping::Emit(Shape::Break),
211        SyntaxKind::CONTINUE_EXPR => Mapping::Emit(Shape::Continue),
212        SyntaxKind::TRY_EXPR => Mapping::Emit(Shape::Try),
213        SyntaxKind::EXPR_STMT => Mapping::ExprStmt,
214        SyntaxKind::MACRO_RULES | SyntaxKind::MACRO_DEF => Mapping::Emit(Shape::MacroDef),
215        SyntaxKind::MACRO_CALL => Mapping::Emit(Shape::MacroCall),
216        SyntaxKind::MODULE => Mapping::Native("module"),
217        SyntaxKind::EXTERN_BLOCK => Mapping::Native("extern_block"),
218        SyntaxKind::CONST => Mapping::Native("const"),
219        SyntaxKind::STATIC => Mapping::Native("static"),
220        SyntaxKind::ERROR => Mapping::Error,
221        // Everything else — item plumbing, patterns, types and interior
222        // expression detail — is transparent: no node, children visited.
223        _ => Mapping::Transparent,
224    }
225}
226
227/// An `fn` directly inside an `impl` or `trait` body is a method; anywhere
228/// else (file root, module, nested in another body) it is a free function.
229fn fn_shape(node: &SyntaxNode) -> Shape {
230    if node
231        .parent()
232        .is_some_and(|parent| parent.kind() == SyntaxKind::ASSOC_ITEM_LIST)
233    {
234        Shape::Method
235    } else {
236        Shape::Function
237    }
238}
239
240/// Whether a `BIN_EXPR`'s operator token is `=` or a compound assignment.
241/// Operands are child nodes, so the only child tokens besides trivia are the
242/// operator itself.
243fn is_assignment(node: &SyntaxNode) -> bool {
244    node.children_with_tokens()
245        .filter_map(ra_ap_syntax::SyntaxElement::into_token)
246        .any(|token| ASSIGN_OPS.contains(&token.kind()))
247}
248
249/// Stable native shape for a non-assignment binary operation.
250fn binary_operator(node: &SyntaxNode) -> Option<&'static str> {
251    node.children_with_tokens()
252        .filter_map(ra_ap_syntax::SyntaxElement::into_token)
253        .map(|token| token.kind())
254        .find_map(|operator| match operator {
255            SyntaxKind::PLUS => Some("binary-add"),
256            SyntaxKind::MINUS => Some("binary-sub"),
257            SyntaxKind::STAR => Some("binary-mul"),
258            SyntaxKind::SLASH => Some("binary-div"),
259            SyntaxKind::PERCENT => Some("binary-rem"),
260            SyntaxKind::SHL => Some("binary-shl"),
261            SyntaxKind::SHR => Some("binary-shr"),
262            SyntaxKind::AMP => Some("binary-bit-and"),
263            SyntaxKind::PIPE => Some("binary-bit-or"),
264            SyntaxKind::CARET => Some("binary-bit-xor"),
265            SyntaxKind::AMP2 => Some("binary-and"),
266            SyntaxKind::PIPE2 => Some("binary-or"),
267            SyntaxKind::EQ2 => Some("binary-eq"),
268            SyntaxKind::NEQ => Some("binary-ne"),
269            SyntaxKind::L_ANGLE => Some("binary-lt"),
270            SyntaxKind::R_ANGLE => Some("binary-gt"),
271            SyntaxKind::LTEQ => Some("binary-le"),
272            SyntaxKind::GTEQ => Some("binary-ge"),
273            _ => None,
274        })
275}
276
277/// Whether a statement's inner expression maps to a shape of its own, making
278/// the `EXPR_STMT` wrapper redundant. Expression-position macro calls sit
279/// inside a `MACRO_EXPR` wrapper, which is looked through.
280fn inner_expression_emits(stmt: &SyntaxNode) -> bool {
281    let mut expr = stmt.children().next();
282    while let Some(node) = expr {
283        match classify(&node) {
284            Mapping::Emit(_) | Mapping::Native(_) | Mapping::Error => return true,
285            Mapping::Transparent if node.kind() == SyntaxKind::MACRO_EXPR => {
286                expr = node.children().next();
287            }
288            _ => return false,
289        }
290    }
291    false
292}
293
294/// Map one CST token kind onto the shared [`TokenKind`] vocabulary.
295fn map_token_kind(kind: SyntaxKind) -> TokenKind {
296    match kind {
297        SyntaxKind::IDENT => TokenKind::Identifier,
298        SyntaxKind::TRUE_KW | SyntaxKind::FALSE_KW => TokenKind::Literal(LiteralKind::Bool),
299        SyntaxKind::INT_NUMBER => TokenKind::Literal(LiteralKind::Integer),
300        SyntaxKind::FLOAT_NUMBER => TokenKind::Literal(LiteralKind::Float),
301        // Raw strings have no kind of their own: the parser reports `r"..."`
302        // as STRING, `br"..."` as BYTE_STRING and `cr"..."` as C_STRING, so
303        // the three of them are already covered here.
304        SyntaxKind::STRING | SyntaxKind::BYTE_STRING | SyntaxKind::C_STRING => {
305            TokenKind::Literal(LiteralKind::String)
306        }
307        SyntaxKind::CHAR | SyntaxKind::BYTE => TokenKind::Literal(LiteralKind::Char),
308        SyntaxKind::LIFETIME_IDENT => TokenKind::Lifetime,
309        kind if kind.is_keyword(PARSE_EDITION) => TokenKind::Keyword,
310        kind if kind.is_punct() => TokenKind::Punctuation,
311        _ => TokenKind::Unknown,
312    }
313}
314
315/// Accumulates the token stream and IR tree for one file.
316struct IrBuilder<'s> {
317    source: &'s str,
318    interner: LexemeInterner,
319    tokens: Vec<Token>,
320    /// Byte start of each emitted token, for mapping node byte ranges onto
321    /// token index ranges by binary search.
322    token_starts: Vec<usize>,
323    /// Byte offset of the start of each source line.
324    line_starts: Vec<usize>,
325    error_ranges: Vec<ByteRange>,
326    depth_truncated: bool,
327}
328
329impl<'s> IrBuilder<'s> {
330    fn new(source: &'s str) -> Self {
331        let mut line_starts = vec![0];
332        for (index, byte) in source.bytes().enumerate() {
333            if byte == b'\n' {
334                line_starts.push(index + 1);
335            }
336        }
337        Self {
338            source,
339            interner: LexemeInterner::new(),
340            tokens: Vec::new(),
341            token_starts: Vec::new(),
342            line_starts,
343            error_ranges: Vec::new(),
344            depth_truncated: false,
345        }
346    }
347
348    /// Walk every CST token in source order, dropping trivia. The CST is
349    /// lossless, so this yields the complete token stream of the file.
350    fn collect_tokens(&mut self, root: &SyntaxNode) {
351        for element in root.descendants_with_tokens() {
352            let Some(token) = element.into_token() else {
353                continue;
354            };
355            let kind = token.kind();
356            if matches!(kind, SyntaxKind::WHITESPACE | SyntaxKind::COMMENT) {
357                continue;
358            }
359            let range = token.text_range();
360            let start_byte = usize::from(range.start());
361            let end_byte = usize::from(range.end());
362            let (start_line, start_column) = self.line_column(start_byte);
363            let text = self.interner.intern(token.text());
364            self.token_starts.push(start_byte);
365            self.tokens.push(Token {
366                kind: map_token_kind(kind),
367                text,
368                span: SourceSpan {
369                    start_byte,
370                    end_byte,
371                    start_line,
372                    start_column,
373                },
374            });
375        }
376    }
377
378    /// 1-based line and character column of a byte offset.
379    fn line_column(&self, byte: usize) -> (u32, u32) {
380        let line_index = self
381            .line_starts
382            .partition_point(|&start| start <= byte)
383            .saturating_sub(1);
384        let line_start = self.line_starts.get(line_index).copied().unwrap_or(0);
385        let column_chars = self
386            .source
387            .get(line_start..byte)
388            .map_or(0, |prefix| prefix.chars().count());
389        (
390            u32::try_from(line_index + 1).unwrap_or(u32::MAX),
391            u32::try_from(column_chars + 1).unwrap_or(u32::MAX),
392        )
393    }
394
395    /// Map one CST node onto the IR, appending zero or more nodes to `out`.
396    fn visit(&mut self, cst: &SyntaxNode, out: &mut Vec<IrNode>, depth: usize) {
397        if depth >= MAX_IR_DEPTH {
398            self.emit_depth_error(cst, out);
399            return;
400        }
401
402        match classify(cst) {
403            Mapping::Emit(shape) => {
404                let name = self.node_name(cst);
405                let node = self.build_node(shape, name, cst, depth);
406                out.push(node);
407            }
408            Mapping::Native(kind) => {
409                let shape = Shape::Native(self.interner.intern(kind));
410                let node = self.build_node(shape, None, cst, depth);
411                out.push(node);
412            }
413            Mapping::ExprStmt => {
414                if inner_expression_emits(cst) {
415                    // The inner expression's own node is the statement.
416                    for child in cst.children() {
417                        self.visit(&child, out, depth + 1);
418                    }
419                } else {
420                    let node = self.build_node(Shape::ExprStmt, None, cst, depth);
421                    out.push(node);
422                }
423            }
424            Mapping::Error => {
425                self.error_ranges.push(byte_range(cst));
426                // Recurse anyway: real parsers wrap intact regions in error
427                // nodes, and those descendants must still be recovered.
428                let node = self.build_node(Shape::Error, None, cst, depth);
429                out.push(node);
430            }
431            Mapping::Transparent => {
432                for child in cst.children() {
433                    self.visit(&child, out, depth + 1);
434                }
435            }
436        }
437    }
438
439    /// Build an [`IrNode`] for `cst`, visiting its children first.
440    fn build_node(
441        &mut self,
442        shape: Shape,
443        name: Option<Lexeme>,
444        cst: &SyntaxNode,
445        depth: usize,
446    ) -> IrNode {
447        let mut children = Vec::new();
448        for child in cst.children() {
449            self.visit(&child, &mut children, depth + 1);
450        }
451        let range = byte_range(cst);
452        IrNode {
453            shape,
454            name,
455            token_start: self.token_index_at(range.start),
456            token_end: self.token_index_at(range.end),
457            range,
458            children,
459        }
460    }
461
462    /// Preserve an unvisited CST subtree as recoverable truncation data.
463    fn emit_depth_error(&mut self, cst: &SyntaxNode, out: &mut Vec<IrNode>) {
464        let range = byte_range(cst);
465        self.depth_truncated = true;
466        self.error_ranges.push(range);
467        out.push(IrNode {
468            shape: Shape::Error,
469            name: None,
470            token_start: self.token_index_at(range.start),
471            token_end: self.token_index_at(range.end),
472            range,
473            children: Vec::new(),
474        });
475    }
476
477    /// Index of the first emitted token starting at or after `byte`.
478    fn token_index_at(&self, byte: usize) -> usize {
479        self.token_starts.partition_point(|&start| start < byte)
480    }
481
482    /// Recover a declared name where the grammar provides one: the `NAME`
483    /// child of definitions, or the invoked path of a macro call.
484    fn node_name(&mut self, cst: &SyntaxNode) -> Option<Lexeme> {
485        let name_kind = match cst.kind() {
486            SyntaxKind::FN
487            | SyntaxKind::STRUCT
488            | SyntaxKind::ENUM
489            | SyntaxKind::UNION
490            | SyntaxKind::MACRO_RULES
491            | SyntaxKind::MACRO_DEF => SyntaxKind::NAME,
492            SyntaxKind::MACRO_CALL => SyntaxKind::PATH,
493            _ => return None,
494        };
495        cst.children()
496            .find(|child| child.kind() == name_kind)
497            .map(|child| self.interner.intern(&child.text().to_string()))
498    }
499}
500
501/// The byte range a CST node covers.
502fn byte_range(node: &SyntaxNode) -> ByteRange {
503    let range = node.text_range();
504    ByteRange {
505        start: usize::from(range.start()),
506        end: usize::from(range.end()),
507    }
508}
509
510#[cfg(test)]
511#[allow(clippy::unwrap_used, clippy::expect_used)]
512mod tests {
513    use super::*;
514    use codehelion_core::ir::MAX_IR_DEPTH;
515
516    fn parse(source: &str) -> SyntaxIrFile {
517        RustStructuralFrontend.parse(source)
518    }
519
520    fn assert_bounded_depth_truncation(file: &SyntaxIrFile, source_len: usize) {
521        assert!(
522            file.depth_truncated,
523            "a depth-limited parse must be distinguished from ordinary recovery"
524        );
525        let mut deepest = 0;
526        let mut error_leaves = Vec::new();
527        let mut pending: Vec<(&IrNode, usize)> = file.roots.iter().map(|root| (root, 1)).collect();
528        while let Some((node, depth)) = pending.pop() {
529            deepest = deepest.max(depth);
530            if node.shape == Shape::Error && node.children.is_empty() {
531                error_leaves.push(node.range);
532            }
533            pending.extend(node.children.iter().rev().map(|child| (child, depth + 1)));
534        }
535
536        assert!(
537            deepest <= MAX_IR_DEPTH,
538            "IR depth {deepest} exceeds the frontend limit {MAX_IR_DEPTH}"
539        );
540        assert!(
541            error_leaves.iter().any(|range| {
542                !range.is_empty() && range.end <= source_len && file.error_ranges.contains(range)
543            }),
544            "depth truncation must be represented by an Error leaf and error range"
545        );
546
547        let mut visited = 0;
548        file.walk(&mut |_| visited += 1);
549        assert_eq!(visited, file.node_count());
550    }
551
552    #[test]
553    fn deeply_nested_rust_is_truncated_without_unbounded_ir() {
554        let depth = 10_000;
555        let ignored_braces = "{".repeat(depth);
556        let control_source =
557            format!("fn control() {{ /* {ignored_braces} */ let text = \"{ignored_braces}\"; }}");
558        let control = parse(&control_source);
559        assert!(control.error_ranges.is_empty());
560        assert!(
561            control.roots.iter().all(|node| node.shape != Shape::Error),
562            "delimiters in comments and literals must not consume nesting budget"
563        );
564
565        let mut builder_guard_source = String::from("fn builder_guard() ");
566        builder_guard_source.push_str(&"{".repeat(MAX_IR_DEPTH));
567        builder_guard_source.push_str("()");
568        builder_guard_source.push_str(&"}".repeat(MAX_IR_DEPTH));
569        let builder_guard_file = parse(&builder_guard_source);
570        assert_bounded_depth_truncation(&builder_guard_file, builder_guard_source.len());
571
572        let mut source = String::from("fn deeply_nested() ");
573        source.push_str(&"{".repeat(depth));
574        source.push_str("()");
575        source.push_str(&"}".repeat(depth));
576
577        let file = parse(&source);
578        assert_bounded_depth_truncation(&file, source.len());
579        drop(file);
580        drop(builder_guard_file);
581        drop(control);
582    }
583
584    fn shape_label(shape: &Shape) -> String {
585        match shape {
586            Shape::Function => "function".to_owned(),
587            Shape::Method => "method".to_owned(),
588            Shape::Closure => "closure".to_owned(),
589            Shape::Record => "record".to_owned(),
590            Shape::Impl => "impl".to_owned(),
591            Shape::Block => "block".to_owned(),
592            Shape::Loop => "loop".to_owned(),
593            Shape::Branch => "branch".to_owned(),
594            Shape::Match => "match".to_owned(),
595            Shape::MatchArm => "match-arm".to_owned(),
596            Shape::Call => "call".to_owned(),
597            Shape::Assign => "assign".to_owned(),
598            Shape::VarDecl => "var-decl".to_owned(),
599            Shape::Return => "return".to_owned(),
600            Shape::Break => "break".to_owned(),
601            Shape::Continue => "continue".to_owned(),
602            Shape::Try => "try".to_owned(),
603            Shape::ExprStmt => "expr-stmt".to_owned(),
604            Shape::MacroDef => "macro-def".to_owned(),
605            Shape::MacroCall => "macro-call".to_owned(),
606            Shape::Error => "error".to_owned(),
607            Shape::Native(kind) => format!("native:{kind}"),
608        }
609    }
610
611    fn render_node(node: &IrNode, depth: usize, out: &mut String) {
612        for _ in 0..depth {
613            out.push_str("  ");
614        }
615        out.push_str(&shape_label(&node.shape));
616        if let Some(name) = &node.name {
617            out.push(' ');
618            out.push_str(name);
619        }
620        out.push('\n');
621        for child in &node.children {
622            render_node(child, depth + 1, out);
623        }
624    }
625
626    /// Render the IR tree as one indented line per node: shape label plus
627    /// the recovered name, when present.
628    fn render(file: &SyntaxIrFile) -> String {
629        let mut out = String::new();
630        for root in &file.roots {
631            render_node(root, 0, &mut out);
632        }
633        out
634    }
635
636    fn shapes_of(children: &[IrNode]) -> Vec<Shape> {
637        children.iter().map(|child| child.shape.clone()).collect()
638    }
639
640    const GOLDEN_SOURCE: &str = r#"
641mod app {
642    pub struct Point {
643        x: i32,
644        y: i32,
645    }
646
647    pub enum Op {
648        Add,
649        Sub,
650    }
651
652    impl Point {
653        fn shift(&mut self, dx: i32) -> i32 {
654            self.x += dx;
655            self.x
656        }
657    }
658
659    macro_rules! trace {
660        ($e:expr) => {
661            $e
662        };
663    }
664
665    fn compute(op: Op, mut acc: i32) -> Result<i32, String> {
666        let step = |v: i32| v + 1;
667        for i in 0..3 {
668            acc = step(acc + i);
669        }
670        while acc > 10 {
671            acc -= 1;
672        }
673        loop {
674            if acc == 0 {
675                break;
676            } else if acc < 0 {
677                continue;
678            } else {
679                acc = acc.checked_sub(1).ok_or("underflow")?;
680            }
681        }
682        match op {
683            Op::Add => acc += 1,
684            Op::Sub => acc -= 1,
685        }
686        fn helper(v: i32) -> i32 {
687            v
688        }
689        println!("{}", helper(acc));
690        return Ok(acc);
691    }
692}
693"#;
694
695    #[test]
696    fn golden_tree_pins_the_mapping_contract() {
697        let file = parse(GOLDEN_SOURCE);
698        assert!(
699            file.error_ranges.is_empty(),
700            "the golden source must parse cleanly"
701        );
702        let expected = "\
703native:module
704  record Point
705  record Op
706  impl
707    method shift
708      block
709        assign
710  macro-def trace
711  function compute
712    block
713      var-decl
714        closure
715          native:binary-add
716      loop
717        block
718          assign
719            call
720              native:binary-add
721      loop
722        native:binary-gt
723        block
724          assign
725      loop
726        block
727          branch
728            native:binary-eq
729            block
730              break
731            branch
732              native:binary-lt
733              block
734                continue
735              block
736                assign
737                  try
738                    call
739                      call
740      match
741        match-arm
742          assign
743        match-arm
744          assign
745      function helper
746        block
747      macro-call println
748      return
749        call
750";
751        assert_eq!(render(&file), expected);
752    }
753
754    #[test]
755    fn fn_position_separates_methods_from_functions() {
756        let source = "\
757fn free() {}
758struct S;
759impl S {
760    fn on_impl(&self) {}
761}
762trait T {
763    fn on_trait(&self);
764}
765";
766        let file = parse(source);
767        let mut found = Vec::new();
768        file.walk(&mut |node| {
769            if matches!(node.shape, Shape::Function | Shape::Method) {
770                let name = node.name.as_ref().map(ToString::to_string);
771                found.push((node.shape.clone(), name));
772            }
773        });
774        assert_eq!(
775            found,
776            vec![
777                (Shape::Function, Some("free".to_owned())),
778                (Shape::Method, Some("on_impl".to_owned())),
779                (Shape::Method, Some("on_trait".to_owned())),
780            ]
781        );
782    }
783
784    #[test]
785    fn fn_body_collapses_to_one_block_of_statements() {
786        let file = parse("fn f() { let a = 1; a = 2; g(); return; }");
787        let function = &file.roots[0];
788        assert_eq!(function.shape, Shape::Function);
789        assert_eq!(
790            function.children.len(),
791            1,
792            "the body must be exactly one Block node"
793        );
794        let body = &function.children[0];
795        assert_eq!(body.shape, Shape::Block);
796        assert_eq!(
797            shapes_of(&body.children),
798            vec![Shape::VarDecl, Shape::Assign, Shape::Call, Shape::Return]
799        );
800
801        let summaries = body.statement_summaries(&file.tokens);
802        let tags: Vec<u8> = summaries.iter().map(|summary| summary.shape_tag).collect();
803        assert_eq!(
804            tags,
805            vec![
806                Shape::VarDecl.tag(),
807                Shape::Assign.tag(),
808                Shape::Return.tag()
809            ],
810            "a bare call statement is a Call node, which is not a statement shape"
811        );
812        let text: Vec<&str> = summaries[0]
813            .tokens(&file.tokens)
814            .iter()
815            .map(|token| token.text.as_str())
816            .collect();
817        assert_eq!(text, vec!["let", "a", "=", "1", ";"]);
818    }
819
820    #[test]
821    fn expr_stmt_unwraps_to_the_inner_shape() {
822        let file = parse("fn f() { g(); a + b; }");
823        let body = &file.roots[0].children[0];
824        assert_eq!(
825            shapes_of(&body.children),
826            vec![Shape::Call, Shape::Native("binary-add".into())],
827            "a call statement and a binary expression retain their own shapes"
828        );
829    }
830
831    #[test]
832    fn assignment_operators_map_to_assign_and_comparisons_do_not() {
833        let file = parse("fn f() { x = 1; x += 1; x == 1; }");
834        let body = &file.roots[0].children[0];
835        assert_eq!(
836            shapes_of(&body.children),
837            vec![
838                Shape::Assign,
839                Shape::Assign,
840                Shape::Native("binary-eq".into())
841            ],
842            "assignments and comparisons retain distinct structural shapes"
843        );
844    }
845
846    #[test]
847    fn non_assignment_binary_operators_are_distinct_structural_nodes() {
848        let file = parse("fn f(a: u64, b: u64) { a + b; a / b; }");
849        let body = &file.roots[0].children[0];
850        assert_eq!(
851            shapes_of(&body.children),
852            vec![
853                Shape::Native("binary-add".into()),
854                Shape::Native("binary-div".into())
855            ]
856        );
857        assert_eq!(body.children[0].shape, Shape::Native("binary-add".into()));
858        assert_eq!(body.children[1].shape, Shape::Native("binary-div".into()));
859    }
860
861    #[test]
862    fn broken_fn_between_intact_fns_keeps_both_neighbours() {
863        let file = parse("fn first() {}\nfn broken() { let = ; }\nfn second() {}\n");
864        let mut function_names = Vec::new();
865        let mut error_nodes = 0;
866        file.walk(&mut |node| {
867            if node.shape == Shape::Function {
868                function_names.push(node.name.as_ref().map(ToString::to_string));
869            }
870            if node.shape == Shape::Error {
871                error_nodes += 1;
872            }
873        });
874        assert!(function_names.contains(&Some("first".to_owned())));
875        assert!(function_names.contains(&Some("second".to_owned())));
876        assert!(
877            error_nodes >= 1,
878            "the malformed region yields an Error node"
879        );
880        assert!(!file.error_ranges.is_empty());
881    }
882
883    #[test]
884    fn truncation_at_eof_still_yields_the_function() {
885        let file = parse("fn tail() { let x = 1;");
886        assert_eq!(file.roots.len(), 1);
887        let function = &file.roots[0];
888        assert_eq!(function.shape, Shape::Function);
889        assert_eq!(function.name.as_deref(), Some("tail"));
890        assert_eq!(shapes_of(&function.children), vec![Shape::Block]);
891        assert_eq!(
892            shapes_of(&function.children[0].children),
893            vec![Shape::VarDecl]
894        );
895        assert!(!file.error_ranges.is_empty());
896    }
897
898    #[test]
899    fn token_stream_classification_and_spans() {
900        let source = "fn f<'a>(x: &'a str) -> u32 {\n    // gone\n    let é = 1.5; g(2, 'z', \"s\", true)\n}\n";
901        let file = parse(source);
902
903        // `None` marks a token that is missing from the stream entirely.
904        let kind_of = |text: &str| -> Option<TokenKind> {
905            file.tokens
906                .iter()
907                .find(|token| token.text == text)
908                .map(|token| token.kind)
909        };
910        assert_eq!(kind_of("fn"), Some(TokenKind::Keyword));
911        assert_eq!(kind_of("let"), Some(TokenKind::Keyword));
912        assert_eq!(kind_of("f"), Some(TokenKind::Identifier));
913        assert_eq!(kind_of("é"), Some(TokenKind::Identifier));
914        assert_eq!(kind_of("'a"), Some(TokenKind::Lifetime));
915        assert_eq!(kind_of("1.5"), Some(TokenKind::Literal(LiteralKind::Float)));
916        assert_eq!(kind_of("2"), Some(TokenKind::Literal(LiteralKind::Integer)));
917        assert_eq!(kind_of("'z'"), Some(TokenKind::Literal(LiteralKind::Char)));
918        assert_eq!(
919            kind_of("\"s\""),
920            Some(TokenKind::Literal(LiteralKind::String))
921        );
922        assert_eq!(kind_of("true"), Some(TokenKind::Literal(LiteralKind::Bool)));
923        assert_eq!(kind_of("->"), Some(TokenKind::Punctuation));
924        assert_eq!(kind_of("("), Some(TokenKind::Punctuation));
925
926        assert!(
927            file.tokens
928                .iter()
929                .all(|token| !token.text.contains("gone") && !token.text.trim().is_empty()),
930            "comments and whitespace must not appear in the stream"
931        );
932
933        // Spans are byte-accurate and positions are 1-based; the column is
934        // counted in characters, so `1.5` sits one byte further right than
935        // its column suggests (the `é` before it is two bytes).
936        let e_acute = file.tokens.iter().find(|token| token.text == "é").unwrap();
937        assert_eq!(e_acute.span.start_byte, source.find('é').unwrap());
938        assert_eq!(
939            e_acute.span.end_byte,
940            e_acute.span.start_byte + 'é'.len_utf8()
941        );
942        assert_eq!(e_acute.span.start_line, 3);
943        assert_eq!(e_acute.span.start_column, 9);
944
945        let float = file
946            .tokens
947            .iter()
948            .find(|token| token.text == "1.5")
949            .unwrap();
950        assert_eq!(float.span.start_byte, source.find("1.5").unwrap());
951        assert_eq!(float.span.end_byte, float.span.start_byte + 3);
952        assert_eq!(float.span.start_line, 3);
953        assert_eq!(float.span.start_column, 13);
954    }
955
956    #[test]
957    fn parsing_twice_is_deterministic() {
958        let first = parse(GOLDEN_SOURCE);
959        let second = parse(GOLDEN_SOURCE);
960        assert_eq!(first.tokens, second.tokens);
961        assert_eq!(first.roots, second.roots);
962        assert_eq!(first.error_ranges, second.error_ranges);
963    }
964
965    #[test]
966    fn file_carries_language_and_versions() {
967        let frontend = RustStructuralFrontend;
968        assert_eq!(frontend.language(), Language::Rust);
969        assert_eq!(frontend.frontend_version(), "rust-ir-v1");
970
971        let file = parse("fn a() {}");
972        assert_eq!(file.language, Language::Rust);
973        assert_eq!(file.frontend_version, STRUCTURAL_FRONTEND_VERSION);
974        assert_eq!(file.ir_schema_version, IR_SCHEMA_VERSION);
975        assert!(file.diagnostics.is_empty());
976    }
977}