use crate::linter::{Diagnostic, LintResult, Severity, Span};
use regex::Regex;
static ARRAY_IN_CONDITIONAL: std::sync::LazyLock<Regex> = std::sync::LazyLock::new(|| {
Regex::new(r"\$\{?([a-z_][a-z0-9_]*)(\[[^\]]*\])?\}?").unwrap()
});
static DOUBLE_BRACKET: std::sync::LazyLock<Regex> =
std::sync::LazyLock::new(|| Regex::new(r"\[\[.*?\]\]").unwrap());
fn is_comment_line(line: &str) -> bool {
line.trim_start().starts_with('#')
}
fn has_double_bracket(line: &str) -> bool {
line.contains("[[")
}
fn has_subscript(subscript: Option<&str>) -> bool {
subscript.is_some()
}
fn matches_array_heuristics(var_name: &str) -> bool {
var_name.ends_with('s')
|| var_name.contains("array")
|| var_name.contains("list")
|| var_name.contains("items")
}
fn create_array_diagnostic(
var_name: &str,
line_num: usize,
start_col: usize,
end_col: usize,
) -> Diagnostic {
Diagnostic::new(
"SC2199",
Severity::Warning,
format!(
"Arrays implicitly concatenate in [[ ]]. Use ${{{}[@]}} to check all elements or ${{{}[0]}} for first element",
var_name, var_name
),
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) || !has_double_bracket(line) {
continue;
}
for bracket_match in DOUBLE_BRACKET.find_iter(line) {
let bracket_text = bracket_match.as_str();
for cap in ARRAY_IN_CONDITIONAL.captures_iter(bracket_text) {
let var_name = cap.get(1).unwrap().as_str();
let subscript = cap.get(2).map(|m| m.as_str());
if has_subscript(subscript) {
continue;
}
if matches_array_heuristics(var_name) {
let start_col = line.find(bracket_text).unwrap_or(0) + 1;
let end_col = start_col + bracket_text.len();
let diagnostic =
create_array_diagnostic(var_name, line_num, start_col, end_col);
result.add(diagnostic);
break; }
}
}
}
result
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn prop_sc2199_comments_never_diagnosed() {
let test_cases = vec![
"# [[ $items = \"test\" ]]",
" # [[ ${arrays} ]]",
"\t# [[ -n $files ]]",
];
for code in test_cases {
let result = check(code);
assert_eq!(result.diagnostics.len(), 0);
}
}
#[test]
fn prop_sc2199_single_bracket_never_diagnosed() {
let test_cases = vec!["[ $items = \"test\" ]", "[ -n $files ]", "[ ${arrays} ]"];
for code in test_cases {
let result = check(code);
assert_eq!(result.diagnostics.len(), 0);
}
}
#[test]
fn prop_sc2199_subscripted_arrays_never_diagnosed() {
let test_cases = vec![
"[[ ${items[@]} ]]",
"[[ ${files[*]} ]]",
"[[ ${items[0]} = \"first\" ]]",
"[[ ${arrays[1]} ]]",
];
for code in test_cases {
let result = check(code);
assert_eq!(result.diagnostics.len(), 0);
}
}
#[test]
fn prop_sc2199_bare_arrays_always_diagnosed() {
let test_cases = vec![
("[[ $items = \"test\" ]]", "items"),
("[[ ${files} ]]", "files"),
("[[ -n $arrays ]]", "arrays"),
("[[ $my_array ]]", "my_array"),
("[[ $data_list ]]", "data_list"),
];
for (code, var_name) in test_cases {
let result = check(code);
assert_eq!(result.diagnostics.len(), 1, "Should diagnose: {}", code);
assert!(result.diagnostics[0].message.contains(var_name));
}
}
#[test]
fn prop_sc2199_singular_names_never_diagnosed() {
let test_cases = vec!["[[ $item = \"test\" ]]", "[[ $file ]]", "[[ -n $path ]]"];
for code in test_cases {
let result = check(code);
assert_eq!(result.diagnostics.len(), 0);
}
}
#[test]
fn prop_sc2199_one_diagnostic_per_bracket() {
let code = "[[ $items && $files ]]";
let result = check(code);
assert_eq!(result.diagnostics.len(), 1);
}
#[test]
fn prop_sc2199_diagnostic_code_always_sc2199() {
let code = "[[ $items ]] && [[ $files ]]";
let result = check(code);
for diagnostic in &result.diagnostics {
assert_eq!(&diagnostic.code, "SC2199");
}
}
#[test]
fn prop_sc2199_diagnostic_severity_always_warning() {
let code = "[[ $items = \"test\" ]]";
let result = check(code);
for diagnostic in &result.diagnostics {
assert_eq!(diagnostic.severity, Severity::Warning);
}
}
#[test]
fn prop_sc2199_empty_source_no_diagnostics() {
let result = check("");
assert_eq!(result.diagnostics.len(), 0);
}
#[test]
fn test_sc2199_array_in_conditional() {
let code = r#"[[ $items = "test" ]]"#;
let result = check(code);
assert_eq!(result.diagnostics.len(), 1);
assert_eq!(result.diagnostics[0].code, "SC2199");
assert_eq!(result.diagnostics[0].severity, Severity::Warning);
assert!(result.diagnostics[0].message.contains("[@]"));
}
#[test]
fn test_sc2199_array_braces() {
let code = r#"[[ ${arrays} ]]"#;
let result = check(code);
assert_eq!(result.diagnostics.len(), 1);
}
#[test]
fn test_sc2199_array_check_empty() {
let code = r#"[[ -n $files ]]"#;
let result = check(code);
assert_eq!(result.diagnostics.len(), 1);
}
#[test]
fn test_sc2199_with_at_ok() {
let code = r#"[[ ${items[@]} ]]"#;
let result = check(code);
assert_eq!(result.diagnostics.len(), 0);
}
#[test]
fn test_sc2199_with_star_ok() {
let code = r#"[[ ${items[*]} ]]"#;
let result = check(code);
assert_eq!(result.diagnostics.len(), 0);
}
#[test]
fn test_sc2199_with_index_ok() {
let code = r#"[[ ${items[0]} = "first" ]]"#;
let result = check(code);
assert_eq!(result.diagnostics.len(), 0);
}
#[test]
fn test_sc2199_singular_var_ok() {
let code = r#"[[ $item = "test" ]]"#;
let result = check(code);
assert_eq!(result.diagnostics.len(), 0);
}
#[test]
fn test_sc2199_array_name_patterns() {
let code = r#"
[[ $my_array ]]
[[ $data_list ]]
[[ $all_items ]]
"#;
let result = check(code);
assert_eq!(result.diagnostics.len(), 3);
}
#[test]
fn test_sc2199_regular_test_ok() {
let code = r#"[ $items = "test" ]"#;
let result = check(code);
assert_eq!(result.diagnostics.len(), 0);
}
#[test]
fn test_sc2199_multiple_conditionals() {
let code = r#"[[ $paths ]] && [[ $files ]]"#;
let result = check(code);
assert_eq!(result.diagnostics.len(), 2);
}
}