Skip to main content

stern4rust/finding/parsing/
test_file_parser.rs

1// Copyright 2025 Umberto Gotti <umberto.gotti@umbertogotti.dev>
2// Licensed under the MIT License
3// SPDX-License-Identifier: MIT
4
5use syn::Item;
6use syn::parse_file;
7use syn::spanned::Spanned;
8
9use crate::finding::model::section::Section;
10use crate::finding::model::test_file_item::TestFileItem;
11use crate::finding::parsing::item_naming::ItemNaming;
12use crate::source_file::SourceFile;
13
14// Turns a test file into the list of items the structure rule reasons about.
15//
16// Plain `//` comments never reach the syntax tree, so the header and every
17// explanatory comment are invisible here -- which is why the rule does not need
18// to know where the header ends. What it does need is for a comment introducing
19// an item to count as part of that item, so each block is extended upwards over
20// the comment lines directly above it.
21pub struct TestFileParser;
22
23impl TestFileParser {
24    // None means the file does not parse. That is rustc's to report, far more
25    // clearly than this could, and guessing at a shape from broken source would
26    // pile noise on top of a compile error.
27    pub fn parse(file: &SourceFile) -> Option<Vec<TestFileItem>> {
28        let syntax = parse_file(&file.contents()).ok()?;
29        Some(
30            syntax
31                .items
32                .iter()
33                .map(|item| Self::item(file, item))
34                .collect(),
35        )
36    }
37
38    fn item(file: &SourceFile, item: &Item) -> TestFileItem {
39        let span = item.span();
40        let first_line = Self::with_leading_comments(file, span.start().line);
41        TestFileItem::new(
42            Self::section(item),
43            Self::name(file, item, span.start().line),
44            first_line,
45            span.end().line,
46        )
47    }
48
49    fn section(item: &Item) -> Section {
50        match item {
51            Item::Use(_) => Section::Imports,
52            Item::Const(_) | Item::Static(_) => Section::Constants,
53            Item::Fn(function) if Self::is_test(&function.attrs) => Section::Tests,
54            _ => Section::Helpers,
55        }
56    }
57
58    // `#[test]`, `#[tokio::test]` and any other harness spelled the same way.
59    // Matching the last path segment rather than the whole path is what keeps
60    // this from having to enumerate test frameworks.
61    fn is_test(attrs: &[syn::Attribute]) -> bool {
62        attrs.iter().any(|attr| {
63            attr.path()
64                .segments
65                .last()
66                .is_some_and(|segment| segment.ident == "test")
67        })
68    }
69
70    fn name(file: &SourceFile, item: &Item, start_line: usize) -> String {
71        // An impl block belongs to the type it implements, so it sorts under
72        // that name and sits beside the struct rather than drifting to the end
73        // of the section. That is this parser's own answer; the rest is shared.
74        if let Item::Impl(inner) = item {
75            return Self::type_name(&inner.self_ty);
76        }
77        ItemNaming::identifier(item).unwrap_or_else(|| ItemNaming::source_line(file, start_line))
78    }
79
80    fn type_name(ty: &syn::Type) -> String {
81        match ty {
82            syn::Type::Path(path) => path
83                .path
84                .segments
85                .last()
86                .map_or_else(String::new, |segment| segment.ident.to_string()),
87            _ => String::new(),
88        }
89    }
90
91    // An import sorts and reports as it was written, which is how rustfmt orders
92    // them and how a reader would look for one.
93
94    fn with_leading_comments(file: &SourceFile, start_line: usize) -> usize {
95        let mut first = start_line;
96        while first > 1 && Self::is_comment(file, first - 1) {
97            first -= 1;
98        }
99        first
100    }
101
102    fn is_comment(file: &SourceFile, line: usize) -> bool {
103        file.lines()
104            .get(line.saturating_sub(1))
105            .is_some_and(|text| text.trim_start().starts_with("//"))
106    }
107}