#![expect(
clippy::pub_use,
clippy::module_name_repetitions,
reason = "reexport adapters to the lint rule; ShellcheckCli is the public adapter name"
)]
mod cli;
pub use cli::ShellcheckCli;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Sh {
Bash,
Sh,
}
impl Sh {
#[must_use]
pub fn from_token(token: &str) -> Option<Self> {
match token {
"bash" => Some(Self::Bash),
"sh" => Some(Self::Sh),
_ => None,
}
}
fn flag(self) -> &'static str {
match self {
Self::Bash => "bash",
Self::Sh => "sh",
}
}
fn setup_line(self) -> &'static str {
match self {
Self::Bash => "set -eo pipefail",
Self::Sh => "set -e",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Severity {
Error,
Warning,
Info,
Style,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Finding {
pub code: u16,
pub severity: Severity,
pub line: i64,
pub column: i64,
pub message: String,
}
pub trait ShellChecker {
fn check(&self, script: &str, shell: Sh) -> Vec<Finding>;
}
pub enum Availability {
Present(ShellcheckCli),
Absent,
}
impl Availability {
#[must_use]
pub fn probe() -> Self {
if ShellcheckCli::is_available() {
Self::Present(ShellcheckCli::new())
} else {
Self::Absent
}
}
}
#[must_use]
pub fn sanitize_expressions(script: &str) -> String {
let spans = expression_spans(script);
if spans.is_empty() {
return script.to_owned();
}
let mut out = String::with_capacity(script.len());
let mut remaining = spans.into_iter().peekable();
for (offset, ch) in script.char_indices() {
while remaining.peek().is_some_and(|&(_, end)| offset >= end) {
remaining.next();
}
if remaining
.peek()
.is_some_and(|&(start, end)| offset >= start && offset < end)
{
blank_char(&mut out, ch);
} else {
out.push(ch);
}
}
out
}
fn expression_spans(script: &str) -> Vec<(usize, usize)> {
let mut spans = Vec::new();
let mut iter = script.char_indices().peekable();
while let Some((offset, ch)) = iter.next() {
if ch != '$' {
continue;
}
let mut probe = iter.clone();
if probe.next().map(|(_, c)| c) != Some('{') || probe.next().map(|(_, c)| c) != Some('{') {
continue;
}
if let Some(end) = find_close(&mut probe) {
spans.push((offset, end));
iter = probe;
}
}
spans
}
fn find_close(iter: &mut std::iter::Peekable<std::str::CharIndices<'_>>) -> Option<usize> {
while let Some((_, ch)) = iter.next() {
if ch == '}' && iter.peek().map(|&(_, c)| c) == Some('}') {
let (close_offset, _) = iter.next()?;
return close_offset.checked_add(1);
}
}
None
}
fn blank_char(out: &mut String, ch: char) {
if ch == '\n' {
out.push('\n');
} else {
for _ in 0..ch.len_utf8() {
out.push('_');
}
}
}
#[cfg(test)]
mod fake;
#[cfg(test)]
pub use fake::FakeChecker;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sh_from_token() {
assert_eq!(Sh::from_token("bash"), Some(Sh::Bash));
assert_eq!(Sh::from_token("sh"), Some(Sh::Sh));
assert_eq!(Sh::from_token("pwsh"), None);
assert_eq!(Sh::from_token("python"), None);
}
#[test]
fn sanitize_blanks_expression_preserving_columns() {
let got = sanitize_expressions("echo '${{ matrix.os }}'");
assert_eq!(got, "echo '________________'");
assert_eq!(got.len(), "echo '${{ matrix.os }}'".len());
}
#[test]
fn sanitize_preserves_newlines_inside_expression() {
let src = "echo ${{\n github.sha\n}}";
let got = sanitize_expressions(src);
assert_eq!(got.len(), src.len());
assert_eq!(got.matches('\n').count(), 2);
assert!(got.starts_with("echo _"));
}
#[test]
fn sanitize_leaves_plain_shell_untouched() {
let src = "rm $RUNNER_TEMP/file";
assert_eq!(sanitize_expressions(src), src);
}
#[test]
fn sanitize_leaves_unterminated_expression_untouched() {
let src = "echo ${{ broken";
assert_eq!(sanitize_expressions(src), src);
}
#[test]
fn sanitize_handles_multiple_expressions() {
let got = sanitize_expressions("${{ a }}-${{ b }}");
assert_eq!(got, "________-________");
}
}