use crate::linter::{Diagnostic, Fix, LintResult, Severity, Span};
use regex::Regex;
use std::collections::HashSet;
#[allow(clippy::expect_used)] static HEREDOC_SINGLE_QUOTED: std::sync::LazyLock<Regex> = std::sync::LazyLock::new(|| {
Regex::new(r"<<-?\s*'(\w+)'").expect("valid single-quoted heredoc regex")
});
#[allow(clippy::expect_used)] static HEREDOC_DOUBLE_QUOTED: std::sync::LazyLock<Regex> = std::sync::LazyLock::new(|| {
Regex::new(r#"<<-?\s*"(\w+)""#).expect("valid double-quoted heredoc regex")
});
#[allow(clippy::expect_used)] static BACKTICK_PATTERN: std::sync::LazyLock<Regex> =
std::sync::LazyLock::new(|| Regex::new(r"`([^`]+)`").expect("valid backtick regex"));
#[allow(clippy::expect_used)] static ASSIGNMENT_BACKTICK: std::sync::LazyLock<Regex> = std::sync::LazyLock::new(|| {
Regex::new(
r"(?:^|;|\s)(?:local|export|readonly|declare|typeset)?\s*[A-Za-z_][A-Za-z0-9_]*=`[^`]+`",
)
.expect("valid assignment backtick regex")
});
fn collect_heredoc_body_lines(
lines: &[&str],
start_idx: usize,
delimiter: &str,
quoted_lines: &mut HashSet<usize>,
) {
for (inner_idx, inner_line) in lines.iter().enumerate().skip(start_idx + 1) {
if inner_line.trim() == delimiter {
break;
}
quoted_lines.insert(inner_idx + 1);
}
}
fn get_quoted_heredoc_lines(source: &str) -> HashSet<usize> {
let mut quoted_lines = HashSet::new();
let lines: Vec<&str> = source.lines().collect();
for (idx, line) in lines.iter().enumerate() {
for pattern in &[&*HEREDOC_SINGLE_QUOTED, &*HEREDOC_DOUBLE_QUOTED] {
if let Some(caps) = pattern.captures(line) {
if let Some(delim) = caps.get(1) {
collect_heredoc_body_lines(&lines, idx, delim.as_str(), &mut quoted_lines);
}
}
}
}
quoted_lines
}
pub fn check(source: &str) -> LintResult {
let mut result = LintResult::new();
let quoted_heredoc_lines = get_quoted_heredoc_lines(source);
for (line_num, line) in source.lines().enumerate() {
let line_num = line_num + 1;
if line.trim_start().starts_with('#') {
continue;
}
if quoted_heredoc_lines.contains(&line_num) {
continue;
}
if ASSIGNMENT_BACKTICK.is_match(line) {
continue;
}
for cap in BACKTICK_PATTERN.captures_iter(line) {
let full_match = cap.get(0).unwrap();
let command = cap.get(1).unwrap().as_str();
let start_col = full_match.start() + 1;
let end_col = full_match.end() + 1;
let fix_text = format!("$({})", command);
let diagnostic = Diagnostic::new(
"SC2006",
Severity::Info,
"Use $(...) instead of deprecated backticks",
Span::new(line_num, start_col, line_num, end_col),
)
.with_fix(Fix::new(fix_text));
result.add(diagnostic);
}
}
result
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_sc2006_assignment_not_flagged() {
let script = r#"result=`date`"#;
let result = check(script);
assert_eq!(
result.diagnostics.len(),
0,
"F080: Backticks in assignments are intentional, not flagged"
);
}
#[test]
fn test_sc2006_autofix_non_assignment() {
let script = r#"echo `date`"#;
let result = check(script);
assert_eq!(result.diagnostics.len(), 1);
assert!(result.diagnostics[0].fix.is_some());
assert_eq!(
result.diagnostics[0].fix.as_ref().unwrap().replacement,
"$(date)"
);
}
#[test]
fn test_sc2006_ls_assignment_not_flagged() {
let script = r#"files=`ls *.txt`"#;
let result = check(script);
assert_eq!(
result.diagnostics.len(),
0,
"F080: Backticks in assignments are intentional"
);
}
#[test]
fn test_sc2006_command_with_args_assignment_not_flagged() {
let script = r#"output=`grep "pattern" file.txt`"#;
let result = check(script);
assert_eq!(
result.diagnostics.len(),
0,
"F080: Backticks in assignments are intentional"
);
}
#[test]
fn test_sc2006_false_positive_modern_syntax() {
let script = r#"result=$(date)"#;
let result = check(script);
assert_eq!(result.diagnostics.len(), 0);
}
#[test]
fn test_sc2006_false_positive_in_comment() {
let script = r#"# This is a comment with `backticks`"#;
let result = check(script);
assert_eq!(result.diagnostics.len(), 0);
}
#[test]
fn test_sc2006_multiple_assignments_not_flagged() {
let script = r#"
a=`cmd1`
b=`cmd2`
c=`cmd3`
"#;
let result = check(script);
assert_eq!(
result.diagnostics.len(),
0,
"F080: Backticks in assignments are intentional"
);
}
#[test]
fn test_sc2006_multiple_non_assignments_flagged() {
let script = r#"
echo `cmd1`
echo `cmd2`
echo `cmd3`
"#;
let result = check(script);
assert_eq!(result.diagnostics.len(), 3);
}
#[test]
fn test_sc2006_in_if_statement() {
let script = r#"if [ "`whoami`" = "root" ]; then echo "root"; fi"#;
let result = check(script);
assert_eq!(result.diagnostics.len(), 1);
}
#[test]
fn test_sc2006_echo_statement() {
let script = r#"echo "Current date: `date`""#;
let result = check(script);
assert_eq!(result.diagnostics.len(), 1);
}
#[test]
fn test_sc2006_assignment_in_function_not_flagged() {
let script = r#"
function get_time() {
time=`date +%H:%M:%S`
echo "$time"
}
"#;
let result = check(script);
assert_eq!(
result.diagnostics.len(),
0,
"F080: Backticks in function assignments are intentional"
);
}
#[test]
fn test_FP_096_single_quoted_heredoc_not_flagged() {
let script = "cat << 'EOF'\n`date`\nEOF";
let result = check(script);
assert_eq!(
result.diagnostics.len(),
0,
"SC2006 must NOT flag backticks inside single-quoted heredoc"
);
}
#[test]
fn test_FP_096_double_quoted_heredoc_not_flagged() {
let script = "cat << \"EOF\"\n`whoami`\nEOF";
let result = check(script);
assert_eq!(
result.diagnostics.len(),
0,
"SC2006 must NOT flag backticks inside double-quoted heredoc"
);
}
#[test]
fn test_FP_096_unquoted_heredoc_still_flagged() {
let script = "cat << EOF\n`date`\nEOF";
let result = check(script);
assert_eq!(
result.diagnostics.len(),
1,
"SC2006 SHOULD flag backticks inside unquoted heredoc"
);
}
#[test]
fn test_FP_096_markdown_backticks_not_flagged() {
let script = "cat << 'EOF'\n| `config.json` | Configuration |\nEOF";
let result = check(script);
assert_eq!(
result.diagnostics.len(),
0,
"SC2006 must NOT flag markdown backticks in quoted heredoc"
);
}
#[test]
fn test_FP_080_simple_assignment_not_flagged() {
let script = r#"x=`cmd`"#;
let result = check(script);
assert_eq!(
result.diagnostics.len(),
0,
"F080: Simple assignment with backticks must NOT be flagged"
);
}
#[test]
fn test_FP_080_local_assignment_not_flagged() {
let script = r#"local x=`cmd`"#;
let result = check(script);
assert_eq!(
result.diagnostics.len(),
0,
"F080: local assignment with backticks must NOT be flagged"
);
}
#[test]
fn test_FP_080_export_assignment_not_flagged() {
let script = r#"export x=`cmd`"#;
let result = check(script);
assert_eq!(
result.diagnostics.len(),
0,
"F080: export assignment with backticks must NOT be flagged"
);
}
#[test]
fn test_FP_080_readonly_assignment_not_flagged() {
let script = r#"readonly x=`cmd`"#;
let result = check(script);
assert_eq!(
result.diagnostics.len(),
0,
"F080: readonly assignment with backticks must NOT be flagged"
);
}
#[test]
fn test_FP_080_non_assignment_still_flagged() {
let script = r#"echo `date`"#;
let result = check(script);
assert_eq!(
result.diagnostics.len(),
1,
"Backticks in non-assignment context SHOULD be flagged"
);
}
#[test]
fn test_FP_080_if_condition_still_flagged() {
let script = r#"if [ "`whoami`" = "root" ]; then echo hi; fi"#;
let result = check(script);
assert_eq!(
result.diagnostics.len(),
1,
"Backticks in if condition SHOULD be flagged"
);
}
}