use crate::linter::{Diagnostic, Fix, LintResult, Severity, Span};
use regex::Regex;
static CMD_SUB_PATTERN: std::sync::LazyLock<Regex> =
std::sync::LazyLock::new(|| Regex::new(r#"(?m)(?P<pre>[^"']|^)\$\((?P<cmd>[^)]+)\)"#).unwrap());
static BACKTICK_PATTERN: std::sync::LazyLock<Regex> =
std::sync::LazyLock::new(|| Regex::new(r#"(?m)(?P<pre>[^"']|^)`(?P<cmd>[^`]+)`"#).unwrap());
pub fn check(source: &str) -> LintResult {
let mut result = LintResult::new();
let cmd_sub_pattern = &*CMD_SUB_PATTERN;
let backtick_pattern = &*BACKTICK_PATTERN;
for (line_num, line) in source.lines().enumerate() {
let line_num = line_num + 1;
if line.trim_start().starts_with('#') {
continue;
}
for cap in cmd_sub_pattern.captures_iter(line) {
let cmd_match = cap.name("cmd").unwrap();
let dollar_paren_pos = line[..cmd_match.start()]
.rfind("$(")
.unwrap_or(cmd_match.start());
let col = dollar_paren_pos + 1; let end_col = cmd_match.end() + 2;
if dollar_paren_pos > 0 && line.chars().nth(dollar_paren_pos - 1) == Some('"') {
continue;
}
let span = Span::new(line_num, col, line_num, end_col);
let cmd_text = format!("$({})", cmd_match.as_str());
let fix = Fix::new(format!("\"{}\"", cmd_text));
let diag = Diagnostic::new(
"SC2046",
Severity::Warning,
format!("Quote this to prevent word splitting: {}", cmd_text),
span,
)
.with_fix(fix);
result.add(diag);
}
for cap in backtick_pattern.captures_iter(line) {
let cmd_match = cap.name("cmd").unwrap();
let backtick_pos = line[..cmd_match.start()]
.rfind('`')
.unwrap_or(cmd_match.start());
let col = backtick_pos + 1; let end_col = cmd_match.end() + 2;
let span = Span::new(line_num, col, line_num, end_col);
let cmd = cmd_match.as_str();
let fix = Fix::new(format!("\"$({})\"", cmd));
let diag = Diagnostic::new(
"SC2046",
Severity::Warning,
"Quote this and use $(...) instead of backticks".to_string(),
span,
)
.with_fix(fix);
result.add(diag);
}
}
result
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_sc2046_basic_detection() {
let bash_code = "files=$(find . -name '*.txt')";
let result = check(bash_code);
assert_eq!(result.diagnostics.len(), 1);
assert_eq!(result.diagnostics[0].code, "SC2046");
assert!(result.diagnostics[0].message.contains("Quote this"));
}
#[test]
fn test_sc2046_autofix() {
let bash_code = "files=$(ls)";
let result = check(bash_code);
assert!(result.diagnostics[0].fix.is_some());
assert_eq!(
result.diagnostics[0].fix.as_ref().unwrap().replacement,
"\"$(ls)\""
);
}
#[test]
fn test_sc2046_backtick_detection() {
let bash_code = "files=`ls *.txt`";
let result = check(bash_code);
assert_eq!(result.diagnostics.len(), 1);
assert_eq!(result.diagnostics[0].code, "SC2046");
assert!(result.diagnostics[0].message.contains("backticks"));
}
#[test]
fn test_sc2046_backtick_autofix() {
let bash_code = "files=`ls`";
let result = check(bash_code);
assert!(result.diagnostics[0].fix.is_some());
assert_eq!(
result.diagnostics[0].fix.as_ref().unwrap().replacement,
"\"$(ls)\""
);
}
#[test]
fn test_sc2046_skip_quoted() {
let bash_code = r#"files="$(find . -name '*.txt')""#;
let result = check(bash_code);
assert_eq!(result.diagnostics.len(), 0);
}
#[test]
fn test_sc2046_multiple_substitutions() {
let bash_code = r#"
result=$(echo $(cat file.txt))
"#;
let result = check(bash_code);
assert!(!result.diagnostics.is_empty());
}
#[test]
fn test_sc2046_severity() {
let bash_code = "files=$(ls)";
let result = check(bash_code);
assert_eq!(result.diagnostics[0].severity, Severity::Warning);
}
}