wowlab_tidy/languages/rust/rules/hygiene/
tidy_directives.rs1use crate::{Example, FileCtx, Violation};
2
3#[rustfmt::skip]
4const EXAMPLES: &[Example] = &[
5 Example {
6 label: "file directive at top with blank line",
7 code: "// #t(file: rust_panic) startup binary\n\nuse std::io;",
8 pass: true,
9 },
10 Example {
11 label: "multiple file directives with different reasons",
12 code: "// #t(file: rust_panic) startup binary\n// #t(file: rust_unwrap_in_lib) CLI error handling\n\nuse std::io;",
13 pass: true,
14 },
15 Example {
16 label: "file directive missing blank line before code",
17 code: "// #t(file: rust_panic) startup binary\nuse std::io;",
18 pass: false,
19 },
20 Example {
21 label: "file directive after imports",
22 code: "use std::io;\n// #t(file: rust_panic) startup binary\n\nfn main() {}",
23 pass: false,
24 },
25 Example {
26 label: "non-file directive anywhere is fine",
27 code: "use std::io;\n// #t(rust_panic) reason\npanic!(\"x\");",
28 pass: true,
29 },
30 Example {
31 label: "file with no directives",
32 code: "use std::io;\nfn main() {}",
33 pass: true,
34 },
35 Example {
36 label: "file directive then doc comment without blank line fails",
37 code: "// #t(file: rust_panic) startup binary\n//! Module docs.\n\nuse std::io;",
38 pass: false,
39 },
40 Example {
41 label: "file directive then blank then doc comment passes",
42 code: "// #t(file: rust_panic) startup binary\n\n//! Module docs.\n\nuse std::io;",
43 pass: true,
44 },
45 Example {
46 label: "mergeable directives with same reason",
47 code: "// #t(file: rust_panic) startup binary\n// #t(file: rust_unwrap_in_lib) startup binary\n\nuse std::io;",
48 pass: false,
49 },
50 Example {
51 label: "merged directives on one line",
52 code: "// #t(file: rust_panic, rust_unwrap_in_lib) startup binary\n\nuse std::io;",
53 pass: true,
54 },
55];
56
57crate::line_rule!(
58 tidy_directives,
59 "Enforce file-wide #t directives at top of file with a blank line separator.",
60 "File-wide tidy directives belong at the very top so they are immediately visible. A blank line after them visually separates configuration from code.",
61);
62
63const FILE_PREFIX: &str = "// #t(file:";
64const DIRECTIVE_PAIR_SIZE: usize = 2;
65
66use winnow::token::take_until;
67
68fn is_file_directive(trimmed: &str) -> bool {
69 crate::infra::parse::matches(trimmed, FILE_PREFIX)
70}
71
72fn extract_reason(line: &str) -> &str {
73 let trimmed = line.trim();
74 let mut input = trimmed;
75
76 if crate::infra::parse::try_parse(&mut input, take_until(0.., ")")).is_some()
77 && crate::infra::parse::try_parse(&mut input, ")").is_some()
78 {
79 return input.trim();
80 }
81
82 ""
83}
84
85fn check_tidy_directives(ctx: &FileCtx<'_>) -> Vec<Violation> {
86 let lines = ctx.lines;
87
88 if lines.is_empty() {
89 return Vec::new();
90 }
91
92 let file_directive_lines: Vec<usize> = lines
93 .iter()
94 .enumerate()
95 .filter_map(|(index, line)| is_file_directive(line.trim()).then_some(index))
96 .collect();
97
98 if file_directive_lines.is_empty() {
99 return Vec::new();
100 }
101
102 let header_end = directive_header_end(lines);
103 let mut violations = misplaced_directives(ctx.rel, &file_directive_lines, header_end);
104
105 violations.extend(duplicate_reason_violations(
106 ctx.rel,
107 lines,
108 &file_directive_lines,
109 ));
110
111 if let Some(violation) =
112 missing_separator_violation(ctx.rel, lines, &file_directive_lines, header_end)
113 {
114 violations.push(violation);
115 }
116
117 violations
118}
119
120fn directive_header_end(lines: &[&str]) -> usize {
121 lines
122 .iter()
123 .take_while(|line| {
124 let trimmed = line.trim();
125
126 trimmed.is_empty() || is_file_directive(trimmed)
127 })
128 .count()
129}
130
131fn misplaced_directives(rel: &str, directive_lines: &[usize], header_end: usize) -> Vec<Violation> {
132 directive_lines
133 .iter()
134 .filter(|&&line_idx| line_idx >= header_end)
135 .map(|&line_idx| {
136 crate::violation(
137 rel,
138 line_idx + 1,
139 "file-wide #t directive must be at the top of the file, before imports and code",
140 )
141 })
142 .collect()
143}
144
145fn duplicate_reason_violations(
147 rel: &str,
148 lines: &[&str],
149 directive_lines: &[usize],
150) -> Vec<Violation> {
151 let mut violations = Vec::new();
152
153 for window in directive_lines.windows(DIRECTIVE_PAIR_SIZE) {
154 let &[a, b] = window else { continue };
155 let consecutive = (a + 1..b).all(|i| {
156 lines.get(i).is_some_and(|line| {
157 let trimmed = line.trim();
158
159 trimmed.is_empty() || is_file_directive(trimmed)
160 })
161 });
162
163 if !consecutive {
164 continue;
165 }
166
167 let Some((reason_a, reason_b)) = lines
168 .get(a)
169 .zip(lines.get(b))
170 .map(|(a, b)| (extract_reason(a), extract_reason(b)))
171 else {
172 continue;
173 };
174
175 if !reason_a.is_empty() && reason_a == reason_b {
176 violations.push(crate::violation(
177 rel,
178 b + 1,
179 format!(
180 "file-wide #t directives with the same reason should be merged into one line (reason: {reason_a})"
181 ),
182 ));
183 }
184 }
185
186 violations
187}
188
189fn missing_separator_violation(
190 rel: &str,
191 lines: &[&str],
192 directive_lines: &[usize],
193 header_end: usize,
194) -> Option<Violation> {
195 let last_directive = directive_lines
196 .iter()
197 .copied()
198 .filter(|&i| i < header_end)
199 .max()?;
200 let content_idx =
201 lines
202 .iter()
203 .enumerate()
204 .skip(last_directive + 1)
205 .find_map(|(index, line)| {
206 let trimmed = line.trim();
207
208 (!trimmed.is_empty() && !is_file_directive(trimmed)).then_some(index)
209 })?;
210 let has_blank = (last_directive + 1..content_idx)
211 .any(|index| lines.get(index).is_some_and(|line| line.trim().is_empty()));
212
213 if has_blank {
214 None
215 } else {
216 Some(crate::violation(
217 rel,
218 content_idx + 1,
219 "missing blank line after file-wide #t directives",
220 ))
221 }
222}
223
224crate::tidy_test!(check_tidy_directives, {
225 crate::example_tests!(EXAMPLES, check_tidy_directives);
226});