1use 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
31pub const STRUCTURAL_FRONTEND_VERSION: &str = "rust-ir-v1";
35
36const PARSE_EDITION: Edition = Edition::CURRENT;
39
40const 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
55fn 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
84fn 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#[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 diagnostics: Vec::new(),
160 error_ranges: builder.error_ranges,
161 depth_truncated: builder.depth_truncated,
162 test_module: false,
163 }
164 }
165}
166
167enum Mapping {
169 Emit(Shape),
171 Native(&'static str),
173 ExprStmt,
175 Error,
177 Transparent,
179}
180
181fn 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 SyntaxKind::BLOCK_EXPR => Mapping::Emit(Shape::Block),
196 SyntaxKind::LOOP_EXPR | SyntaxKind::WHILE_EXPR | SyntaxKind::FOR_EXPR => {
197 Mapping::Emit(Shape::Loop)
198 }
199 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 _ => Mapping::Transparent,
224 }
225}
226
227fn 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
240fn 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
249fn 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
277fn 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
294fn 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 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
315struct IrBuilder<'s> {
317 source: &'s str,
318 interner: LexemeInterner,
319 tokens: Vec<Token>,
320 token_starts: Vec<usize>,
323 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 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 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 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 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 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 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 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 fn token_index_at(&self, byte: usize) -> usize {
479 self.token_starts.partition_point(|&start| start < byte)
480 }
481
482 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
501fn 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 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 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 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}