use crate::linter::{Diagnostic, LintResult, Severity, Span};
pub fn check(source: &str) -> LintResult {
let mut result = LintResult::new();
let heredoc_body = crate::linter::quoting::heredoc_body_lines(source);
for (line_num, line) in source.lines().enumerate() {
let line_num = line_num + 1;
if line_num == 1 {
continue;
}
if heredoc_body.contains(&line_num) {
continue;
}
let trimmed = line.trim_start();
if let Some(after) = trimmed.strip_prefix("#!") {
if after.starts_with("/bin/")
|| after.starts_with("/usr/bin/")
|| after.starts_with("/usr/local/bin/")
|| after.starts_with("/sbin/")
{
let diagnostic = Diagnostic::new(
"SC1128",
Severity::Error,
format!(
"The shebang must be on the first line. Move it from line {} to line 1.",
line_num
),
Span::new(line_num, 1, line_num, line.len() + 1),
);
result.add(diagnostic);
}
}
}
result
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_sc1128_shebang_on_line_2() {
let code = "# comment\n#!/bin/bash\necho hello";
let result = check(code);
assert_eq!(result.diagnostics.len(), 1);
assert_eq!(result.diagnostics[0].code, "SC1128");
assert_eq!(result.diagnostics[0].severity, Severity::Error);
assert!(result.diagnostics[0].message.contains("line 2"));
}
#[test]
fn test_sc1128_shebang_on_line_3() {
let code = "# header\n# more header\n#!/usr/bin/env bash\necho hello";
let result = check(code);
assert_eq!(result.diagnostics.len(), 1);
assert!(result.diagnostics[0].message.contains("line 3"));
}
#[test]
fn test_sc1128_shebang_after_code() {
let code = "echo hello\n#!/bin/sh\necho world";
let result = check(code);
assert_eq!(result.diagnostics.len(), 1);
}
#[test]
fn test_sc1128_correct_shebang_ok() {
let code = "#!/bin/bash\n# comment\necho hello";
let result = check(code);
assert_eq!(result.diagnostics.len(), 0);
}
#[test]
fn test_sc1128_no_shebang_ok() {
let code = "echo hello\necho world";
let result = check(code);
assert_eq!(result.diagnostics.len(), 0);
}
#[test]
fn test_sc1128_hash_bang_in_comment_ok() {
let code = "#!/bin/bash\n# Note: #! is the shebang\necho hello";
let result = check(code);
assert_eq!(result.diagnostics.len(), 0);
}
#[test]
fn test_sc1128_hash_bang_in_string_context() {
let code = "#!/bin/bash\necho '#! is special'\necho hello";
let result = check(code);
assert_eq!(result.diagnostics.len(), 0);
}
#[test]
fn test_sc1128_empty_source() {
let result = check("");
assert_eq!(result.diagnostics.len(), 0);
}
#[test]
fn test_sc1128_single_line() {
let code = "#!/bin/bash";
let result = check(code);
assert_eq!(result.diagnostics.len(), 0);
}
#[test]
fn test_sc1128_usr_local_bin() {
let code = "echo hello\n#!/usr/local/bin/bash\necho world";
let result = check(code);
assert_eq!(result.diagnostics.len(), 1);
}
#[test]
fn test_sc1128_multiple_shebangs() {
let code = "#!/bin/bash\n#!/bin/sh\necho hello";
let result = check(code);
assert_eq!(result.diagnostics.len(), 1);
}
#[test]
fn test_sc1128_shebang_written_by_a_heredoc_is_not_this_script_s() {
let code = "#!/bin/bash\ncat > \"$dir/cmd.sh\" <<EOF\n#!/usr/bin/env bash\necho x\nEOF\n";
assert_eq!(check(code).diagnostics.len(), 0);
}
#[test]
fn test_sc1128_shebang_in_a_quoted_delimiter_heredoc_is_not_this_script_s() {
let code = "#!/bin/bash\ncat > f <<'EOF'\n#!/bin/sh\nEOF\n";
assert_eq!(check(code).diagnostics.len(), 0);
}
#[test]
fn must_still_fire_after_the_heredoc_closes() {
let code = "#!/bin/bash\ncat > f <<EOF\nbody\nEOF\n#!/bin/sh\n";
let result = check(code);
assert_eq!(result.diagnostics.len(), 1, "{:?}", result.diagnostics);
assert_eq!(result.diagnostics[0].code, "SC1128");
assert_eq!(result.diagnostics[0].span.start_line, 5);
}
#[test]
fn must_still_fire_when_a_heredoc_is_never_terminated() {
let code = "#!/bin/bash\ncat > f <<EOF\nbody\n#!/bin/sh\n";
let result = check(code);
assert_eq!(result.diagnostics.len(), 1, "{:?}", result.diagnostics);
assert_eq!(result.diagnostics[0].span.start_line, 4);
}
#[test]
fn must_still_fire_on_a_word_that_merely_mentions_a_heredoc() {
let code = "#!/bin/bash\n# we used to write <<EOF here\n#!/bin/sh\n";
let result = check(code);
assert_eq!(result.diagnostics.len(), 1, "{:?}", result.diagnostics);
assert_eq!(result.diagnostics[0].span.start_line, 3);
}
}
#[cfg(test)]
mod tests_heredoc_body {
use super::*;
#[test]
fn shebang_in_a_heredoc_body_is_not_misplaced() {
let result = check("cat > /tmp/x <<SH\n#!/bin/sh\necho hi\nSH\necho done\n");
assert_eq!(result.diagnostics.len(), 0, "got {:?}", result.diagnostics);
}
#[test]
fn shebang_in_a_quoted_heredoc_body_is_not_misplaced() {
let result = check("cat > /tmp/x <<'SH'\n#!/usr/bin/env bash\necho hi\nSH\n");
assert_eq!(result.diagnostics.len(), 0, "got {:?}", result.diagnostics);
}
#[test]
fn still_fires_on_a_genuinely_misplaced_shebang() {
let result = check("echo hello\n#!/bin/bash\necho bye\n");
assert_eq!(result.diagnostics.len(), 1, "got {:?}", result.diagnostics);
assert_eq!(result.diagnostics[0].code, "SC1128");
assert_eq!(result.diagnostics[0].severity, Severity::Error);
assert_eq!(result.diagnostics[0].span.start_line, 2);
}
#[test]
fn still_fires_after_the_heredoc_terminator() {
let result = check("cat > /tmp/x <<SH\n#!/bin/sh\nSH\n#!/bin/bash\n");
assert_eq!(result.diagnostics.len(), 1, "got {:?}", result.diagnostics);
assert_eq!(result.diagnostics[0].span.start_line, 4);
}
#[test]
fn still_fires_when_the_heredoc_never_terminates() {
let result = check("cat > /tmp/x <<SH\n#!/bin/bash\n");
assert_eq!(result.diagnostics.len(), 1, "got {:?}", result.diagnostics);
assert_eq!(result.diagnostics[0].span.start_line, 2);
}
}