1 // Copyright (c) The Limenka developers
2 // Distributed under the MIT software license, see the accompanying
3 // file COPYING or https://opensource.org/license/mit/.
4 5 use std::env;
6 use std::fs;
7 use std::io::ErrorKind;
8 use std::path::PathBuf;
9 use std::process::{Command, ExitCode, Stdio};
10 11 /// A possible error returned by any of the linters.
12 ///
13 /// The error string should explain the failure type and list all violations.
14 type LintError = String;
15 type LintResult = Result<(), LintError>;
16 type LintFn = fn() -> LintResult;
17 18 struct Linter {
19 pub description: &'static str,
20 pub name: &'static str,
21 pub lint_fn: LintFn,
22 }
23 24 fn get_linter_list() -> Vec<&'static Linter> {
25 vec![
26 &Linter {
27 description: "Check that all command line arguments are documented.",
28 name: "doc",
29 lint_fn: lint_doc
30 },
31 &Linter {
32 description: "Check that no symbol from limenka-build-config.h is used without the header being included",
33 name: "includes_build_config",
34 lint_fn: lint_includes_build_config
35 },
36 &Linter {
37 description: "Check that markdown links resolve",
38 name: "markdown",
39 lint_fn: lint_markdown
40 },
41 &Linter {
42 description: "Lint Python code",
43 name: "py_lint",
44 lint_fn: lint_py_lint,
45 },
46 &Linter {
47 description: "Check that std::filesystem is not used directly",
48 name: "std_filesystem",
49 lint_fn: lint_std_filesystem
50 },
51 &Linter {
52 description: "Check that fatal assertions are not used in RPC code",
53 name: "rpc_assert",
54 lint_fn: lint_rpc_assert
55 },
56 &Linter {
57 description: "Check that boost assertions are not used",
58 name: "boost_assert",
59 lint_fn: lint_boost_assert
60 },
61 &Linter {
62 description: "Check that release note snippets are in the right folder",
63 name: "doc_release_note_snippets",
64 lint_fn: lint_doc_release_note_snippets
65 },
66 &Linter {
67 description: "Check that subtrees are pure subtrees",
68 name: "subtree",
69 lint_fn: lint_subtree
70 },
71 &Linter {
72 description: "Check scripted-diffs",
73 name: "scripted_diff",
74 lint_fn: lint_scripted_diff
75 },
76 &Linter {
77 description: "Check that commit messages have a new line before the body or no body at all.",
78 name: "commit_msg",
79 lint_fn: lint_commit_msg
80 },
81 &Linter {
82 description: "Check that tabs are not used as whitespace",
83 name: "tabs_whitespace",
84 lint_fn: lint_tabs_whitespace
85 },
86 &Linter {
87 description: "Check for trailing whitespace",
88 name: "trailing_whitespace",
89 lint_fn: lint_trailing_whitespace
90 },
91 &Linter {
92 description: "Run all linters of the form: test/lint/lint-*.py",
93 name: "all_python_linters",
94 lint_fn: run_all_python_linters
95 },
96 ]
97 }
98 99 fn print_help_and_exit() {
100 print!(
101 r#"
102 Usage: test_runner [--lint=LINTER_TO_RUN]
103 Runs all linters in the lint test suite, printing any errors
104 they detect.
105 106 If you wish to only run some particular lint tests, pass
107 '--lint=' with the name of the lint test you wish to run.
108 You can set as many '--lint=' values as you wish, e.g.:
109 test_runner --lint=doc --lint=subtree
110 111 The individual linters available to run are:
112 "#
113 );
114 for linter in get_linter_list() {
115 println!("{}: \"{}\"", linter.name, linter.description)
116 }
117 118 std::process::exit(1);
119 }
120 121 fn parse_lint_args(args: &[String]) -> Vec<&'static Linter> {
122 let linter_list = get_linter_list();
123 let mut lint_values = Vec::new();
124 125 for arg in args {
126 #[allow(clippy::if_same_then_else)]
127 if arg.starts_with("--lint=") {
128 let lint_arg_value = arg
129 .trim_start_matches("--lint=")
130 .trim_matches('"')
131 .trim_matches('\'');
132 133 let try_find_linter = linter_list
134 .iter()
135 .find(|linter| linter.name == lint_arg_value);
136 match try_find_linter {
137 Some(linter) => {
138 lint_values.push(*linter);
139 }
140 None => {
141 println!("No linter {lint_arg_value} found!");
142 print_help_and_exit();
143 }
144 }
145 } else if arg.eq("--help") || arg.eq("-h") {
146 print_help_and_exit();
147 } else {
148 print_help_and_exit();
149 }
150 }
151 152 lint_values
153 }
154 155 /// Return the git command
156 ///
157 /// Lint functions should use this command, so that only files tracked by git are considered and
158 /// temporary and untracked files are ignored. For example, instead of 'grep', 'git grep' should be
159 /// used.
160 fn git() -> Command {
161 let mut git = Command::new("git");
162 git.arg("--no-pager");
163 git
164 }
165 166 /// Return stdout on success and a LintError on failure, when invalid UTF8 was detected or the
167 /// command did not succeed.
168 fn check_output(cmd: &mut std::process::Command) -> Result<String, LintError> {
169 let out = cmd.output().expect("command error");
170 if !out.status.success() {
171 return Err(String::from_utf8_lossy(&out.stderr).to_string());
172 }
173 Ok(String::from_utf8(out.stdout)
174 .map_err(|e| {
175 format!("All path names, source code, messages, and output must be valid UTF8!\n{e}")
176 })?
177 .trim()
178 .to_string())
179 }
180 181 /// Return the git root as utf8, or panic
182 fn get_git_root() -> PathBuf {
183 PathBuf::from(check_output(git().args(["rev-parse", "--show-toplevel"])).unwrap())
184 }
185 186 /// Return the commit range, or panic
187 fn commit_range() -> String {
188 // Use the env var, if set. E.g. COMMIT_RANGE='HEAD~n..HEAD' for the last 'n' commits.
189 env::var("COMMIT_RANGE").unwrap_or_else(|_| {
190 // Otherwise, assume that a merge commit exists. This merge commit is assumed
191 // to be the base, after which linting will be done. If the merge commit is
192 // HEAD, the range will be empty.
193 format!(
194 "{}..HEAD",
195 check_output(git().args(["rev-list", "--max-count=1", "--merges", "HEAD"]))
196 .expect("check_output failed")
197 )
198 })
199 }
200 201 /// Return all subtree paths
202 fn get_subtrees() -> Vec<&'static str> {
203 vec![
204 "src/crc32c",
205 "src/crypto/ctaes",
206 "src/leveldb",
207 "src/minisketch",
208 "src/secp256k1",
209 ]
210 }
211 212 /// Return the pathspecs to exclude all subtrees
213 fn get_pathspecs_exclude_subtrees() -> Vec<String> {
214 get_subtrees()
215 .iter()
216 .map(|s| format!(":(exclude){}", s))
217 .collect()
218 }
219 220 fn lint_subtree() -> LintResult {
221 // This only checks that the trees are pure subtrees, it is not doing a full
222 // check with -r to not have to fetch all the remotes.
223 let mut good = true;
224 for subtree in get_subtrees() {
225 good &= Command::new("test/lint/git-subtree-check.sh")
226 .arg(subtree)
227 .status()
228 .expect("command_error")
229 .success();
230 }
231 if good {
232 Ok(())
233 } else {
234 Err("".to_string())
235 }
236 }
237 238 fn lint_scripted_diff() -> LintResult {
239 if Command::new("test/lint/commit-script-check.sh")
240 .arg(commit_range())
241 .status()
242 .expect("command error")
243 .success()
244 {
245 Ok(())
246 } else {
247 Err("".to_string())
248 }
249 }
250 251 fn lint_commit_msg() -> LintResult {
252 let mut good = true;
253 let commit_hashes = check_output(git().args(&[
254 "-c",
255 "log.showSignature=false",
256 "log",
257 &commit_range(),
258 "--format=%H",
259 ]))?;
260 for hash in commit_hashes.lines() {
261 let commit_info = check_output(git().args([
262 "-c",
263 "log.showSignature=false",
264 "log",
265 "--format=%B",
266 "-n",
267 "1",
268 hash,
269 ]))?;
270 if let Some(line) = commit_info.lines().nth(1) {
271 if !line.is_empty() {
272 println!(
273 "The subject line of commit hash {} is followed by a non-empty line. Subject lines should always be followed by a blank line.",
274 hash
275 );
276 good = false;
277 }
278 }
279 }
280 if good {
281 Ok(())
282 } else {
283 Err("".to_string())
284 }
285 }
286 287 fn lint_py_lint() -> LintResult {
288 let bin_name = "ruff";
289 let checks = format!(
290 "--select={}",
291 [
292 "B006", // mutable-argument-default
293 "B008", // function-call-in-default-argument
294 "E101", // indentation contains mixed spaces and tabs
295 "E401", // multiple imports on one line
296 "E402", // module level import not at top of file
297 "E702", // multiple statements on one line (semicolon)
298 "E703", // statement ends with a semicolon
299 "E711", // comparison to None should be 'if cond is None:'
300 "E721", // do not compare types, use "isinstance()"
301 "E722", // do not use bare 'except'
302 "E742", // do not define classes named "l", "O", or "I"
303 "E743", // do not define functions named "l", "O", or "I"
304 "F402", // import module from line N shadowed by loop variable
305 "F403", // 'from foo_module import *' used; unable to detect undefined names
306 "F404", // future import(s) name after other statements
307 "F405", // foo_function may be undefined, or defined from star imports: bar_module
308 "F406", // "from module import *" only allowed at module level
309 "F407", // an undefined __future__ feature name was imported
310 "F541", // f-string without any placeholders
311 "F601", // dictionary key name repeated with different values
312 "F602", // dictionary key variable name repeated with different values
313 "F621", // too many expressions in an assignment with star-unpacking
314 "F631", // assertion test is a tuple, which are always True
315 "F632", // use ==/!= to compare str, bytes, and int literals
316 "F811", // redefinition of unused name from line N
317 "F821", // undefined name 'Foo'
318 "F822", // undefined name name in __all__
319 "F823", // local variable name … referenced before assignment
320 "PLE", // Pylint errors
321 "W191", // indentation contains tabs
322 "W291", // trailing whitespace
323 "W292", // no newline at end of file
324 "W293", // blank line contains whitespace
325 "W605", // invalid escape sequence "x"
326 ]
327 .join(",")
328 );
329 let files = check_output(
330 git()
331 .args(["ls-files", "--", "*.py"])
332 .args(get_pathspecs_exclude_subtrees()),
333 )?;
334 335 let mut cmd = Command::new(bin_name);
336 cmd.args(["check", &checks]).args(files.lines());
337 338 match cmd.status() {
339 Ok(status) if status.success() => Ok(()),
340 Ok(_) => Err(format!("`{}` found errors!", bin_name)),
341 Err(e) if e.kind() == ErrorKind::NotFound => {
342 println!(
343 "`{}` was not found in $PATH, skipping those checks.",
344 bin_name
345 );
346 Ok(())
347 }
348 Err(e) => Err(format!("Error running `{}`: {}", bin_name, e)),
349 }
350 }
351 352 fn lint_std_filesystem() -> LintResult {
353 let found = git()
354 .args([
355 "grep",
356 "--line-number",
357 "std::filesystem",
358 "--",
359 "./src/",
360 ":(exclude)src/util/fs.h",
361 ])
362 .status()
363 .expect("command error")
364 .success();
365 if found {
366 Err(r#"
367 Direct use of std::filesystem may be dangerous and buggy. Please include <util/fs.h> and use the
368 fs:: namespace, which has unsafe filesystem functions marked as deleted.
369 "#
370 .trim()
371 .to_string())
372 } else {
373 Ok(())
374 }
375 }
376 377 fn lint_rpc_assert() -> LintResult {
378 let found = git()
379 .args([
380 "grep",
381 "--line-number",
382 "--extended-regexp",
383 r"\<(A|a)ss(ume|ert)\(",
384 "--",
385 "src/rpc/",
386 "src/wallet/rpc*",
387 ":(exclude)src/rpc/server.cpp",
388 // src/rpc/server.cpp is excluded from this check since it's mostly meta-code.
389 ])
390 .status()
391 .expect("command error")
392 .success();
393 if found {
394 Err(r#"
395 CHECK_NONFATAL(condition) or NONFATAL_UNREACHABLE should be used instead of assert for RPC code.
396 397 Aborting the whole process is undesirable for RPC code. So nonfatal
398 checks should be used over assert. See: src/util/check.h
399 "#
400 .trim()
401 .to_string())
402 } else {
403 Ok(())
404 }
405 }
406 407 fn lint_boost_assert() -> LintResult {
408 let found = git()
409 .args([
410 "grep",
411 "--line-number",
412 "--extended-regexp",
413 r"BOOST_ASSERT\(",
414 "--",
415 "*.cpp",
416 "*.h",
417 ])
418 .status()
419 .expect("command error")
420 .success();
421 if found {
422 Err(r#"
423 BOOST_ASSERT must be replaced with Assert, BOOST_REQUIRE, or BOOST_CHECK to avoid an unnecessary
424 include of the boost/assert.hpp dependency.
425 "#
426 .trim()
427 .to_string())
428 } else {
429 Ok(())
430 }
431 }
432 433 fn lint_doc_release_note_snippets() -> LintResult {
434 let non_release_notes = check_output(git().args([
435 "ls-files",
436 "--",
437 "doc/release-notes/",
438 ":(exclude)doc/release-notes/*.*.md", // Assume that at least one dot implies a proper release note
439 ]))?;
440 if non_release_notes.is_empty() {
441 Ok(())
442 } else {
443 println!("{non_release_notes}");
444 Err(r#"
445 Release note snippets and other docs must be put into the doc/ folder directly.
446 447 The doc/release-notes/ folder is for archived release notes of previous releases only. Snippets are
448 expected to follow the naming "/doc/release-notes-<PR number>.md".
449 "#
450 .trim()
451 .to_string())
452 }
453 }
454 455 /// Return the pathspecs for whitespace related excludes
456 fn get_pathspecs_exclude_whitespace() -> Vec<String> {
457 let mut list = get_pathspecs_exclude_subtrees();
458 list.extend(
459 [
460 // Permanent excludes
461 "*.patch",
462 "src/qt/locale",
463 "contrib/windeploy/win-codesign.cert",
464 "doc/README_windows.txt",
465 // Temporary excludes, or existing violations
466 "doc/release-notes/release-notes-0.*",
467 "contrib/init/limenkad.openrc",
468 "contrib/macdeploy/macdeployqtplus",
469 "src/crypto/sha256_sse4.cpp",
470 "src/qt/res/src/*.svg",
471 "test/functional/test_framework/crypto/ellswift_decode_test_vectors.csv",
472 "test/functional/test_framework/crypto/xswiftec_inv_test_vectors.csv",
473 "contrib/qos/tc.sh",
474 "contrib/verify-commits/gpg.sh",
475 "src/univalue/include/univalue_escapes.h",
476 "src/univalue/test/object.cpp",
477 "test/lint/git-subtree-check.sh",
478 ]
479 .iter()
480 .map(|s| format!(":(exclude){}", s)),
481 );
482 list
483 }
484 485 fn lint_trailing_whitespace() -> LintResult {
486 let trailing_space = git()
487 .args(["grep", "-I", "--line-number", "\\s$", "--"])
488 .args(get_pathspecs_exclude_whitespace())
489 .status()
490 .expect("command error")
491 .success();
492 if trailing_space {
493 Err(r#"
494 Trailing whitespace (including Windows line endings [CR LF]) is problematic, because git may warn
495 about it, or editors may remove it by default, forcing developers in the future to either undo the
496 changes manually or spend time on review.
497 498 Thus, it is best to remove the trailing space now.
499 500 Please add any false positives, such as subtrees, Windows-related files, patch files, or externally
501 sourced files to the exclude list.
502 "#
503 .trim()
504 .to_string())
505 } else {
506 Ok(())
507 }
508 }
509 510 fn lint_tabs_whitespace() -> LintResult {
511 let tabs = git()
512 .args(["grep", "-I", "--line-number", "--perl-regexp", "^\\t", "--"])
513 .args(["*.cpp", "*.h", "*.md", "*.py", "*.sh"])
514 .args(get_pathspecs_exclude_whitespace())
515 .status()
516 .expect("command error")
517 .success();
518 if tabs {
519 Err(r#"
520 Use of tabs in this codebase is problematic, because existing code uses spaces and tabs will cause
521 display issues and conflict with editor settings.
522 523 Please remove the tabs.
524 525 Please add any false positives, such as subtrees, or externally sourced files to the exclude list.
526 "#
527 .trim()
528 .to_string())
529 } else {
530 Ok(())
531 }
532 }
533 534 fn lint_includes_build_config() -> LintResult {
535 let config_path = "./cmake/limenka-build-config.h.in";
536 let defines_regex = format!(
537 r"^\s*(?!//).*({})",
538 check_output(Command::new("grep").args(["define", "--", config_path]))
539 .expect("grep failed")
540 .lines()
541 .map(|line| {
542 line.split_whitespace()
543 .nth(1)
544 .unwrap_or_else(|| panic!("Could not extract name in line: {line}"))
545 })
546 .collect::<Vec<_>>()
547 .join("|")
548 );
549 let print_affected_files = |mode: bool| {
550 // * mode==true: Print files which use the define, but lack the include
551 // * mode==false: Print files which lack the define, but use the include
552 let defines_files = check_output(
553 git()
554 .args([
555 "grep",
556 "--perl-regexp",
557 if mode {
558 "--files-with-matches"
559 } else {
560 "--files-without-match"
561 },
562 &defines_regex,
563 "--",
564 "*.cpp",
565 "*.h",
566 ])
567 .args(get_pathspecs_exclude_subtrees())
568 .args([
569 // These are exceptions which don't use limenka-build-config.h, rather CMakeLists.txt adds
570 // these cppflags manually.
571 ":(exclude)src/crypto/sha256_arm_shani.cpp",
572 ":(exclude)src/crypto/sha256_avx2.cpp",
573 ":(exclude)src/crypto/sha256_sse41.cpp",
574 ":(exclude)src/crypto/sha256_x86_shani.cpp",
575 ]),
576 )
577 .expect("grep failed");
578 git()
579 .args([
580 "grep",
581 if mode {
582 "--files-without-match"
583 } else {
584 "--files-with-matches"
585 },
586 if mode {
587 "^#include <limenka-build-config.h> // IWYU pragma: keep$"
588 } else {
589 "#include <limenka-build-config.h>" // Catch redundant includes with and without the IWYU pragma
590 },
591 "--",
592 ])
593 .args(defines_files.lines())
594 .status()
595 .expect("command error")
596 .success()
597 };
598 let missing = print_affected_files(true);
599 if missing {
600 return Err(format!(
601 r#"
602 One or more files use a symbol declared in the limenka-build-config.h header. However, they are not
603 including the header. This is problematic, because the header may or may not be indirectly
604 included. If the indirect include were to be intentionally or accidentally removed, the build could
605 still succeed, but silently be buggy. For example, a slower fallback algorithm could be picked,
606 even though limenka-build-config.h indicates that a faster feature is available and should be used.
607 608 If you are unsure which symbol is used, you can find it with this command:
609 git grep --perl-regexp '{}' -- file_name
610 611 Make sure to include it with the IWYU pragma. Otherwise, IWYU may falsely instruct to remove the
612 include again.
613 614 #include <limenka-build-config.h> // IWYU pragma: keep
615 "#,
616 defines_regex
617 )
618 .trim()
619 .to_string());
620 }
621 let redundant = print_affected_files(false);
622 if redundant {
623 return Err(r#"
624 None of the files use a symbol declared in the limenka-build-config.h header. However, they are including
625 the header. Consider removing the unused include.
626 "#
627 .to_string());
628 }
629 Ok(())
630 }
631 632 fn lint_doc() -> LintResult {
633 if Command::new("test/lint/check-doc.py")
634 .status()
635 .expect("command error")
636 .success()
637 {
638 Ok(())
639 } else {
640 Err("".to_string())
641 }
642 }
643 644 fn lint_markdown() -> LintResult {
645 let bin_name = "mlc";
646 let mut md_ignore_paths = get_subtrees();
647 md_ignore_paths.push("./doc/README_doxygen.md");
648 let md_ignore_path_str = md_ignore_paths.join(",");
649 650 let mut cmd = Command::new(bin_name);
651 cmd.args([
652 "--offline",
653 "--ignore-path",
654 md_ignore_path_str.as_str(),
655 "--gitignore",
656 "--gituntracked",
657 "--root-dir",
658 ".",
659 ])
660 .stdout(Stdio::null()); // Suppress overly-verbose output
661 662 match cmd.output() {
663 Ok(output) if output.status.success() => Ok(()),
664 Ok(output) => {
665 let stderr = String::from_utf8_lossy(&output.stderr);
666 Err(format!(
667 r#"
668 One or more markdown links are broken.
669 670 Note: relative links are preferred as jump-to-file works natively within Emacs, but they are not required.
671 672 Markdown link errors found:
673 {}
674 "#,
675 stderr
676 )
677 .trim()
678 .to_string())
679 }
680 Err(e) if e.kind() == ErrorKind::NotFound => {
681 println!("`mlc` was not found in $PATH, skipping markdown lint check.");
682 Ok(())
683 }
684 Err(e) => Err(format!("Error running mlc: {}", e)), // Misc errors
685 }
686 }
687 688 fn run_all_python_linters() -> LintResult {
689 let mut good = true;
690 let lint_dir = get_git_root().join("test/lint");
691 for entry in fs::read_dir(lint_dir).unwrap() {
692 let entry = entry.unwrap();
693 let entry_fn = entry.file_name().into_string().unwrap();
694 if entry_fn.starts_with("lint-")
695 && entry_fn.ends_with(".py")
696 && !Command::new("python3")
697 .arg(entry.path())
698 .status()
699 .expect("command error")
700 .success()
701 {
702 good = false;
703 println!("^---- ⚠️ Failure generated from {}", entry_fn);
704 }
705 }
706 if good {
707 Ok(())
708 } else {
709 Err("".to_string())
710 }
711 }
712 713 fn main() -> ExitCode {
714 let linters_to_run: Vec<&Linter> = if env::args().count() > 1 {
715 let args: Vec<String> = env::args().skip(1).collect();
716 parse_lint_args(&args)
717 } else {
718 // If no arguments are passed, run all linters.
719 get_linter_list()
720 };
721 722 let git_root = get_git_root();
723 let commit_range = commit_range();
724 let commit_log = check_output(git().args(["log", "--no-merges", "--oneline", &commit_range]))
725 .expect("check_output failed");
726 println!("Checking commit range ({commit_range}):\n{commit_log}\n");
727 728 let mut test_failed = false;
729 for linter in linters_to_run {
730 // chdir to root before each lint test
731 env::set_current_dir(&git_root).unwrap();
732 if let Err(err) = (linter.lint_fn)() {
733 println!(
734 "^^^\n{err}\n^---- ⚠️ Failure generated from lint check '{}' ({})!\n\n",
735 linter.name, linter.description,
736 );
737 test_failed = true;
738 }
739 }
740 if test_failed {
741 ExitCode::FAILURE
742 } else {
743 ExitCode::SUCCESS
744 }
745 }
746