use std::{ collections::HashSet, path::{Path, PathBuf}, }; use xshell::Shell; #[cfg(not(feature = "in-rust-tree"))] use xshell::cmd; #[cfg(not(feature = "in-rust-tree"))] #[test] fn check_code_formatting() { let sh = &Shell::new().unwrap(); sh.change_dir(sourcegen::project_root()); let out = cmd!(sh, "rustup run stable rustfmt --version").read().unwrap(); if !out.contains("stable") { panic!( "Failed to run rustfmt from toolchain 'stable'. \ Please run `rustup component add rustfmt --toolchain stable` to install it.", ) } let res = cmd!(sh, "rustup run stable cargo fmt -- --check").run(); if res.is_err() { let _ = cmd!(sh, "rustup run stable cargo fmt").run(); } res.unwrap() } #[test] fn check_lsp_extensions_docs() { let sh = &Shell::new().unwrap(); let expected_hash = { let lsp_ext_rs = sh .read_file(sourcegen::project_root().join("crates/rust-analyzer/src/lsp/ext.rs")) .unwrap(); stable_hash(lsp_ext_rs.as_str()) }; let actual_hash = { let lsp_extensions_md = sh.read_file(sourcegen::project_root().join("docs/dev/lsp-extensions.md")).unwrap(); let text = lsp_extensions_md .lines() .find_map(|line| line.strip_prefix("lsp/ext.rs hash:")) .unwrap() .trim(); u64::from_str_radix(text, 16).unwrap() }; if actual_hash != expected_hash { panic!( " lsp/ext.rs was changed without touching lsp-extensions.md. Expected hash: {expected_hash:x} Actual hash: {actual_hash:x} Please adjust docs/dev/lsp-extensions.md. " ) } } #[test] fn files_are_tidy() { let sh = &Shell::new().unwrap(); let files = sourcegen::list_files(&sourcegen::project_root().join("crates")); let mut tidy_docs = TidyDocs::default(); let mut tidy_marks = TidyMarks::default(); for path in files { let extension = path.extension().unwrap_or_default().to_str().unwrap_or_default(); match extension { "rs" => { let text = sh.read_file(&path).unwrap(); check_todo(&path, &text); check_dbg(&path, &text); check_test_attrs(&path, &text); check_trailing_ws(&path, &text); tidy_docs.visit(&path, &text); tidy_marks.visit(&path, &text); } "toml" => { let text = sh.read_file(&path).unwrap(); check_cargo_toml(&path, text); } _ => (), } } tidy_docs.finish(); tidy_marks.finish(); } fn check_cargo_toml(path: &Path, text: String) { let mut section = None; for (line_no, text) in text.lines().enumerate() { let text = text.trim(); if text.starts_with('[') { if !text.ends_with(']') { panic!( "\nplease don't add comments or trailing whitespace in section lines.\n\ {}:{}\n", path.display(), line_no + 1 ) } section = Some(text); continue; } let text: String = text.split_whitespace().collect(); if !text.contains("path=") { continue; } match section { Some(s) if s.contains("dev-dependencies") => { if text.contains("version") { panic!( "\ncargo internal dev-dependencies should not have a version.\n\ {}:{}\n", path.display(), line_no + 1 ); } } Some(s) if s.contains("dependencies") => { if !text.contains("version") { panic!( "\ncargo internal dependencies should have a version.\n\ {}:{}\n", path.display(), line_no + 1 ); } } _ => {} } } } #[cfg(not(feature = "in-rust-tree"))] #[test] fn check_licenses() { let sh = &Shell::new().unwrap(); let expected = " (MIT OR Apache-2.0) AND Unicode-DFS-2016 0BSD OR MIT OR Apache-2.0 Apache-2.0 Apache-2.0 OR BSL-1.0 Apache-2.0 OR MIT Apache-2.0 WITH LLVM-exception Apache-2.0 WITH LLVM-exception OR Apache-2.0 OR MIT Apache-2.0/MIT BSD-3-Clause CC0-1.0 ISC MIT MIT / Apache-2.0 MIT OR Apache-2.0 MIT OR Apache-2.0 OR Zlib MIT OR Zlib OR Apache-2.0 MIT/Apache-2.0 Unlicense OR MIT Unlicense/MIT Zlib OR Apache-2.0 OR MIT " .lines() .filter(|it| !it.is_empty()) .collect::>(); let meta = cmd!(sh, "cargo metadata --format-version 1").read().unwrap(); let mut licenses = meta .split(|c| c == ',' || c == '{' || c == '}') .filter(|it| it.contains(r#""license""#)) .map(|it| it.trim()) .map(|it| it[r#""license":"#.len()..].trim_matches('"')) .collect::>(); licenses.sort_unstable(); licenses.dedup(); if licenses != expected { let mut diff = String::new(); diff.push_str("New Licenses:\n"); for &l in licenses.iter() { if !expected.contains(&l) { diff += &format!(" {l}\n") } } diff.push_str("\nMissing Licenses:\n"); for &l in expected.iter() { if !licenses.contains(&l) { diff += &format!(" {l}\n") } } panic!("different set of licenses!\n{diff}"); } assert_eq!(licenses, expected); } fn check_todo(path: &Path, text: &str) { let need_todo = &[ // This file itself obviously needs to use todo (<- like this!). "tests/tidy.rs", // Some of our assists generate `todo!()`. "handlers/add_turbo_fish.rs", "handlers/generate_function.rs", "handlers/add_missing_match_arms.rs", "handlers/replace_derive_with_manual_impl.rs", // To support generating `todo!()` in assists, we have `expr_todo()` in // `ast::make`. "ast/make.rs", // The documentation in string literals may contain anything for its own purposes "ide-db/src/generated/lints.rs", "ide-assists/src/utils/gen_trait_fn_body.rs", "ide-assists/src/tests/generated.rs", // The tests for missing fields "ide-diagnostics/src/handlers/missing_fields.rs", ]; if need_todo.iter().any(|p| path.ends_with(p)) { return; } if text.contains("TODO") || text.contains("TOOD") || text.contains("todo!") { // Generated by an assist if text.contains("${0:todo!()}") { return; } panic!( "\nTODO markers or todo! macros should not be committed to the master branch,\n\ use FIXME instead\n\ {}\n", path.display(), ) } } fn check_dbg(path: &Path, text: &str) { let need_dbg = &[ // This file itself obviously needs to use dbg. "slow-tests/tidy.rs", // Assists to remove `dbg!()` "handlers/remove_dbg.rs", // We have .dbg postfix "ide-completion/src/completions/postfix.rs", "ide-completion/src/completions/keyword.rs", "ide-completion/src/tests/expression.rs", "ide-completion/src/tests/proc_macros.rs", // The documentation in string literals may contain anything for its own purposes "ide-completion/src/lib.rs", "ide-db/src/generated/lints.rs", // test for doc test for remove_dbg "src/tests/generated.rs", // `expect!` string can contain `dbg!` (due to .dbg postfix) "ide-completion/src/tests/special.rs", ]; if need_dbg.iter().any(|p| path.ends_with(p)) { return; } if text.contains("dbg!") { panic!( "\ndbg! macros should not be committed to the master branch,\n\ {}\n", path.display(), ) } } fn check_test_attrs(path: &Path, text: &str) { let ignore_rule = "https://github.com/rust-lang/rust-analyzer/blob/master/docs/dev/style.md#ignore"; let need_ignore: &[&str] = &[ // This file. "slow-tests/tidy.rs", // Special case to run `#[ignore]` tests. "ide/src/runnables.rs", // A legit test which needs to be ignored, as it takes too long to run // :( "hir-def/src/nameres/collector.rs", // Long sourcegen test to generate lint completions. "ide-db/src/tests/sourcegen_lints.rs", // Obviously needs ignore. "ide-assists/src/handlers/toggle_ignore.rs", // See above. "ide-assists/src/tests/generated.rs", ]; if text.contains("#[ignore") && !need_ignore.iter().any(|p| path.ends_with(p)) { panic!("\ndon't `#[ignore]` tests, see:\n\n {ignore_rule}\n\n {}\n", path.display(),) } let panic_rule = "https://github.com/rust-lang/rust-analyzer/blob/master/docs/dev/style.md#should_panic"; let need_panic: &[&str] = &[ // This file. "slow-tests/tidy.rs", "test-utils/src/fixture.rs", // Generated code from lints contains doc tests in string literals. "ide-db/src/generated/lints.rs", ]; if text.contains("#[should_panic") && !need_panic.iter().any(|p| path.ends_with(p)) { panic!( "\ndon't add `#[should_panic]` tests, see:\n\n {}\n\n {}\n", panic_rule, path.display(), ) } } fn check_trailing_ws(path: &Path, text: &str) { if is_exclude_dir(path, &["test_data"]) { return; } for (line_number, line) in text.lines().enumerate() { if line.chars().last().is_some_and(char::is_whitespace) { panic!("Trailing whitespace in {} at line {}", path.display(), line_number + 1) } } } #[derive(Default)] struct TidyDocs { missing_docs: Vec, contains_fixme: Vec, } impl TidyDocs { fn visit(&mut self, path: &Path, text: &str) { // Tests and diagnostic fixes don't need module level comments. if is_exclude_dir(path, &["tests", "test_data", "fixes", "grammar"]) { return; } if is_exclude_file(path) { return; } let first_line = match text.lines().next() { Some(it) => it, None => return, }; if first_line.starts_with("//!") { if first_line.contains("FIXME") { self.contains_fixme.push(path.to_path_buf()); } } else { if text.contains("// Feature:") || text.contains("// Assist:") || text.contains("// Diagnostic:") { return; } self.missing_docs.push(path.display().to_string()); } fn is_exclude_file(d: &Path) -> bool { let file_names = ["tests.rs", "famous_defs_fixture.rs"]; d.file_name() .unwrap_or_default() .to_str() .map(|f_n| file_names.iter().any(|name| *name == f_n)) .unwrap_or(false) } } fn finish(self) { if !self.missing_docs.is_empty() { panic!( "\nMissing docs strings\n\n\ modules:\n{}\n\n", self.missing_docs.join("\n") ) } if let Some(path) = self.contains_fixme.first() { panic!("FIXME doc in a fully-documented crate: {}", path.display()) } } } fn is_exclude_dir(p: &Path, dirs_to_exclude: &[&str]) -> bool { p.strip_prefix(sourcegen::project_root()) .unwrap() .components() .rev() .skip(1) .filter_map(|it| it.as_os_str().to_str()) .any(|it| dirs_to_exclude.contains(&it)) } #[derive(Default)] struct TidyMarks { hits: HashSet, checks: HashSet, } impl TidyMarks { fn visit(&mut self, _path: &Path, text: &str) { find_marks(&mut self.hits, text, "hit"); find_marks(&mut self.checks, text, "check"); find_marks(&mut self.checks, text, "check_count"); } fn finish(self) { assert!(!self.hits.is_empty()); let diff: Vec<_> = self.hits.symmetric_difference(&self.checks).map(|it| it.as_str()).collect(); if !diff.is_empty() { panic!("unpaired marks: {diff:?}") } } } #[allow(deprecated)] fn stable_hash(text: &str) -> u64 { use std::hash::{Hash, Hasher, SipHasher}; let text = text.replace('\r', ""); let mut hasher = SipHasher::default(); text.hash(&mut hasher); hasher.finish() } fn find_marks(set: &mut HashSet, text: &str, mark: &str) { let mut text = text; let mut prev_text = ""; while text != prev_text { prev_text = text; if let Some(idx) = text.find(mark) { text = &text[idx + mark.len()..]; if let Some(stripped_text) = text.strip_prefix("!(") { text = stripped_text.trim_start(); if let Some(idx2) = text.find(|c: char| !(c.is_alphanumeric() || c == '_')) { let mark_text = &text[..idx2]; set.insert(mark_text.to_string()); text = &text[idx2..]; } } } } }