use crate::linter::{Diagnostic, LintResult, Severity, Span};
use regex::Regex;
static GLOB_IN_ASSIGNMENT: std::sync::LazyLock<Regex> = std::sync::LazyLock::new(|| {
Regex::new(r"^[A-Za-z_][A-Za-z0-9_]*=([^=]*\*[^=\s]*|.*\{[^}]+\}[^=\s]*)").unwrap()
});
fn is_comment_line(line: &str) -> bool {
line.trim_start().starts_with('#')
}
fn is_array_assignment(line: &str) -> bool {
line.contains("=(")
}
fn is_quoted_assignment(line: &str) -> bool {
line.contains("=\"") || line.contains("='")
}
fn contains_command_substitution(value: &str) -> bool {
value.contains("$(") || value.contains('`')
}
fn is_brace_expansion_group(chars: &[char], start: usize) -> bool {
let mut depth = 1;
let mut j = start + 1;
while j < chars.len() && depth > 0 {
match chars[j] {
'{' => depth += 1,
'}' => depth -= 1,
',' if depth == 1 => return true,
'.' if depth == 1 && j + 1 < chars.len() && chars[j + 1] == '.' => return true,
_ => {}
}
j += 1;
}
false
}
fn has_brace_expansion(value: &str) -> bool {
if !value.contains('{') {
return false;
}
let chars: Vec<char> = value.chars().collect();
for i in 0..chars.len() {
if chars[i] == '{' && (i == 0 || chars[i - 1] != '$') && is_brace_expansion_group(&chars, i)
{
return true;
}
}
false
}
fn create_glob_assignment_diagnostic(
line_num: usize,
start_col: usize,
end_col: usize,
) -> Diagnostic {
Diagnostic::new(
"SC2125",
Severity::Warning,
"Brace expansions and globs are literal in assignments. Assign as array, e.g., arr=(*.txt)",
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;
let trimmed = line.trim_start();
if is_comment_line(trimmed) || is_array_assignment(trimmed) || is_quoted_assignment(trimmed)
{
continue;
}
for cap in GLOB_IN_ASSIGNMENT.captures_iter(trimmed) {
if let Some(value) = cap.get(1) {
let val_text = value.as_str();
if contains_command_substitution(val_text) {
continue;
}
let has_glob = val_text.contains('*');
let has_brace = has_brace_expansion(val_text);
if !has_glob && !has_brace {
continue;
}
let start_col = cap.get(0).unwrap().start() + 1;
let end_col = cap.get(0).unwrap().end() + 1;
let diagnostic = create_glob_assignment_diagnostic(line_num, start_col, end_col);
result.add(diagnostic);
}
}
}
result
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn prop_sc2125_comments_never_diagnosed() {
let test_cases = vec![
"# files=*.txt",
" # docs={a,b,c}.md",
"\t# logs=/tmp/*.log",
];
for code in test_cases {
let result = check(code);
assert_eq!(result.diagnostics.len(), 0);
}
}
#[test]
fn prop_sc2125_array_assignments_never_diagnosed() {
let test_cases = vec!["files=(*.txt)", "docs=({a,b,c}.md)", "logs=(/tmp/*.log)"];
for code in test_cases {
let result = check(code);
assert_eq!(result.diagnostics.len(), 0);
}
}
#[test]
fn prop_sc2125_quoted_assignments_never_diagnosed() {
let test_cases = vec![
"pattern=\"*.txt\"",
"glob='*.log'",
"brace=\"{a,b,c}\"",
"path='/tmp/*.log'",
];
for code in test_cases {
let result = check(code);
assert_eq!(result.diagnostics.len(), 0);
}
}
#[test]
fn prop_sc2125_command_substitution_never_diagnosed() {
let test_cases = vec![
"files=$(ls *.txt)",
"docs=`find . -name *.md`",
"logs=$(echo /tmp/*.log)",
];
for code in test_cases {
let result = check(code);
assert_eq!(result.diagnostics.len(), 0);
}
}
#[test]
fn prop_sc2125_literal_values_never_diagnosed() {
let test_cases = vec!["name=value", "path=/usr/bin", "file=test.txt"];
for code in test_cases {
let result = check(code);
assert_eq!(result.diagnostics.len(), 0);
}
}
#[test]
fn prop_sc2125_unquoted_globs_always_diagnosed() {
let test_cases = vec![
"files=*.txt",
"logs=/tmp/*.log",
"all=/var/log/*.log",
"docs=*.md",
];
for code in test_cases {
let result = check(code);
assert_eq!(result.diagnostics.len(), 1, "Should diagnose: {}", code);
assert!(result.diagnostics[0].message.contains("array"));
}
}
#[test]
fn prop_sc2125_brace_expansions_always_diagnosed() {
let test_cases = vec![
"docs={a,b,c}.md",
"configs=/etc/{a,b,c}/config",
"files={1,2,3}.txt",
];
for code in test_cases {
let result = check(code);
assert_eq!(result.diagnostics.len(), 1, "Should diagnose: {}", code);
}
}
#[test]
fn prop_sc2125_diagnostic_code_always_sc2125() {
let code = "files=*.txt\nlogs=*.log";
let result = check(code);
for diagnostic in &result.diagnostics {
assert_eq!(&diagnostic.code, "SC2125");
}
}
#[test]
fn prop_sc2125_diagnostic_severity_always_warning() {
let code = "files=*.txt";
let result = check(code);
for diagnostic in &result.diagnostics {
assert_eq!(diagnostic.severity, Severity::Warning);
}
}
#[test]
fn prop_sc2125_empty_source_no_diagnostics() {
let result = check("");
assert_eq!(result.diagnostics.len(), 0);
}
#[test]
fn test_sc2125_glob_assignment() {
let code = r#"files=*.txt"#;
let result = check(code);
assert_eq!(result.diagnostics.len(), 1);
assert_eq!(result.diagnostics[0].code, "SC2125");
assert_eq!(result.diagnostics[0].severity, Severity::Warning);
assert!(result.diagnostics[0].message.contains("array"));
}
#[test]
fn test_sc2125_brace_expansion() {
let code = r#"docs={a,b,c}.md"#;
let result = check(code);
assert_eq!(result.diagnostics.len(), 1);
}
#[test]
fn test_sc2125_path_glob() {
let code = r#"logs=/tmp/*.log"#;
let result = check(code);
assert_eq!(result.diagnostics.len(), 1);
}
#[test]
fn test_sc2125_array_assignment_ok() {
let code = r#"files=(*.txt)"#;
let result = check(code);
assert_eq!(result.diagnostics.len(), 0);
}
#[test]
fn test_sc2125_quoted_assignment_ok() {
let code = r#"pattern="*.txt""#;
let result = check(code);
assert_eq!(result.diagnostics.len(), 0);
}
#[test]
fn test_sc2125_single_quoted_ok() {
let code = r#"glob='*.log'"#;
let result = check(code);
assert_eq!(result.diagnostics.len(), 0);
}
#[test]
fn test_sc2125_command_substitution_ok() {
let code = r#"files=$(ls *.txt)"#;
let result = check(code);
assert_eq!(result.diagnostics.len(), 0);
}
#[test]
fn test_sc2125_literal_value_ok() {
let code = r#"name=value"#;
let result = check(code);
assert_eq!(result.diagnostics.len(), 0);
}
#[test]
fn test_sc2125_multiple_wildcards() {
let code = r#"all=/var/log/*.log"#;
let result = check(code);
assert_eq!(result.diagnostics.len(), 1);
}
#[test]
fn test_sc2125_brace_in_path() {
let code = r#"configs=/etc/{a,b,c}/config"#;
let result = check(code);
assert_eq!(result.diagnostics.len(), 1);
}
#[test]
fn test_issue_93_param_expansion_default_ok() {
let code = r#"TEST_LINE=${TEST_LINE:-99999}"#;
let result = check(code);
assert_eq!(
result.diagnostics.len(),
0,
"SC2125 must NOT flag parameter expansion ${{VAR:-default}}"
);
}
#[test]
fn test_issue_93_param_expansion_multiple_ok() {
let code = r#"PROBAR_COUNT=${PROBAR_COUNT:-0}"#;
let result = check(code);
assert_eq!(result.diagnostics.len(), 0);
}
#[test]
fn test_issue_93_param_expansion_alt_value_ok() {
let code = r#"VALUE=${OTHER:+replacement}"#;
let result = check(code);
assert_eq!(result.diagnostics.len(), 0);
}
#[test]
fn test_issue_93_param_expansion_error_ok() {
let code = r#"REQUIRED=${VAR:?error message}"#;
let result = check(code);
assert_eq!(result.diagnostics.len(), 0);
}
#[test]
fn test_issue_93_param_expansion_assign_default_ok() {
let code = r#"VAR=${VAR:=default}"#;
let result = check(code);
assert_eq!(result.diagnostics.len(), 0);
}
#[test]
fn test_issue_93_param_expansion_length_ok() {
let code = r#"LEN=${#VAR}"#;
let result = check(code);
assert_eq!(result.diagnostics.len(), 0);
}
#[test]
fn test_issue_93_mixed_param_and_brace_flagged() {
let code = r#"FILE=${DIR}/{a,b}.txt"#;
let result = check(code);
assert_eq!(
result.diagnostics.len(),
1,
"Mixed param expansion + brace expansion should be flagged"
);
}
}