1 use std::{
2 collections::HashSet,
3 path::{Path, PathBuf},
4 };
5
6 use xshell::{cmd, pushd, pushenv, read_file};
7
8 #[test]
check_code_formatting()9 fn check_code_formatting() {
10 let _dir = pushd(sourcegen::project_root()).unwrap();
11 let _e = pushenv("RUSTUP_TOOLCHAIN", "stable");
12
13 let out = cmd!("rustfmt --version").read().unwrap();
14 if !out.contains("stable") {
15 panic!(
16 "Failed to run rustfmt from toolchain 'stable'. \
17 Please run `rustup component add rustfmt --toolchain stable` to install it.",
18 )
19 }
20
21 let res = cmd!("cargo fmt -- --check").run();
22 if res.is_err() {
23 let _ = cmd!("cargo fmt").run();
24 }
25 res.unwrap()
26 }
27
28 #[test]
check_lsp_extensions_docs()29 fn check_lsp_extensions_docs() {
30 let expected_hash = {
31 let lsp_ext_rs =
32 read_file(sourcegen::project_root().join("crates/rust-analyzer/src/lsp_ext.rs"))
33 .unwrap();
34 stable_hash(lsp_ext_rs.as_str())
35 };
36
37 let actual_hash = {
38 let lsp_extensions_md =
39 read_file(sourcegen::project_root().join("docs/dev/lsp-extensions.md")).unwrap();
40 let text = lsp_extensions_md
41 .lines()
42 .find_map(|line| line.strip_prefix("lsp_ext.rs hash:"))
43 .unwrap()
44 .trim();
45 u64::from_str_radix(text, 16).unwrap()
46 };
47
48 if actual_hash != expected_hash {
49 panic!(
50 "
51 lsp_ext.rs was changed without touching lsp-extensions.md.
52
53 Expected hash: {:x}
54 Actual hash: {:x}
55
56 Please adjust docs/dev/lsp-extensions.md.
57 ",
58 expected_hash, actual_hash
59 )
60 }
61 }
62
63 #[test]
files_are_tidy()64 fn files_are_tidy() {
65 let files = sourcegen::list_files(&sourcegen::project_root().join("crates"));
66
67 let mut tidy_docs = TidyDocs::default();
68 let mut tidy_marks = TidyMarks::default();
69 for path in files {
70 let extension = path.extension().unwrap_or_default().to_str().unwrap_or_default();
71 match extension {
72 "rs" => {
73 let text = read_file(&path).unwrap();
74 check_todo(&path, &text);
75 check_dbg(&path, &text);
76 check_test_attrs(&path, &text);
77 check_trailing_ws(&path, &text);
78 deny_clippy(&path, &text);
79 tidy_docs.visit(&path, &text);
80 tidy_marks.visit(&path, &text);
81 }
82 "toml" => {
83 let text = read_file(&path).unwrap();
84 check_cargo_toml(&path, text);
85 }
86 _ => (),
87 }
88 }
89
90 tidy_docs.finish();
91 tidy_marks.finish();
92 }
93
check_cargo_toml(path: &Path, text: String)94 fn check_cargo_toml(path: &Path, text: String) {
95 let mut section = None;
96 for (line_no, text) in text.lines().enumerate() {
97 let text = text.trim();
98 if text.starts_with('[') {
99 if !text.ends_with(']') {
100 panic!(
101 "\nplease don't add comments or trailing whitespace in section lines.\n\
102 {}:{}\n",
103 path.display(),
104 line_no + 1
105 )
106 }
107 section = Some(text);
108 continue;
109 }
110 let text: String = text.split_whitespace().collect();
111 if !text.contains("path=") {
112 continue;
113 }
114 match section {
115 Some(s) if s.contains("dev-dependencies") => {
116 if text.contains("version") {
117 panic!(
118 "\ncargo internal dev-dependencies should not have a version.\n\
119 {}:{}\n",
120 path.display(),
121 line_no + 1
122 );
123 }
124 }
125 Some(s) if s.contains("dependencies") => {
126 if !text.contains("version") {
127 panic!(
128 "\ncargo internal dependencies should have a version.\n\
129 {}:{}\n",
130 path.display(),
131 line_no + 1
132 );
133 }
134 }
135 _ => {}
136 }
137 }
138 }
139
140 #[test]
check_merge_commits()141 fn check_merge_commits() {
142 let stdout = cmd!("git rev-list --merges --invert-grep --author 'bors\\[bot\\]' HEAD~19..")
143 .read()
144 .unwrap();
145 if !stdout.is_empty() {
146 panic!(
147 "
148 Merge commits are not allowed in the history.
149
150 When updating a pull-request, please rebase your feature branch
151 on top of master by running `git rebase master`. If rebase fails,
152 you can re-apply your changes like this:
153
154 # Just look around to see the current state.
155 $ git status
156 $ git log
157
158 # Abort in-progress rebase and merges, if any.
159 $ git rebase --abort
160 $ git merge --abort
161
162 # Make the branch point to the latest commit from master,
163 # while maintaining your local changes uncommited.
164 $ git reset --soft origin/master
165
166 # Commit all changes in a single batch.
167 $ git commit -am'My changes'
168
169 # Verify that everything looks alright.
170 $ git status
171 $ git log
172
173 # Push the changes. We did a rebase, so we need `--force` option.
174 # `--force-with-lease` is a more safe (Rusty) version of `--force`.
175 $ git push --force-with-lease
176
177 # Verify that both local and remote branch point to the same commit.
178 $ git log
179
180 And don't fear to mess something up during a rebase -- you can
181 always restore the previous state using `git ref-log`:
182
183 https://github.blog/2015-06-08-how-to-undo-almost-anything-with-git/#redo-after-undo-local
184 "
185 );
186 }
187 }
188
deny_clippy(path: &Path, text: &str)189 fn deny_clippy(path: &Path, text: &str) {
190 let ignore = &[
191 // The documentation in string literals may contain anything for its own purposes
192 "ide_db/src/helpers/generated_lints.rs",
193 // The tests test clippy lint hovers
194 "ide/src/hover/tests.rs",
195 // The tests test clippy lint completions
196 "ide_completion/src/tests/attribute.rs",
197 ];
198 if ignore.iter().any(|p| path.ends_with(p)) {
199 return;
200 }
201
202 if text.contains("\u{61}llow(clippy") {
203 panic!(
204 "\n\nallowing lints is forbidden: {}.
205 rust-analyzer intentionally doesn't check clippy on CI.
206 You can allow lint globally via `xtask clippy`.
207 See https://github.com/rust-lang/rust-clippy/issues/5537 for discussion.
208
209 ",
210 path.display()
211 )
212 }
213 }
214
215 #[test]
check_licenses()216 fn check_licenses() {
217 let expected = "
218 0BSD OR MIT OR Apache-2.0
219 Apache-2.0
220 Apache-2.0 OR BSL-1.0
221 Apache-2.0 OR MIT
222 Apache-2.0/MIT
223 BSD-3-Clause
224 CC0-1.0 OR Artistic-2.0
225 ISC
226 MIT
227 MIT / Apache-2.0
228 MIT OR Apache-2.0
229 MIT OR Apache-2.0 OR Zlib
230 MIT OR Zlib OR Apache-2.0
231 MIT/Apache-2.0
232 Unlicense OR MIT
233 Unlicense/MIT
234 Zlib OR Apache-2.0 OR MIT
235 "
236 .lines()
237 .filter(|it| !it.is_empty())
238 .collect::<Vec<_>>();
239
240 let meta = cmd!("cargo metadata --format-version 1").read().unwrap();
241 let mut licenses = meta
242 .split(|c| c == ',' || c == '{' || c == '}')
243 .filter(|it| it.contains(r#""license""#))
244 .map(|it| it.trim())
245 .map(|it| it[r#""license":"#.len()..].trim_matches('"'))
246 .collect::<Vec<_>>();
247 licenses.sort_unstable();
248 licenses.dedup();
249 if licenses != expected {
250 let mut diff = String::new();
251
252 diff.push_str("New Licenses:\n");
253 for &l in licenses.iter() {
254 if !expected.contains(&l) {
255 diff += &format!(" {}\n", l)
256 }
257 }
258
259 diff.push_str("\nMissing Licenses:\n");
260 for &l in expected.iter() {
261 if !licenses.contains(&l) {
262 diff += &format!(" {}\n", l)
263 }
264 }
265
266 panic!("different set of licenses!\n{}", diff);
267 }
268 assert_eq!(licenses, expected);
269 }
270
check_todo(path: &Path, text: &str)271 fn check_todo(path: &Path, text: &str) {
272 let need_todo = &[
273 // This file itself obviously needs to use todo (<- like this!).
274 "tests/tidy.rs",
275 // Some of our assists generate `todo!()`.
276 "handlers/add_turbo_fish.rs",
277 "handlers/generate_function.rs",
278 "handlers/add_missing_match_arms.rs",
279 "handlers/replace_derive_with_manual_impl.rs",
280 // To support generating `todo!()` in assists, we have `expr_todo()` in
281 // `ast::make`.
282 "ast/make.rs",
283 // The documentation in string literals may contain anything for its own purposes
284 "ide_db/src/helpers/generated_lints.rs",
285 "ide_assists/src/utils/gen_trait_fn_body.rs",
286 "ide_assists/src/tests/generated.rs",
287 // The tests for missing fields
288 "ide_diagnostics/src/handlers/missing_fields.rs",
289 ];
290 if need_todo.iter().any(|p| path.ends_with(p)) {
291 return;
292 }
293 if text.contains("TODO") || text.contains("TOOD") || text.contains("todo!") {
294 // Generated by an assist
295 if text.contains("${0:todo!()}") {
296 return;
297 }
298
299 panic!(
300 "\nTODO markers or todo! macros should not be committed to the master branch,\n\
301 use FIXME instead\n\
302 {}\n",
303 path.display(),
304 )
305 }
306 }
307
check_dbg(path: &Path, text: &str)308 fn check_dbg(path: &Path, text: &str) {
309 let need_dbg = &[
310 // This file itself obviously needs to use dbg.
311 "slow-tests/tidy.rs",
312 // Assists to remove `dbg!()`
313 "handlers/remove_dbg.rs",
314 // We have .dbg postfix
315 "ide_completion/src/completions/postfix.rs",
316 "ide_completion/src/completions/keyword.rs",
317 "ide_completion/src/tests/proc_macros.rs",
318 // The documentation in string literals may contain anything for its own purposes
319 "ide_completion/src/lib.rs",
320 "ide_db/src/helpers/generated_lints.rs",
321 // test for doc test for remove_dbg
322 "src/tests/generated.rs",
323 ];
324 if need_dbg.iter().any(|p| path.ends_with(p)) {
325 return;
326 }
327 if text.contains("dbg!") {
328 panic!(
329 "\ndbg! macros should not be committed to the master branch,\n\
330 {}\n",
331 path.display(),
332 )
333 }
334 }
335
check_test_attrs(path: &Path, text: &str)336 fn check_test_attrs(path: &Path, text: &str) {
337 let ignore_rule =
338 "https://github.com/rust-analyzer/rust-analyzer/blob/master/docs/dev/style.md#ignore";
339 let need_ignore: &[&str] = &[
340 // This file.
341 "slow-tests/tidy.rs",
342 // Special case to run `#[ignore]` tests.
343 "ide/src/runnables.rs",
344 // A legit test which needs to be ignored, as it takes too long to run
345 // :(
346 "hir_def/src/nameres/collector.rs",
347 // Long sourcegen test to generate lint completions.
348 "ide_db/src/tests/sourcegen_lints.rs",
349 // Obviously needs ignore.
350 "ide_assists/src/handlers/toggle_ignore.rs",
351 // See above.
352 "ide_assists/src/tests/generated.rs",
353 ];
354 if text.contains("#[ignore") && !need_ignore.iter().any(|p| path.ends_with(p)) {
355 panic!("\ndon't `#[ignore]` tests, see:\n\n {}\n\n {}\n", ignore_rule, path.display(),)
356 }
357
358 let panic_rule =
359 "https://github.com/rust-analyzer/rust-analyzer/blob/master/docs/dev/style.md#should_panic";
360 let need_panic: &[&str] = &[
361 // This file.
362 "slow-tests/tidy.rs",
363 "test_utils/src/fixture.rs",
364 ];
365 if text.contains("#[should_panic") && !need_panic.iter().any(|p| path.ends_with(p)) {
366 panic!(
367 "\ndon't add `#[should_panic]` tests, see:\n\n {}\n\n {}\n",
368 panic_rule,
369 path.display(),
370 )
371 }
372 }
373
check_trailing_ws(path: &Path, text: &str)374 fn check_trailing_ws(path: &Path, text: &str) {
375 if is_exclude_dir(path, &["test_data"]) {
376 return;
377 }
378 for (line_number, line) in text.lines().enumerate() {
379 if line.chars().last().map(char::is_whitespace) == Some(true) {
380 panic!("Trailing whitespace in {} at line {}", path.display(), line_number + 1)
381 }
382 }
383 }
384
385 #[derive(Default)]
386 struct TidyDocs {
387 missing_docs: Vec<String>,
388 contains_fixme: Vec<PathBuf>,
389 }
390
391 impl TidyDocs {
visit(&mut self, path: &Path, text: &str)392 fn visit(&mut self, path: &Path, text: &str) {
393 // Tests and diagnostic fixes don't need module level comments.
394 if is_exclude_dir(path, &["tests", "test_data", "fixes", "grammar"]) {
395 return;
396 }
397
398 if is_exclude_file(path) {
399 return;
400 }
401
402 let first_line = match text.lines().next() {
403 Some(it) => it,
404 None => return,
405 };
406
407 if first_line.starts_with("//!") {
408 if first_line.contains("FIXME") {
409 self.contains_fixme.push(path.to_path_buf());
410 }
411 } else {
412 if text.contains("// Feature:")
413 || text.contains("// Assist:")
414 || text.contains("// Diagnostic:")
415 {
416 return;
417 }
418 self.missing_docs.push(path.display().to_string());
419 }
420
421 fn is_exclude_file(d: &Path) -> bool {
422 let file_names = ["tests.rs", "famous_defs_fixture.rs"];
423
424 d.file_name()
425 .unwrap_or_default()
426 .to_str()
427 .map(|f_n| file_names.iter().any(|name| *name == f_n))
428 .unwrap_or(false)
429 }
430 }
431
finish(self)432 fn finish(self) {
433 if !self.missing_docs.is_empty() {
434 panic!(
435 "\nMissing docs strings\n\n\
436 modules:\n{}\n\n",
437 self.missing_docs.join("\n")
438 )
439 }
440
441 for path in self.contains_fixme {
442 panic!("FIXME doc in a fully-documented crate: {}", path.display())
443 }
444 }
445 }
446
is_exclude_dir(p: &Path, dirs_to_exclude: &[&str]) -> bool447 fn is_exclude_dir(p: &Path, dirs_to_exclude: &[&str]) -> bool {
448 p.strip_prefix(sourcegen::project_root())
449 .unwrap()
450 .components()
451 .rev()
452 .skip(1)
453 .filter_map(|it| it.as_os_str().to_str())
454 .any(|it| dirs_to_exclude.contains(&it))
455 }
456
457 #[derive(Default)]
458 struct TidyMarks {
459 hits: HashSet<String>,
460 checks: HashSet<String>,
461 }
462
463 impl TidyMarks {
visit(&mut self, _path: &Path, text: &str)464 fn visit(&mut self, _path: &Path, text: &str) {
465 for line in text.lines() {
466 if let Some(mark) = find_mark(line, "hit") {
467 self.hits.insert(mark.to_string());
468 }
469 if let Some(mark) = find_mark(line, "check") {
470 self.checks.insert(mark.to_string());
471 }
472 if let Some(mark) = find_mark(line, "check_count") {
473 self.checks.insert(mark.to_string());
474 }
475 }
476 }
477
finish(self)478 fn finish(self) {
479 assert!(!self.hits.is_empty());
480
481 let diff: Vec<_> =
482 self.hits.symmetric_difference(&self.checks).map(|it| it.as_str()).collect();
483
484 if !diff.is_empty() {
485 panic!("unpaired marks: {:?}", diff)
486 }
487 }
488 }
489
490 #[allow(deprecated)]
stable_hash(text: &str) -> u64491 fn stable_hash(text: &str) -> u64 {
492 use std::hash::{Hash, Hasher, SipHasher};
493
494 let text = text.replace('\r', "");
495 let mut hasher = SipHasher::default();
496 text.hash(&mut hasher);
497 hasher.finish()
498 }
499
find_mark<'a>(text: &'a str, mark: &'static str) -> Option<&'a str>500 fn find_mark<'a>(text: &'a str, mark: &'static str) -> Option<&'a str> {
501 let idx = text.find(mark)?;
502 let text = text[idx + mark.len()..].strip_prefix("!(")?;
503 let idx = text.find(|c: char| !(c.is_alphanumeric() || c == '_'))?;
504 let text = &text[..idx];
505 Some(text)
506 }
507