Skip to main content

stern4rust/
test_file_rewriter.rs

1// Copyright 2025 Umberto Gotti <umberto.gotti@umbertogotti.dev>
2// Licensed under the MIT License
3// SPDX-License-Identifier: MIT
4
5use crate::finding::model::section::Section;
6use crate::finding::model::test_file_item::TestFileItem;
7use crate::finding::parsing::test_file_parser::TestFileParser;
8use crate::source_file::SourceFile;
9use std::collections::BTreeMap;
10
11// A test file put back into the order the structure rule asks for.
12//
13// This exists because the alternative was done by hand four times in one day
14// and produced three separate string-handling bugs -- a reordering script that
15// tracked raw strings but not ordinary ones, then one that missed `\n` escapes,
16// then one desynchronised by a `'"'` char literal. Every one of them was found
17// only because the tool re-checked the result afterwards.
18//
19// The reason a rewriter can be safe where those were not is that it never looks
20// at the text. `syn` says where each item begins and ends; whole line ranges are
21// moved without being read, so a string literal containing something that looks
22// like Rust is carried along like any other line.
23//
24// Grouping by section fixes the section order too, so a constant sitting below a
25// helper moves up with the same pass rather than needing a second one.
26pub struct TestFileRewriter;
27
28impl TestFileRewriter {
29    pub const ORDER: [Section; 4] = [
30        Section::Imports,
31        Section::Constants,
32        Section::Helpers,
33        Section::Tests,
34    ];
35
36    pub const REGISTRIES: [&'static str; 2] = ["all_tests.rs", "mod.rs"];
37    pub const TESTS_ROOT: &'static str = "tests/";
38
39    // None when the file is not this rewriter's to touch, does not parse, holds
40    // no items, or is already correct.
41    //
42    // The scope check is first and is the important one. It mirrors
43    // test-file-structure exactly, because a fixer must never edit a file no
44    // rule governs -- the first version of this did, and reordered the imports
45    // of thirty `src/` files into one alphabetical block, destroying a grouping
46    // convention that no rule checks and so no rule would have restored. It
47    // produced a green run and a tree nobody had reviewed.
48    //
49    // "Already correct" is None rather than an unchanged string so a caller
50    // cannot rewrite a file it did not need to touch, which would show up as a
51    // spurious diff in somebody's review.
52    pub fn rewrite(file: &SourceFile) -> Option<String> {
53        if !Self::governs(file) {
54            return None;
55        }
56        let items = TestFileParser::parse(file)?;
57        if items.is_empty() {
58            return None;
59        }
60        let rewritten = Self::assemble(file, &items);
61        (rewritten != file.contents()).then_some(rewritten)
62    }
63
64    // The same scope test-file-structure applies, and deliberately a copy of
65    // its shape rather than a call into it: a rule answers questions about a
66    // file, and asking it to also authorise edits would give it a second job.
67    fn governs(file: &SourceFile) -> bool {
68        let path = file.relative_path();
69        path.starts_with(Self::TESTS_ROOT)
70            && !path
71                .rsplit('/')
72                .next()
73                .is_some_and(|name| Self::REGISTRIES.contains(&name))
74    }
75
76    // Chunks are joined rather than appended with separators, so the file ends
77    // with exactly one newline instead of whatever the last section happened to
78    // leave behind.
79    fn assemble(file: &SourceFile, items: &[TestFileItem]) -> String {
80        let grouped = Self::grouped(items);
81        let chunks: Vec<String> = Self::ORDER
82            .iter()
83            .filter_map(|section| {
84                let members = grouped.get(section)?;
85                let gap = "\n".repeat(section.blank_lines_between_entries() + 1);
86                Some(
87                    members
88                        .iter()
89                        .map(|item| Self::block(file, item))
90                        .collect::<Vec<String>>()
91                        .join(&gap),
92                )
93            })
94            .collect();
95        let mut out = Self::preamble(file, items);
96        out.push_str(&chunks.join("\n\n"));
97        out.push('\n');
98        let tail = Self::tail(file, items);
99        if !tail.is_empty() {
100            out.push('\n');
101            out.push_str(&tail);
102        }
103        out
104    }
105
106    // Everything above the first item: the header and any file-level commentary,
107    // which belong where their author put them and are never reordered.
108    fn preamble(file: &SourceFile, items: &[TestFileItem]) -> String {
109        let first = items.iter().map(|item| item.first_line).min().unwrap_or(1);
110        let kept: Vec<&String> = file.lines().iter().take(first - 1).collect();
111        let trimmed: Vec<&&String> = kept
112            .iter()
113            .rev()
114            .skip_while(|line| line.trim().is_empty())
115            .collect();
116        if trimmed.is_empty() {
117            return String::new();
118        }
119        let mut preamble: Vec<String> = trimmed.iter().rev().map(|line| (**line).clone()).collect();
120        preamble.push(String::new());
121        preamble.join("\n") + "\n"
122    }
123
124    // Anything below the last item. Kept rather than dropped: a trailing comment
125    // belongs to nobody and losing it would be the rewriter destroying content
126    // it was asked to tidy.
127    fn tail(file: &SourceFile, items: &[TestFileItem]) -> String {
128        let last = items.iter().map(|item| item.last_line).max().unwrap_or(0);
129        let rest: Vec<String> = file
130            .lines()
131            .iter()
132            .skip(last)
133            .skip_while(|line| line.trim().is_empty())
134            .cloned()
135            .collect();
136        // A file ending in a newline splits with a trailing empty element, so
137        // the blank lines have to come off both ends or the result gains one.
138        let Some(end) = rest.iter().rposition(|line| !line.trim().is_empty()) else {
139            return String::new();
140        };
141        rest[..=end].join("\n") + "\n"
142    }
143
144    fn block(file: &SourceFile, item: &TestFileItem) -> String {
145        file.lines()
146            .iter()
147            .skip(item.first_line - 1)
148            .take(item.last_line + 1 - item.first_line)
149            .cloned()
150            .collect::<Vec<String>>()
151            .join("\n")
152    }
153
154    // Imports keep the order they were written in; everything else is sorted.
155    //
156    // Not an oversight. rustfmt owns import order and disagrees with a plain
157    // alphabet on two shapes -- `self`/`super`/`crate`, and a pair diverging at
158    // a segment of differing case -- which is why test-file-structure stands
159    // down on exactly those pairs. A fixer that sorted imports anyway would
160    // write an order `cargo fmt` undoes on the next run, which is the
161    // unsatisfiable loop that stand-down exists to prevent. The first version
162    // of this did precisely that to `use serde_json::Value` sitting beside
163    // `use serde_json::from_str`.
164    //
165    // They are still *moved*, as a block, into the imports section. Grouping
166    // them is safe; ordering them is rustfmt's business.
167    fn grouped(items: &[TestFileItem]) -> BTreeMap<Section, Vec<&TestFileItem>> {
168        let mut grouped: BTreeMap<Section, Vec<&TestFileItem>> = BTreeMap::new();
169        for item in items {
170            grouped.entry(item.section).or_default().push(item);
171        }
172        for (section, members) in grouped.iter_mut() {
173            if *section != Section::Imports {
174                members.sort_by_key(|item| item.sort_key());
175            }
176        }
177        grouped
178    }
179}