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