use crate::linter::{Diagnostic, LintResult, Severity, Span};
const COMMAND_WORDS: &[&str] = &[
"echo", "printf", "return", "exit", "export", "local", "readonly",
];
fn is_test_context(trimmed: &str) -> bool {
trimmed.starts_with('[')
|| trimmed.starts_with("if ")
|| trimmed.starts_with("while ")
|| trimmed.starts_with("until ")
|| trimmed.starts_with("elif ")
|| trimmed.starts_with("test ")
|| trimmed.contains("[ ")
|| trimmed.contains("[[ ")
|| trimmed.contains("==")
}
fn spaced_assignment(trimmed: &str) -> Option<usize> {
let bytes = trimmed.as_bytes();
let first = *bytes.first()?;
if !(first.is_ascii_alphabetic() || first == b'_') {
return None;
}
let mut i = 1;
while i < bytes.len() && (bytes[i].is_ascii_alphanumeric() || bytes[i] == b'_') {
i += 1;
}
let ident_end = i;
let has_space_before = bytes.get(i) == Some(&b' ');
while bytes.get(i) == Some(&b' ') {
i += 1;
}
if bytes.get(i) != Some(&b'=') || bytes.get(i + 1) == Some(&b'=') {
return None;
}
let eq_pos = i;
let has_space_after = bytes.get(i + 1) == Some(&b' ');
if !has_space_before && !has_space_after {
return None;
}
let ident = &trimmed[..ident_end];
if COMMAND_WORDS.contains(&ident) {
return None;
}
if ident == "IFS" && !has_space_before {
return None;
}
Some(eq_pos)
}
pub fn check(source: &str) -> LintResult {
let mut result = LintResult::new();
let mut continued = false;
for (idx, line) in source.lines().enumerate() {
let line_num = idx + 1;
let trimmed = line.trim();
let was_continued = continued;
continued = line.ends_with('\\');
if was_continued || trimmed.is_empty() || trimmed.starts_with('#') {
continue;
}
if is_test_context(trimmed) {
continue;
}
let Some(eq_pos) = spaced_assignment(trimmed) else {
continue;
};
let col = line.find(trimmed).unwrap_or(0) + eq_pos + 1;
result.add(Diagnostic::new(
"SC1007",
Severity::Error,
"Remove space after = if this is intended as an assignment",
Span::new(line_num, col, line_num, col + 1),
));
}
result
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_sc1007_space_around_equals() {
let result = check("FOO = bar");
assert_eq!(result.diagnostics.len(), 1);
assert_eq!(result.diagnostics[0].code, "SC1007");
assert_eq!(result.diagnostics[0].severity, Severity::Error);
}
#[test]
fn test_sc1007_space_after_equals() {
let result = check("FOO= bar");
assert_eq!(result.diagnostics.len(), 1);
}
#[test]
fn test_sc1007_space_before_equals() {
let result = check("FOO =bar");
assert_eq!(result.diagnostics.len(), 1);
}
#[test]
fn test_sc1007_no_space_ok() {
let result = check("FOO=bar");
assert_eq!(result.diagnostics.len(), 0);
}
#[test]
fn test_sc1007_test_context_not_flagged() {
let result = check("[ $x = $y ]");
assert_eq!(result.diagnostics.len(), 0);
}
#[test]
fn test_sc1007_double_bracket_not_flagged() {
let result = check("[[ $x = $y ]]");
assert_eq!(result.diagnostics.len(), 0);
}
#[test]
fn test_sc1007_if_context_not_flagged() {
let result = check("if [ $x = $y ]; then");
assert_eq!(result.diagnostics.len(), 0);
}
#[test]
fn test_sc1007_double_equals_not_flagged() {
let result = check("x == y");
assert_eq!(result.diagnostics.len(), 0);
}
#[test]
fn test_sc1007_comment_not_flagged() {
let result = check("# FOO = bar");
assert_eq!(result.diagnostics.len(), 0);
}
#[test]
fn test_sc1007_multiple_assignments() {
let script = "A = 1\nB = 2\n";
let result = check(script);
assert_eq!(result.diagnostics.len(), 2);
}
}
#[cfg(test)]
mod tests_command_prefix_and_continuation {
use super::*;
#[test]
fn ifs_empty_assignment_is_the_read_idiom() {
let result = check("IFS= read -r answer || answer=\"\"\n");
assert_eq!(result.diagnostics.len(), 0, "got {:?}", result.diagnostics);
}
#[test]
fn ifs_prefix_on_any_command_is_fine() {
let result = check("IFS= cat /etc/hosts\n");
assert_eq!(result.diagnostics.len(), 0, "got {:?}", result.diagnostics);
}
#[test]
fn continuation_line_words_are_arguments_not_assignments() {
let script = "run_case \"no workspace vars\" no all \\\n RUNNER_WORKSPACE= GITHUB_WORKSPACE= RUNNER_WORK_GLOB=\"/tmp/x\"\n";
let result = check(script);
assert_eq!(result.diagnostics.len(), 0, "got {:?}", result.diagnostics);
}
#[test]
fn still_fires_on_space_before_equals() {
let result = check("FOO = bar\n");
assert_eq!(result.diagnostics.len(), 1, "got {:?}", result.diagnostics);
assert_eq!(result.diagnostics[0].code, "SC1007");
assert_eq!(result.diagnostics[0].severity, Severity::Error);
}
#[test]
fn still_fires_on_non_ifs_empty_assignment() {
let result = check("FOO= bar\n");
assert_eq!(result.diagnostics.len(), 1, "got {:?}", result.diagnostics);
}
#[test]
fn still_fires_on_line_after_a_continuation_ends() {
let script = "cmd one \\\n two\nFOO = bar\n";
let result = check(script);
assert_eq!(result.diagnostics.len(), 1, "got {:?}", result.diagnostics);
assert_eq!(result.diagnostics[0].span.start_line, 3);
}
}