use crate::linter::{Diagnostic, LintResult, Severity, Span};
use regex::Regex;
static UNBRACED_POSITIONAL: std::sync::LazyLock<Regex> =
std::sync::LazyLock::new(|| Regex::new(r"\$([1-9][0-9]+)").expect("SC1037 regex must compile"));
static BRACED_POSITIONAL: std::sync::LazyLock<Regex> = std::sync::LazyLock::new(|| {
Regex::new(r"\$\{[1-9][0-9]+\}").expect("SC1037 braced regex must compile")
});
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;
}
for mat in UNBRACED_POSITIONAL.find_iter(line) {
let start = mat.start();
if start >= 2
&& line.is_char_boundary(start - 2)
&& line.is_char_boundary(start)
&& &line[start - 2..start] == "${"
{
continue;
}
if start >= 1 && line.as_bytes()[start - 1] == b'{' {
continue;
}
if !line.is_char_boundary(start) {
continue;
}
let before = &line[..start];
let after_end = mat.end();
if !line.is_char_boundary(after_end) {
continue;
}
if is_inside_braced_expansion(before, &line[after_end..]) {
continue;
}
let param = &mat.as_str()[1..]; let start_col = start + 1;
let end_col = mat.end() + 1;
result.add(Diagnostic::new(
"SC1037",
Severity::Error,
format!(
"Braces are required for positional parameters beyond $9. Use ${{{}}} instead of ${}.",
param, param
),
Span::new(line_num, start_col, line_num, end_col),
));
}
}
result
}
fn is_inside_braced_expansion(before: &str, _after: &str) -> bool {
let mut depth = 0i32;
let bytes = before.as_bytes();
let mut i = 0;
while i < bytes.len() {
if i + 1 < bytes.len() && bytes[i] == b'$' && bytes[i + 1] == b'{' {
depth += 1;
i += 2;
continue;
}
if bytes[i] == b'}' && depth > 0 {
depth -= 1;
}
i += 1;
}
depth > 0
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_sc1037_unbraced_positional_10() {
let code = "echo $10";
let result = check(code);
assert_eq!(result.diagnostics.len(), 1);
assert_eq!(result.diagnostics[0].code, "SC1037");
assert_eq!(result.diagnostics[0].severity, Severity::Error);
assert!(result.diagnostics[0].message.contains("${10}"));
}
#[test]
fn test_sc1037_unbraced_positional_11() {
let code = "echo $11";
let result = check(code);
assert_eq!(result.diagnostics.len(), 1);
}
#[test]
fn test_sc1037_unbraced_positional_123() {
let code = "echo $123";
let result = check(code);
assert_eq!(result.diagnostics.len(), 1);
assert!(result.diagnostics[0].message.contains("${123}"));
}
#[test]
fn test_sc1037_braced_positional_ok() {
let code = "echo ${10}";
let result = check(code);
assert_eq!(result.diagnostics.len(), 0);
}
#[test]
fn test_sc1037_braced_positional_11_ok() {
let code = "echo ${11} ${12}";
let result = check(code);
assert_eq!(result.diagnostics.len(), 0);
}
#[test]
fn test_sc1037_single_digit_ok() {
let code = "echo $1 $2 $9";
let result = check(code);
assert_eq!(result.diagnostics.len(), 0);
}
#[test]
fn test_sc1037_comment_ok() {
let code = "# echo $10";
let result = check(code);
assert_eq!(result.diagnostics.len(), 0);
}
#[test]
fn test_sc1037_multiple_violations() {
let code = "echo $10 $20 $30";
let result = check(code);
assert_eq!(result.diagnostics.len(), 3);
}
}