use crate::linter::{Diagnostic, LintResult, Severity, Span};
use regex::Regex;
static UNQUOTED_EQUALS: std::sync::LazyLock<Regex> = std::sync::LazyLock::new(|| {
Regex::new(r"\b([a-zA-Z_][a-zA-Z0-9_]*=[a-zA-Z0-9_/.:-]+=[a-zA-Z0-9_/.:-]+)\b").unwrap()
});
fn is_simple_assignment(line: &str) -> bool {
let trimmed = line.trim();
if trimmed.contains('=') && !trimmed.contains(' ') {
let equals_count = trimmed.matches('=').count();
if equals_count == 1 {
return true; }
}
false
}
fn is_quoted(line: &str, match_text: &str) -> bool {
let before_match = &line[..line.find(match_text).unwrap_or(0)];
let quote_count_double = before_match.matches('"').count();
let quote_count_single = before_match.matches('\'').count();
quote_count_double % 2 == 1 || quote_count_single % 2 == 1
}
fn create_unquoted_diagnostic(match_text: &str, line: &str, line_num: usize) -> Diagnostic {
let start_col = line.find(match_text).unwrap_or(0) + 1;
let end_col = start_col + match_text.len();
Diagnostic::new(
"SC2026",
Severity::Warning,
format!(
"This word '{}' contains multiple '=' signs. Quote it to prevent word splitting",
match_text
),
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 line.trim_start().starts_with('#') {
continue;
}
if is_simple_assignment(line) {
continue;
}
for cap in UNQUOTED_EQUALS.captures_iter(line) {
let full_match = cap.get(0).unwrap().as_str();
if is_quoted(line, full_match) {
continue;
}
let diagnostic = create_unquoted_diagnostic(full_match, line, line_num);
result.add(diagnostic);
}
}
result
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_sc2026_unquoted_multiple_equals() {
let code = r#"echo name=John=Doe"#;
let result = check(code);
assert_eq!(result.diagnostics.len(), 1);
assert_eq!(result.diagnostics[0].code, "SC2026");
assert_eq!(result.diagnostics[0].severity, Severity::Warning);
assert!(result.diagnostics[0].message.contains("multiple '='"));
}
#[test]
fn test_sc2026_path_assignment() {
let code = r#"echo PATH=/usr/bin=/usr/local/bin"#;
let result = check(code);
assert_eq!(result.diagnostics.len(), 1);
}
#[test]
fn test_sc2026_config_value() {
let code = r#"param=key=value=extra"#;
let result = check(code);
assert_eq!(result.diagnostics.len(), 1);
}
#[test]
fn test_sc2026_quoted_ok() {
let code = r#"echo "name=John=Doe""#;
let result = check(code);
assert_eq!(result.diagnostics.len(), 0);
}
#[test]
fn test_sc2026_single_quoted_ok() {
let code = r#"echo 'PATH=/bin=/usr/bin'"#;
let result = check(code);
assert_eq!(result.diagnostics.len(), 0);
}
#[test]
fn test_sc2026_simple_assignment_ok() {
let code = r#"name=value"#;
let result = check(code);
assert_eq!(result.diagnostics.len(), 0);
}
#[test]
fn test_sc2026_variable_assignment_ok() {
let code = r#"PATH=$PATH:/new/path"#;
let result = check(code);
assert_eq!(result.diagnostics.len(), 0);
}
#[test]
fn test_sc2026_export_ok() {
let code = r#"export PATH=/usr/bin"#;
let result = check(code);
assert_eq!(result.diagnostics.len(), 0);
}
#[test]
fn test_sc2026_multiple_issues() {
let code = r#"
echo foo=bar=baz
echo key=val=extra
"#;
let result = check(code);
assert_eq!(result.diagnostics.len(), 2);
}
#[test]
fn test_sc2026_in_command() {
let code = r#"grep pattern=match=value file.txt"#;
let result = check(code);
assert_eq!(result.diagnostics.len(), 1);
}
}