use crate::linter::{Diagnostic, LintResult, Severity, Span};
use regex::Regex;
static EXECUTE_BACKTICKS: std::sync::LazyLock<Regex> = std::sync::LazyLock::new(|| {
Regex::new(r"(^|[;&|]+)\s*`[^`]+`").unwrap()
});
fn is_comment_line(line: &str) -> bool {
line.trim_start().starts_with('#')
}
fn starts_with_separator(text: &str) -> bool {
text.starts_with(';') || text.starts_with('|') || text.starts_with('&')
}
fn is_in_assignment(prefix: &str) -> bool {
prefix.contains('=') && !prefix.ends_with(';') && !prefix.ends_with('|')
}
fn is_in_safe_context(prefix: &str) -> bool {
prefix.contains("echo") || prefix.contains("printf")
}
fn create_backtick_diagnostic(line_num: usize, start_col: usize, end_col: usize) -> Diagnostic {
Diagnostic::new(
"SC2092",
Severity::Warning,
"Remove backticks to avoid executing output (or use eval if intentional)".to_string(),
Span::new(line_num, start_col, line_num, end_col),
)
}
pub fn check(source: &str) -> LintResult {
let mut result = LintResult::new();
for (line_num, line) in source.lines().enumerate() {
let line_num = line_num + 1;
if is_comment_line(line) {
continue;
}
for mat in EXECUTE_BACKTICKS.find_iter(line) {
let matched_text = &line[mat.start()..mat.end()];
if !starts_with_separator(matched_text) {
let prefix = &line[..mat.start()];
if is_in_assignment(prefix) || is_in_safe_context(prefix) {
continue;
}
}
let start_col = mat.start() + 1;
let end_col = mat.end() + 1;
let diagnostic = create_backtick_diagnostic(line_num, start_col, end_col);
result.add(diagnostic);
}
}
result
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn prop_sc2092_comments_never_diagnosed() {
let test_cases = vec![
"# `which cp` file",
" # `find . -name '*.sh'`",
"\t# `date`",
];
for code in test_cases {
let result = check(code);
assert_eq!(result.diagnostics.len(), 0);
}
}
#[test]
fn prop_sc2092_assignments_never_diagnosed() {
let test_cases = vec!["result=`find .`", "output=`which cp`", "timestamp=`date`"];
for code in test_cases {
let result = check(code);
assert_eq!(result.diagnostics.len(), 0);
}
}
#[test]
fn prop_sc2092_echo_printf_never_diagnosed() {
let test_cases = vec!["echo `date`", "printf '%s' `date`", "echo test `which cp`"];
for code in test_cases {
let result = check(code);
assert_eq!(result.diagnostics.len(), 0);
}
}
#[test]
fn prop_sc2092_executed_backticks_always_diagnosed() {
let test_cases = vec!["`which cp` file", "`find . -name '*.sh'`", "`date`"];
for code in test_cases {
let result = check(code);
assert_eq!(result.diagnostics.len(), 1, "Should diagnose: {}", code);
}
}
#[test]
fn prop_sc2092_after_separator_always_diagnosed() {
let test_cases = vec!["echo start; `find .`", "cmd | `which cp`", "cmd & `date`"];
for code in test_cases {
let result = check(code);
assert_eq!(result.diagnostics.len(), 1, "Should diagnose: {}", code);
}
}
#[test]
fn prop_sc2092_dollar_paren_never_diagnosed() {
let test_cases = vec!["result=$(date)", "$(which cp)", "echo $(date)"];
for code in test_cases {
let result = check(code);
assert_eq!(result.diagnostics.len(), 0);
}
}
#[test]
fn prop_sc2092_diagnostic_code_always_sc2092() {
let code = "`which cp` file1\n`find .`";
let result = check(code);
for diagnostic in &result.diagnostics {
assert_eq!(&diagnostic.code, "SC2092");
}
}
#[test]
fn prop_sc2092_diagnostic_severity_always_warning() {
let code = "`which cp` file";
let result = check(code);
for diagnostic in &result.diagnostics {
assert_eq!(diagnostic.severity, Severity::Warning);
}
}
#[test]
fn prop_sc2092_empty_source_no_diagnostics() {
let result = check("");
assert_eq!(result.diagnostics.len(), 0);
}
#[test]
fn test_sc2092_backticks_executed() {
let code = "`which cp` file1 file2";
let result = check(code);
assert_eq!(result.diagnostics.len(), 1);
}
#[test]
fn test_sc2092_find_backticks() {
let code = "`find . -name '*.txt'`";
let result = check(code);
assert_eq!(result.diagnostics.len(), 1);
}
#[test]
fn test_sc2092_assignment_ok() {
let code = "result=`find . -name '*.txt'`";
let result = check(code);
assert_eq!(result.diagnostics.len(), 0);
}
#[test]
fn test_sc2092_echo_ok() {
let code = "echo `date`";
let result = check(code);
assert_eq!(result.diagnostics.len(), 0);
}
#[test]
fn test_sc2092_comment_ok() {
let code = "# `which cp` file1 file2";
let result = check(code);
assert_eq!(result.diagnostics.len(), 0);
}
#[test]
fn test_sc2092_prefer_dollar_paren() {
let code = "result=$(date)";
let result = check(code);
assert_eq!(result.diagnostics.len(), 0);
}
#[test]
fn test_sc2092_after_semicolon() {
let code = "echo start; `find . -name '*.sh'`";
let result = check(code);
assert_eq!(result.diagnostics.len(), 1);
}
#[test]
fn test_sc2092_direct_execution() {
let code = "find . -name '*.txt'";
let result = check(code);
assert_eq!(result.diagnostics.len(), 0);
}
#[test]
fn test_sc2092_printf_ok() {
let code = "printf '%s\n' `date`";
let result = check(code);
assert_eq!(result.diagnostics.len(), 0);
}
#[test]
fn test_sc2092_nested() {
let code = r#"`echo `date``"#;
let result = check(code);
assert_eq!(result.diagnostics.len(), 1);
}
}