Skip to main content

wowlab_tidy/languages/rust/rules/hygiene/
tidy_directives.rs

1use 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
145// #t(fn: rust_alloc_in_loop) each duplicate directive needs its own diagnostic message
146fn 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});