Skip to main content

stern4rust/finding/parsing/
test_file_parser.rs

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