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}