use crate::linter::{Diagnostic, LintResult, Severity, Span};
use regex::Regex;
static TRAILING_BRACKET: std::sync::LazyLock<Regex> =
std::sync::LazyLock::new(|| Regex::new(r"^\s*\]").unwrap());
static HEREDOC_START: std::sync::LazyLock<Regex> =
std::sync::LazyLock::new(|| Regex::new(r"<<-?\s*'?(\w+)'?").unwrap());
fn try_enter_heredoc(line: &str) -> Option<String> {
HEREDOC_START
.captures(line)
.and_then(|caps| caps.get(1))
.map(|marker| marker.as_str().to_string())
}
fn should_exit_heredoc(line: &str, marker: &str) -> bool {
line.trim() == marker
}
fn create_trailing_bracket_diagnostic(line: &str, line_num: usize) -> Diagnostic {
let start_col = line.find(']').map_or(1, |i| i + 1);
let end_col = start_col + 1;
Diagnostic::new(
"SC2171",
Severity::Error,
"Found trailing ] without opening [".to_string(),
Span::new(line_num, start_col, line_num, end_col),
)
}
struct HeredocState {
in_heredoc: bool,
marker: Option<String>,
}
impl HeredocState {
fn new() -> Self {
Self {
in_heredoc: false,
marker: None,
}
}
fn enter(&mut self, marker: String) {
self.marker = Some(marker);
self.in_heredoc = true;
}
fn exit(&mut self) {
self.in_heredoc = false;
self.marker = None;
}
fn process_line(&mut self, line: &str) -> bool {
if !self.in_heredoc {
if let Some(marker) = try_enter_heredoc(line) {
self.enter(marker);
return true;
}
}
if self.in_heredoc {
if let Some(ref marker) = self.marker {
if should_exit_heredoc(line, marker) {
self.exit();
}
}
return true; }
false
}
}
pub fn check(source: &str) -> LintResult {
let mut result = LintResult::new();
let lines: Vec<&str> = source.lines().collect();
let mut heredoc_state = HeredocState::new();
for (line_num, line) in lines.iter().enumerate() {
let line_num = line_num + 1;
if heredoc_state.process_line(line) {
continue;
}
if line.trim_start().starts_with('#') {
continue;
}
if TRAILING_BRACKET.is_match(line) {
let diagnostic = create_trailing_bracket_diagnostic(line, line_num);
result.add(diagnostic);
}
}
result
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_sc2171_trailing_bracket() {
let code = "] && echo ok";
let result = check(code);
assert_eq!(result.diagnostics.len(), 1);
}
#[test]
fn test_sc2171_if_missing_open() {
let code = r#"if "$a" = x ]; then"#;
let result = check(code);
assert_eq!(result.diagnostics.len(), 0);
}
#[test]
fn test_sc2171_matched_ok() {
let code = r#"[ "$a" = x ]"#;
let result = check(code);
assert_eq!(result.diagnostics.len(), 0);
}
#[test]
fn test_sc2171_comment_ok() {
let code = "# ]";
let result = check(code);
assert_eq!(result.diagnostics.len(), 0);
}
#[test]
fn test_sc2171_array_subscript_ok() {
let code = r#"echo "${arr[0]}""#;
let result = check(code);
assert_eq!(result.diagnostics.len(), 0);
}
#[test]
fn test_sc2171_double_bracket_ok() {
let code = "[[ $a = x ]]";
let result = check(code);
assert_eq!(result.diagnostics.len(), 0);
}
#[test]
fn test_sc2171_standalone_close() {
let code = " ]";
let result = check(code);
assert_eq!(result.diagnostics.len(), 1);
}
#[test]
fn test_sc2171_multiple() {
let code = "]\n]";
let result = check(code);
assert_eq!(result.diagnostics.len(), 2);
}
#[test]
fn test_sc2171_end_of_test_ok() {
let code = "if [ -f file ]; then";
let result = check(code);
assert_eq!(result.diagnostics.len(), 0);
}
#[test]
fn test_sc2171_case_pattern_ok() {
let code = " pattern)";
let result = check(code);
assert_eq!(result.diagnostics.len(), 0);
}
#[test]
fn test_issue_21_json_bracket_in_heredoc() {
let code = r#"#!/bin/bash
cat > config.json <<'EOF'
{
"transitions": [
{"from": "a", "to": "b"}
]
}
EOF"#;
let result = check(code);
assert_eq!(
result.diagnostics.len(),
0,
"JSON brackets inside heredocs should not trigger SC2171"
);
}
#[test]
fn test_issue_21_yaml_bracket_in_heredoc() {
let code = r#"cat <<EOF
items:
- name: test
values: [1, 2, 3]
EOF"#;
let result = check(code);
assert_eq!(
result.diagnostics.len(),
0,
"YAML brackets inside heredocs should not trigger SC2171"
);
}
#[test]
fn test_issue_21_multiline_heredoc() {
let code = r#"cat <<'END'
line 1
]
line 3
END"#;
let result = check(code);
assert_eq!(
result.diagnostics.len(),
0,
"Brackets inside heredocs should be ignored"
);
}
#[test]
fn test_sc2171_heredoc_dash_variant() {
let code = r#"cat <<-EOF
]
EOF"#;
let result = check(code);
assert_eq!(result.diagnostics.len(), 0);
}
#[test]
fn test_sc2171_outside_heredoc_still_detects() {
let code = r#"cat <<EOF
valid heredoc
EOF
]
echo "after heredoc""#;
let result = check(code);
assert_eq!(
result.diagnostics.len(),
1,
"Should detect ] outside heredoc"
);
}
#[cfg(test)]
mod property_tests {
use super::*;
use proptest::prelude::*;
proptest! {
#![proptest_config(proptest::test_runner::Config::with_cases(10))]
#[test]
fn prop_heredoc_content_never_triggers_sc2171(
content in r"[ \]\[\{\}a-zA-Z0-9\n]{1,100}"
) {
let code = format!("cat <<EOF\n{}\nEOF", content);
let result = check(&code);
prop_assert_eq!(result.diagnostics.len(), 0);
}
#[test]
fn prop_standalone_bracket_always_detected(
prefix in r"[ \t]{0,10}"
) {
let code = format!("{}]", prefix);
let result = check(&code);
prop_assert_eq!(result.diagnostics.len(), 1);
}
#[test]
fn prop_heredoc_markers_are_case_sensitive(
marker in r"[A-Z]{3,10}"
) {
let code = format!("cat <<{}\n ]\n{}", marker, marker);
let result = check(&code);
prop_assert_eq!(result.diagnostics.len(), 0, "Bracket inside heredoc with marker {} should not trigger", marker);
}
}
}
}