use crate::linter::{Diagnostic, Fix, LintResult, Severity, Span};
use regex::Regex;
static BRACKET_PATTERN: std::sync::LazyLock<Regex> =
std::sync::LazyLock::new(|| Regex::new(r"\[\[([^\]]+)\]\]").unwrap());
static SC2066_RE_1: std::sync::LazyLock<Regex> = std::sync::LazyLock::new(|| {
Regex::new(r"\$(?:\{([A-Za-z_][A-Za-z0-9_]*)\}|([A-Za-z_][A-Za-z0-9_]*))").unwrap()
});
pub fn check(source: &str) -> LintResult {
let mut result = LintResult::new();
let bracket_pattern = &*BRACKET_PATTERN;
let var_pattern = &*SC2066_RE_1;
for (line_num, line) in source.lines().enumerate() {
let line_num = line_num + 1;
if line.trim_start().starts_with('#') {
continue;
}
for bracket_match in bracket_pattern.captures_iter(line) {
let bracket_content = bracket_match.get(1).unwrap().as_str();
let bracket_start = bracket_match.get(0).unwrap().start();
for var_match in var_pattern.captures_iter(bracket_content) {
let var_offset = var_match.get(0).unwrap().start();
let var_end = var_match.get(0).unwrap().end();
let var_text = var_match.get(0).unwrap().as_str();
let abs_start = bracket_start + 2 + var_offset; let abs_end = bracket_start + 2 + var_end;
if is_quoted_at_position(line, abs_start) {
continue;
}
if is_pattern_position(bracket_content, var_offset) {
continue;
}
let start_col = abs_start + 1; let end_col = abs_end + 1;
let fix_text = format!(r#""{}""#, var_text);
let diagnostic = Diagnostic::new(
"SC2066",
Severity::Warning,
"Quote variable in [[ ... ]] to prevent globbing and word splitting",
Span::new(line_num, start_col, line_num, end_col),
)
.with_fix(Fix::new(fix_text));
result.add(diagnostic);
}
}
}
result
}
fn is_quoted_at_position(line: &str, pos: usize) -> bool {
if pos == 0 || pos >= line.len() {
return false;
}
let before_char = line.chars().nth(pos.saturating_sub(1));
if matches!(before_char, Some('"' | '\'')) {
return true;
}
let before = &line[..pos];
let double_quotes = before.matches('"').count();
let single_quotes = before.matches('\'').count();
double_quotes % 2 == 1 || single_quotes % 2 == 1
}
fn is_pattern_position(content: &str, var_pos: usize) -> bool {
let before = &content[..var_pos];
let condition_start = before
.rfind("&&")
.or_else(|| before.rfind("||"))
.map_or(0, |pos| pos + 2);
let current_condition = &content[condition_start..var_pos];
if let Some(eq_pos) = current_condition.rfind("==") {
return var_pos - condition_start > eq_pos + 2;
}
if let Some(neq_pos) = current_condition.rfind("!=") {
return var_pos - condition_start > neq_pos + 2;
}
false
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_sc2066_basic_detection() {
let script = r#"if [[ $var == value ]]; then echo "yes"; fi"#;
let result = check(script);
assert_eq!(result.diagnostics.len(), 1);
assert_eq!(result.diagnostics[0].code, "SC2066");
assert!(result.diagnostics[0].message.contains("Quote variable"));
}
#[test]
fn test_sc2066_braced_variable() {
let script = r#"if [[ ${myvar} == test ]]; then echo "yes"; fi"#;
let result = check(script);
assert_eq!(result.diagnostics.len(), 1);
}
#[test]
fn test_sc2066_multiple_variables() {
let script = r#"if [[ $var1 == $var2 ]]; then echo "yes"; fi"#;
let result = check(script);
assert_eq!(result.diagnostics.len(), 1);
}
#[test]
fn test_sc2066_autofix() {
let script = r#"if [[ $var == value ]]; then echo "yes"; fi"#;
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,
r#""$var""#
);
}
#[test]
fn test_sc2066_false_positive_quoted() {
let script = r#"if [[ "$var" == value ]]; then echo "yes"; fi"#;
let result = check(script);
assert_eq!(result.diagnostics.len(), 0);
}
#[test]
fn test_sc2066_false_positive_pattern() {
let script = r#"if [[ "test" == $pattern ]]; then echo "yes"; fi"#;
let result = check(script);
assert_eq!(result.diagnostics.len(), 0);
}
#[test]
fn test_sc2066_glob_pattern() {
let script = r#"if [[ $var == *.txt ]]; then echo "yes"; fi"#;
let result = check(script);
assert_eq!(result.diagnostics.len(), 1);
}
#[test]
fn test_sc2066_not_equal() {
let script = r#"if [[ $var != value ]]; then echo "yes"; fi"#;
let result = check(script);
assert_eq!(result.diagnostics.len(), 1);
}
#[test]
fn test_sc2066_and_condition() {
let script = r#"if [[ $var1 == test && $var2 == test ]]; then echo "yes"; fi"#;
let result = check(script);
assert_eq!(result.diagnostics.len(), 2);
}
#[test]
fn test_sc2066_no_false_positive_single_bracket() {
let script = r#"if [ $var == value ]; then echo "yes"; fi"#;
let result = check(script);
assert_eq!(result.diagnostics.len(), 0); }
}