From ca443e2e549c6ff1f20324313ceeb5128d5a8970 Mon Sep 17 00:00:00 2001 From: Daniel Rainer Date: Mon, 20 Apr 2026 18:54:17 +0200 Subject: [PATCH] lint: add xtask for running ShellCheck ShellCheck does not have a built-in way of detecting which files it should check, so we use ripgrep's `ignore` library to find files not ignored by our gitignore rules, and then look for a non-fish shebang in the first line of the file. The resulting shell scripts are then passed to ShellCheck. Part of #12661 --- Cargo.lock | 43 +++++++++++++++++++++++++++++ Cargo.toml | 1 + crates/xtask/Cargo.toml | 2 ++ crates/xtask/src/lib.rs | 1 + crates/xtask/src/main.rs | 6 ++++- crates/xtask/src/shellcheck.rs | 49 ++++++++++++++++++++++++++++++++++ 6 files changed, 101 insertions(+), 1 deletion(-) create mode 100644 crates/xtask/src/shellcheck.rs diff --git a/Cargo.lock b/Cargo.lock index b4f7298f7..218725a4e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -183,6 +183,31 @@ dependencies = [ "libc", ] +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + [[package]] name = "crypto-common" version = "0.1.7" @@ -522,6 +547,22 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "ignore" +version = "0.4.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3d782a365a015e0f5c04902246139249abf769125006fbe7649e2ee88169b4a" +dependencies = [ + "crossbeam-deque", + "globset", + "log", + "memchr", + "regex-automata", + "same-file", + "walkdir", + "winapi-util", +] + [[package]] name = "is_terminal_polyfill" version = "1.70.2" @@ -1195,6 +1236,8 @@ dependencies = [ "clap", "fish-build-helper", "fish-tempfile", + "ignore", + "pcre2", "walkdir", ] diff --git a/Cargo.toml b/Cargo.toml index 0ee3e2d03..023ea2d6d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -35,6 +35,7 @@ fish-wcstringutil = { path = "crates/wcstringutil" } fish-widecharwidth = { path = "crates/widecharwidth" } fish-widestring = { path = "crates/widestring" } fish-wgetopt = { path = "crates/wgetopt" } +ignore = "0.4.25" itertools = "0.14.0" libc = "0.2.177" # lru pulls in hashbrown by default, which uses a faster (though less DoS resistant) hashing algo. diff --git a/crates/xtask/Cargo.toml b/crates/xtask/Cargo.toml index a5b00238f..66c92aaee 100644 --- a/crates/xtask/Cargo.toml +++ b/crates/xtask/Cargo.toml @@ -10,4 +10,6 @@ anstyle.workspace = true clap.workspace = true fish-build-helper.workspace = true fish-tempfile.workspace = true +ignore.workspace = true +pcre2.workspace = true walkdir.workspace = true diff --git a/crates/xtask/src/lib.rs b/crates/xtask/src/lib.rs index 389b90eda..aa12d95b3 100644 --- a/crates/xtask/src/lib.rs +++ b/crates/xtask/src/lib.rs @@ -14,6 +14,7 @@ macro_rules! fail { } pub mod format; +pub mod shellcheck; pub trait CommandExt { fn run_or_fail(&mut self); diff --git a/crates/xtask/src/main.rs b/crates/xtask/src/main.rs index 7744a682a..1e7c662b5 100644 --- a/crates/xtask/src/main.rs +++ b/crates/xtask/src/main.rs @@ -1,7 +1,7 @@ use clap::{Parser, Subcommand}; use fish_build_helper::as_os_strs; use std::{path::PathBuf, process::Command}; -use xtask::{CommandExt as _, cargo, format::FormatArgs}; +use xtask::{CommandExt as _, cargo, format::FormatArgs, shellcheck::shellcheck}; #[derive(Parser)] #[command( @@ -28,6 +28,9 @@ enum Task { }, /// Build man pages ManPages, + /// run ShellCheck on non-fish shell scripts + #[command(name = "shellcheck")] + ShellCheck, } fn main() { @@ -37,6 +40,7 @@ fn main() { Task::Format(format_args) => xtask::format::format(format_args), Task::HtmlDocs { fish_indent } => build_html_docs(fish_indent), Task::ManPages => cargo(["build", "--package", "fish-build-man-pages"]), + Task::ShellCheck => shellcheck(), } } diff --git a/crates/xtask/src/shellcheck.rs b/crates/xtask/src/shellcheck.rs new file mode 100644 index 000000000..713b6a2c6 --- /dev/null +++ b/crates/xtask/src/shellcheck.rs @@ -0,0 +1,49 @@ +use fish_build_helper::workspace_root; +use ignore::Walk; +use pcre2::bytes::Regex; +use std::{ + fs::File, + io::{BufRead, BufReader}, + path::{Path, PathBuf}, + process::Command, + sync::OnceLock, +}; + +pub fn shellcheck() { + let file_paths = files_to_check(); + match Command::new("shellcheck") + .args(file_paths) + .current_dir(workspace_root()) + .status() + { + Ok(status) => { + std::process::exit(status.code().unwrap_or(1)); + } + Err(e) => { + eprintln!("Failed to run shellcheck: {e}"); + std::process::exit(1); + } + } +} + +fn is_shell_script>(path: P) -> bool { + let file = File::open(&path).unwrap(); + let mut first_line = String::new(); + let Ok(_) = BufReader::new(file).read_line(&mut first_line) else { + return false; + }; + static SHEBANG_REGEX: OnceLock = OnceLock::new(); + SHEBANG_REGEX + .get_or_init(|| Regex::new("^#!.*[^i]sh").unwrap()) + .is_match(first_line.trim().as_bytes()) + .unwrap() +} + +fn files_to_check() -> Vec { + Walk::new(workspace_root()) + .map(|path| path.unwrap_or_else(|e| fail!("Error traversing workspace: {e}"))) + .filter(|path| path.file_type().unwrap().is_file()) + .map(|path| path.into_path()) + .filter(|path| is_shell_script(path)) + .collect() +}