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
This commit is contained in:
Daniel Rainer
2026-04-20 18:54:17 +02:00
committed by Johannes Altmanninger
parent 63c3306e6c
commit ca443e2e54
6 changed files with 101 additions and 1 deletions

43
Cargo.lock generated
View File

@@ -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",
]

View File

@@ -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.

View File

@@ -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

View File

@@ -14,6 +14,7 @@ macro_rules! fail {
}
pub mod format;
pub mod shellcheck;
pub trait CommandExt {
fn run_or_fail(&mut self);

View File

@@ -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(),
}
}

View File

@@ -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<P: AsRef<Path>>(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<Regex> = 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<PathBuf> {
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()
}