use crate::linter::{Diagnostic, LintResult, Severity, Span};
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 trimmed.starts_with('#') {
continue;
}
if let Some(rest) = trimmed.strip_prefix("source") {
if rest.starts_with(char::is_whitespace) {
let arg = rest.trim_start();
if is_literal_path(arg) {
let path = extract_path(arg);
result.add(Diagnostic::new(
"SC1091",
Severity::Info,
format!(
"SC1091: Not following: {}. Use shellcheck -x to follow sourced files",
path
),
Span::new(line_num, 1, line_num, line.len() + 1),
));
}
}
}
if is_dot_source(trimmed) {
let rest = &trimmed[1..];
let arg = rest.trim_start();
if is_literal_path(arg) {
let path = extract_path(arg);
result.add(Diagnostic::new(
"SC1091",
Severity::Info,
format!(
"SC1091: Not following: {}. Use shellcheck -x to follow sourced files",
path
),
Span::new(line_num, 1, line_num, line.len() + 1),
));
}
}
}
result
}
fn is_dot_source(trimmed: &str) -> bool {
if !trimmed.starts_with('.') {
return false;
}
if trimmed.len() < 2 {
return false;
}
let second = trimmed.as_bytes()[1];
second == b' ' || second == b'\t'
}
fn is_literal_path(arg: &str) -> bool {
if arg.is_empty() {
return false;
}
let unquoted = arg
.strip_prefix('"')
.or_else(|| arg.strip_prefix('\''))
.unwrap_or(arg);
!unquoted.starts_with('$') && !unquoted.is_empty()
}
fn extract_path(arg: &str) -> &str {
let first_word = arg.split_whitespace().next().unwrap_or(arg);
first_word.trim_matches('"').trim_matches('\'')
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_sc1091_source_literal() {
let code = "source ./lib.sh";
let result = check(code);
assert_eq!(result.diagnostics.len(), 1);
assert_eq!(result.diagnostics[0].code, "SC1091");
assert_eq!(result.diagnostics[0].severity, Severity::Info);
assert!(result.diagnostics[0].message.contains("./lib.sh"));
}
#[test]
fn test_sc1091_dot_literal() {
let code = ". /etc/profile";
let result = check(code);
assert_eq!(result.diagnostics.len(), 1);
assert!(result.diagnostics[0].message.contains("/etc/profile"));
}
#[test]
fn test_sc1091_source_relative() {
let code = "source ../helpers/utils.sh";
let result = check(code);
assert_eq!(result.diagnostics.len(), 1);
assert!(result.diagnostics[0]
.message
.contains("../helpers/utils.sh"));
}
#[test]
fn test_sc1091_variable_no_match() {
let code = r#"source "$config""#;
let result = check(code);
assert_eq!(result.diagnostics.len(), 0);
}
#[test]
fn test_sc1091_dot_variable_no_match() {
let code = ". $HOME/.bashrc";
let result = check(code);
assert_eq!(result.diagnostics.len(), 0);
}
#[test]
fn test_sc1091_comment_no_match() {
let code = "# source ./lib.sh";
let result = check(code);
assert_eq!(result.diagnostics.len(), 0);
}
#[test]
fn test_sc1091_dot_slash_executable_no_match() {
let code = "./script.sh";
let result = check(code);
assert_eq!(result.diagnostics.len(), 0);
}
#[test]
fn test_sc1091_empty_source() {
let code = "";
let result = check(code);
assert_eq!(result.diagnostics.len(), 0);
}
#[test]
fn test_sc1091_quoted_literal_path() {
let code = r#"source "/usr/local/lib/functions.sh""#;
let result = check(code);
assert_eq!(result.diagnostics.len(), 1);
assert!(result.diagnostics[0]
.message
.contains("/usr/local/lib/functions.sh"));
}
#[test]
fn test_sc1091_multiple() {
let code = "source ./a.sh\nsource ./b.sh\n. /etc/profile";
let result = check(code);
assert_eq!(result.diagnostics.len(), 3);
}
}