use crate::linter::{Diagnostic, LintResult, Severity, Span};
use regex::Regex;
static EXIT_OR_RETURN: std::sync::LazyLock<Regex> = std::sync::LazyLock::new(|| {
Regex::new(r"^\s*(exit|return)\b").unwrap()
});
fn is_comment_line(line: &str) -> bool {
line.trim_start().starts_with('#')
}
fn is_exit_or_return_line(line: &str) -> bool {
EXIT_OR_RETURN.is_match(line)
}
fn is_closing_token(line: &str) -> bool {
let trimmed = line.trim();
trimmed == "}"
|| trimmed == "fi"
|| trimmed == "done"
|| trimmed == "esac"
|| trimmed == ";;"
|| trimmed == ";&"
|| trimmed == ";;&"
|| trimmed.ends_with(";;") || trimmed.ends_with(";&")
|| trimmed.ends_with(";;&")
}
fn get_keyword(line: &str) -> &str {
if line.contains("exit") {
"exit"
} else {
"return"
}
}
fn create_unreachable_diagnostic(
keyword: &str,
exit_line: usize,
unreachable_line: usize,
line_content: &str,
) -> Diagnostic {
let start_col = 1;
let end_col = line_content.len() + 1;
Diagnostic::new(
"SC2117",
Severity::Warning,
format!("Unreachable code after '{}' on line {}", keyword, exit_line),
Span::new(unreachable_line, start_col, unreachable_line, end_col),
)
}
pub fn check(source: &str) -> LintResult {
let mut result = LintResult::new();
let lines: Vec<&str> = source.lines().collect();
for (i, line) in lines.iter().enumerate() {
let line_num = i + 1;
if is_comment_line(line) {
continue;
}
if is_exit_or_return_line(line) {
let mut has_code_after = false;
for (j, next_line) in lines[i + 1..].iter().enumerate() {
let trimmed = next_line.trim();
if trimmed.is_empty() || is_comment_line(next_line) {
continue;
}
if is_closing_token(next_line) {
break;
}
has_code_after = true;
let unreachable_line = i + 1 + j + 1;
let keyword = get_keyword(line);
let diagnostic =
create_unreachable_diagnostic(keyword, line_num, unreachable_line, next_line);
result.add(diagnostic);
break; }
if has_code_after {
break; }
}
}
result
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn prop_sc2117_comments_never_diagnosed() {
let test_cases = vec!["# exit 1\n# echo test", " # return 0\n # local x=5"];
for code in test_cases {
let result = check(code);
assert_eq!(result.diagnostics.len(), 0);
}
}
#[test]
fn prop_sc2117_exit_at_end_never_diagnosed() {
let test_cases = vec![
"echo test\nexit 0",
"local x=5\nreturn 1",
"exit 1\n\n", ];
for code in test_cases {
let result = check(code);
assert_eq!(result.diagnostics.len(), 0);
}
}
#[test]
fn prop_sc2117_code_after_always_diagnosed() {
let test_cases = vec![
("exit 1\necho test", "exit"),
("return 0\nlocal x=5", "return"),
];
for (code, keyword) in test_cases {
let result = check(code);
assert_eq!(result.diagnostics.len(), 1);
assert!(result.diagnostics[0].message.contains(keyword));
}
}
#[test]
fn prop_sc2117_closing_tokens_not_unreachable() {
let closing_tokens = vec!["}", "fi", "done", "esac"];
for token in closing_tokens {
let code = format!("exit 0\n{}", token);
let result = check(&code);
assert_eq!(result.diagnostics.len(), 0);
}
}
#[test]
fn prop_sc2117_only_first_unreachable_reported() {
let code = "exit 1\necho line1\necho line2\necho line3";
let result = check(code);
assert_eq!(result.diagnostics.len(), 1);
}
#[test]
fn prop_sc2117_diagnostic_code_always_sc2117() {
let code = "exit 1\necho test";
let result = check(code);
for diagnostic in &result.diagnostics {
assert_eq!(&diagnostic.code, "SC2117");
}
}
#[test]
fn prop_sc2117_diagnostic_severity_always_warning() {
let code = "return 0\nlocal x=1";
let result = check(code);
for diagnostic in &result.diagnostics {
assert_eq!(diagnostic.severity, Severity::Warning);
}
}
#[test]
fn prop_sc2117_empty_source_no_diagnostics() {
let result = check("");
assert_eq!(result.diagnostics.len(), 0);
}
#[test]
fn test_sc2117_exit_with_code() {
let code = r#"
exit 1
echo "Never runs"
"#;
let result = check(code);
assert_eq!(result.diagnostics.len(), 1);
}
#[test]
fn test_sc2117_return_with_code() {
let code = r#"
return 0
local x=5
"#;
let result = check(code);
assert_eq!(result.diagnostics.len(), 1);
}
#[test]
fn test_sc2117_exit_at_end_ok() {
let code = r#"
echo "Running"
exit 0
"#;
let result = check(code);
assert_eq!(result.diagnostics.len(), 0);
}
#[test]
fn test_sc2117_return_at_end_ok() {
let code = r#"
local result=$1
return 0
"#;
let result = check(code);
assert_eq!(result.diagnostics.len(), 0);
}
#[test]
fn test_sc2117_exit_before_closing_brace_ok() {
let code = r#"
foo() {
exit 1
}
"#;
let result = check(code);
assert_eq!(result.diagnostics.len(), 0);
}
#[test]
fn test_sc2117_comment_after_ok() {
let code = r#"
exit 1
# This is just a comment
"#;
let result = check(code);
assert_eq!(result.diagnostics.len(), 0);
}
#[test]
fn test_sc2117_empty_lines_ok() {
let code = r#"
return 0
"#;
let result = check(code);
assert_eq!(result.diagnostics.len(), 0);
}
#[test]
fn test_sc2117_multiple_lines() {
let code = r#"
exit 1
echo "line1"
echo "line2"
"#;
let result = check(code);
assert_eq!(result.diagnostics.len(), 1);
}
#[test]
fn test_sc2117_in_function() {
let code = r#"
foo() {
return 1
echo "unreachable"
}
"#;
let result = check(code);
assert_eq!(result.diagnostics.len(), 1);
}
#[test]
fn test_sc2117_after_fi_ok() {
let code = r#"
if [ $? -eq 0 ]; then
exit 0
fi
echo "This runs if condition is false"
"#;
let result = check(code);
assert_eq!(result.diagnostics.len(), 0);
}
#[test]
fn test_issue_123_exit_before_case_terminator() {
let code = r#"
case $option in
a)
exit 0
;;
b)
exit 1
;;
esac
"#;
let result = check(code);
assert_eq!(
result.diagnostics.len(),
0,
"SC2117 must NOT flag ;; after exit in case statements"
);
}
#[test]
fn test_issue_123_return_before_case_terminator() {
let code = r#"
case $mode in
debug) return 1 ;;
prod) return 0 ;;
esac
"#;
let result = check(code);
assert_eq!(
result.diagnostics.len(),
0,
"SC2117 must NOT flag ;; after return"
);
}
#[test]
fn test_issue_123_fallthrough_terminator() {
let code = r#"
case $x in
a)
exit 1
;&
b)
exit 0
;;
esac
"#;
let result = check(code);
assert_eq!(
result.diagnostics.len(),
0,
"SC2117 must NOT flag ;& after exit"
);
}
}