Skip to main content

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