Skip to main content

tidy/
ui_tests.rs

1//! Tidy check to ensure below in UI test directories:
2//! - there are no stray `.stderr` files
3
4use std::collections::BTreeSet;
5use std::ffi::OsStr;
6use std::fs;
7use std::io::Write;
8use std::path::{Path, PathBuf};
9
10use crate::diagnostics::{CheckId, RunningCheck, TidyCtx};
11
12const ISSUES_TXT_HEADER: &str = r#"============================================================
13    ⚠️⚠️⚠️NOTHING SHOULD EVER BE ADDED TO THIS LIST⚠️⚠️⚠️
14============================================================
15"#;
16
17pub fn check(root_path: &Path, tidy_ctx: TidyCtx) {
18    let path = &root_path.join("tests");
19    let mut check = tidy_ctx.start_check(CheckId::new("ui_tests").path(path));
20    let bless = tidy_ctx.is_bless_enabled();
21
22    // the list of files in ui tests that are allowed to start with `issue-XXXX`
23    // BTreeSet because we would like a stable ordering so --bless works
24    let mut prev_line = "";
25    let mut is_sorted = true;
26    let allowed_issue_names: BTreeSet<_> = include_str!("issues.txt")
27        .strip_prefix(ISSUES_TXT_HEADER)
28        .unwrap()
29        .lines()
30        .inspect(|&line| {
31            if prev_line > line {
32                is_sorted = false;
33            }
34
35            prev_line = line;
36        })
37        .collect();
38
39    if !is_sorted && !bless {
40        check.error(
41            "`src/tools/tidy/src/issues.txt` is not in order, mostly because you modified it manually,
42            please only update it with command `x test tidy --bless`"
43        );
44    }
45
46    deny_new_top_level_ui_tests(&mut check, &path.join("ui"));
47
48    let remaining_issue_names = recursively_check_ui_tests(&mut check, path, &allowed_issue_names);
49
50    // if there are any file names remaining, they were moved on the fs.
51    // our data must remain up to date, so it must be removed from issues.txt
52    // do this automatically on bless, otherwise issue a tidy error
53    if bless && (!remaining_issue_names.is_empty() || !is_sorted) {
54        let tidy_src = root_path.join("src/tools/tidy/src");
55        // instead of overwriting the file, recreate it and use an "atomic rename"
56        // so we don't bork things on panic or a contributor using Ctrl+C
57        let blessed_issues_path = tidy_src.join("issues_blessed.txt");
58        let mut blessed_issues_txt = fs::File::create(&blessed_issues_path).unwrap();
59        blessed_issues_txt.write_all(ISSUES_TXT_HEADER.as_bytes()).unwrap();
60        // If we changed paths to use the OS separator, reassert Unix chauvinism for blessing.
61        for filename in allowed_issue_names.difference(&remaining_issue_names) {
62            writeln!(blessed_issues_txt, "{filename}").unwrap();
63        }
64        let old_issues_path = tidy_src.join("issues.txt");
65        fs::rename(blessed_issues_path, old_issues_path).unwrap();
66    } else {
67        for file_name in remaining_issue_names {
68            let mut p = PathBuf::from(path);
69            p.push(file_name);
70            check.error(format!(
71                "file `{}` no longer exists and should be removed from the exclusions in `src/tools/tidy/src/issues.txt`",
72                p.display()
73            ));
74        }
75    }
76
77    // The list of subdirectories in ui tests.
78    // Compare previous subdirectory with current subdirectory
79    // to sync with `tests/ui/README.md`.
80    // See <https://github.com/rust-lang/rust/issues/150399>
81    let mut prev_line = String::new();
82    let mut is_sorted = true;
83    let documented_subdirs: BTreeSet<_> = include_str!("../../../../tests/ui/README.md")
84        .lines()
85        .filter_map(|line| {
86            static_regex!(r"^##.*?`(?<dir>[^`]+)`").captures(line).map(|cap| {
87                let dir = &cap["dir"];
88                // FIXME(reddevilmidzy) normalize subdirs title in tests/ui/README.md
89                if dir.ends_with('/') {
90                    dir.strip_suffix('/').unwrap().to_string()
91                } else {
92                    dir.to_string()
93                }
94            })
95        })
96        .inspect(|line| {
97            if prev_line.as_str() > line.as_str() {
98                is_sorted = false;
99            }
100
101            prev_line = line.clone();
102        })
103        .collect();
104    let filesystem_subdirs = collect_ui_tests_subdirs(&path);
105    let is_modified = !filesystem_subdirs.eq(&documented_subdirs);
106
107    if !is_sorted {
108        check.error("`tests/ui/README.md` is not in order");
109    }
110    if is_modified {
111        for directory in documented_subdirs.symmetric_difference(&filesystem_subdirs) {
112            if documented_subdirs.contains(directory) {
113                check.error(format!(
114                               "ui subdirectory `{directory}` is listed in `tests/ui/README.md` but does not exist in the filesystem"
115                           ));
116            } else {
117                check.error(format!(
118                               "ui subdirectory `{directory}` exists in the filesystem but is not documented in `tests/ui/README.md`"
119                           ));
120            }
121        }
122        check.error(
123                   "`tests/ui/README.md` subdirectory listing is out of sync with the filesystem. \
124                    Please add or remove subdirectory entries (## headers with backtick-wrapped names) to match the actual directories in `tests/ui/`"
125               );
126    }
127}
128
129fn deny_new_top_level_ui_tests(check: &mut RunningCheck, tests_path: &Path) {
130    // See <https://github.com/rust-lang/compiler-team/issues/902> where we propose banning adding
131    // new ui tests *directly* under `tests/ui/`. For more context, see:
132    //
133    // - <https://github.com/rust-lang/rust/issues/73494>
134    // - <https://github.com/rust-lang/rust/issues/133895>
135
136    let top_level_ui_tests = ignore::WalkBuilder::new(tests_path)
137        .max_depth(Some(1))
138        .follow_links(false)
139        .build()
140        .flatten()
141        .filter(|e| {
142            let file_name = e.file_name();
143            file_name != ".gitattributes" && file_name != "README.md"
144        })
145        .filter(|e| !e.file_type().is_some_and(|f| f.is_dir()));
146
147    for entry in top_level_ui_tests {
148        check.error(format!(
149            "ui tests should be added under meaningful subdirectories: `{}`, see https://github.com/rust-lang/compiler-team/issues/902",
150            entry.path().display()
151        ));
152    }
153}
154
155fn recursively_check_ui_tests<'issues>(
156    check: &mut RunningCheck,
157    path: &Path,
158    allowed_issue_names: &'issues BTreeSet<&'issues str>,
159) -> BTreeSet<&'issues str> {
160    let mut remaining_issue_names: BTreeSet<&str> = allowed_issue_names.clone();
161
162    let (ui, ui_fulldeps) = (path.join("ui"), path.join("ui-fulldeps"));
163    let paths = [ui.as_path(), ui_fulldeps.as_path()];
164    crate::walk::walk_no_read(&paths, |_, _| false, &mut |entry| {
165        let file_path = entry.path();
166        if let Some(ext) = file_path.extension().and_then(OsStr::to_str) {
167            check_unexpected_extension(check, file_path, ext);
168
169            // NB: We do not use file_stem() as some file names have multiple `.`s and we
170            // must strip all of them.
171            let testname =
172                file_path.file_name().unwrap().to_str().unwrap().split_once('.').unwrap().0;
173            if ext == "stderr" || ext == "stdout" || ext == "fixed" {
174                check_stray_output_snapshot(check, file_path, testname);
175                check_empty_output_snapshot(check, file_path);
176            }
177
178            deny_new_nondescriptive_test_names(
179                check,
180                path,
181                &mut remaining_issue_names,
182                file_path,
183                testname,
184                ext,
185            );
186        }
187    });
188    remaining_issue_names
189}
190
191fn collect_ui_tests_subdirs(path: &Path) -> BTreeSet<String> {
192    let ui = path.join("ui");
193    let entries = std::fs::read_dir(ui.as_path()).unwrap();
194
195    entries
196        .filter_map(|entry| entry.ok())
197        .map(|entry| entry.path())
198        .filter(|path| path.is_dir())
199        .map(|dir_path| {
200            let dir_path = dir_path.strip_prefix(path).unwrap();
201            format!(
202                "tests/{}",
203                dir_path.to_string_lossy().replace(std::path::MAIN_SEPARATOR_STR, "/")
204            )
205        })
206        .collect()
207}
208
209fn check_unexpected_extension(check: &mut RunningCheck, file_path: &Path, ext: &str) {
210    const EXPECTED_TEST_FILE_EXTENSIONS: &[&str] = &[
211        "rs",     // test source files
212        "stderr", // expected stderr file, corresponds to a rs file
213        "svg",    // expected svg file, corresponds to a rs file, equivalent to stderr
214        "stdout", // expected stdout file, corresponds to a rs file
215        "fixed",  // expected source file after applying fixes
216        "md",     // test directory descriptions
217        "ftl",    // translation tests
218    ];
219
220    const EXTENSION_EXCEPTION_PATHS: &[&str] = &[
221        "tests/ui/asm/named-asm-labels.s", // loading an external asm file to test named labels lint
222        "tests/ui/asm/normalize-offsets-for-crlf.s", // loading an external asm file to test CRLF normalization
223        "tests/ui/codegen/mismatched-data-layout.json", // testing mismatched data layout w/ custom targets
224        "tests/ui/check-cfg/my-awesome-platform.json",  // testing custom targets with cfgs
225        "tests/ui/argfile/commandline-argfile-badutf8.args", // passing args via a file
226        "tests/ui/argfile/commandline-argfile.args",    // passing args via a file
227        "tests/ui/crate-loading/auxiliary/libfoo.rlib", // testing loading a manually created rlib
228        "tests/ui/include-macros/data.bin", // testing including data with the include macros
229        "tests/ui/include-macros/file.txt", // testing including data with the include macros
230        "tests/ui/include-macros/invalid-utf8-binary-file.bin", // testing including data with the include macros
231        "tests/ui/macros/macro-expanded-include/file.txt", // testing including data with the include macros
232        "tests/ui/macros/not-utf8.bin", // testing including data with the include macros
233        "tests/ui/macros/syntax-extension-source-utils-files/includeme.fragment", // more include
234        "tests/ui/proc-macro/auxiliary/included-file.txt", // more include
235        "tests/ui/unpretty/auxiliary/data.txt", // more include
236        "tests/ui/invalid/foo.natvis.xml", // sample debugger visualizer
237        "tests/ui/sanitizer/dataflow-abilist.txt", // dataflow sanitizer ABI list file
238        "tests/ui/shell-argfiles/shell-argfiles.args", // passing args via a file
239        "tests/ui/shell-argfiles/shell-argfiles-badquotes.args", // passing args via a file
240        "tests/ui/shell-argfiles/shell-argfiles-via-argfile-shell.args", // passing args via a file
241        "tests/ui/shell-argfiles/shell-argfiles-via-argfile.args", // passing args via a file
242        "tests/ui/std/windows-bat-args1.bat", // tests escaping arguments through batch files
243        "tests/ui/std/windows-bat-args2.bat", // tests escaping arguments through batch files
244        "tests/ui/std/windows-bat-args3.bat", // tests escaping arguments through batch files
245    ];
246
247    // files that are neither an expected extension or an exception should not exist
248    // they're probably typos or not meant to exist
249    if !(EXPECTED_TEST_FILE_EXTENSIONS.contains(&ext)
250        || EXTENSION_EXCEPTION_PATHS.iter().any(|path| file_path.ends_with(path)))
251    {
252        check.error(format!("file {} has unexpected extension {}", file_path.display(), ext));
253    }
254}
255
256fn check_stray_output_snapshot(check: &mut RunningCheck, file_path: &Path, testname: &str) {
257    // Test output filenames have one of the formats:
258    // ```
259    // $testname.stderr
260    // $testname.$mode.stderr
261    // $testname.$revision.stderr
262    // $testname.$revision.$mode.stderr
263    // ```
264    //
265    // For now, just make sure that there is a corresponding
266    // `$testname.rs` file.
267
268    if !file_path.with_file_name(testname).with_extension("rs").exists()
269        && !testname.contains("ignore-tidy")
270    {
271        check.error(format!("Stray file with UI testing output: {:?}", file_path));
272    }
273}
274
275fn check_empty_output_snapshot(check: &mut RunningCheck, file_path: &Path) {
276    if let Ok(metadata) = fs::metadata(file_path)
277        && metadata.len() == 0
278    {
279        check.error(format!("Empty file with UI testing output: {:?}", file_path));
280    }
281}
282
283fn deny_new_nondescriptive_test_names(
284    check: &mut RunningCheck,
285    path: &Path,
286    remaining_issue_names: &mut BTreeSet<&str>,
287    file_path: &Path,
288    testname: &str,
289    ext: &str,
290) {
291    if ext == "rs"
292        && let Some(test_name) = static_regex!(r"^issues?[-_]?(\d{3,})").captures(testname)
293    {
294        // these paths are always relative to the passed `path` and always UTF8
295        let stripped_path = file_path
296            .strip_prefix(path)
297            .unwrap()
298            .to_str()
299            .unwrap()
300            .replace(std::path::MAIN_SEPARATOR_STR, "/");
301
302        if !remaining_issue_names.remove(stripped_path.as_str())
303            && !stripped_path.starts_with("ui/issues/")
304        {
305            check.error(format!(
306                "issue-number-only test names are not descriptive, consider renaming file `tests/{stripped_path}` to `{{reason}}-issue-{issue_n}.rs`",
307                issue_n = &test_name[1],
308            ));
309        }
310    }
311}