1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
// SC1028: Parentheses in `[ ]` need escaping
//
// In single-bracket test expressions, parentheses must be escaped with `\`
// or they will be interpreted as subshell syntax.
//
// Examples:
// Bad:
// [ (expr) ]
// [ ( -f file ) ]
//
// Good:
// [ \( expr \) ]
// [ \( -f file \) ]
// [[ (expr) ]] # double brackets handle parens natively
use crate::linter::{Diagnostic, LintResult, Severity, Span};
/// Find bare `(` or `)` characters that are NOT part of `$(...)` command
/// substitution or `\(` / `\)` escaped parens.
/// Returns byte offsets of each bare paren.
fn find_bare_parens(line: &str) -> Vec<usize> {
let bytes = line.as_bytes();
let mut results = Vec::new();
let mut cmd_sub_depth: u32 = 0;
let mut i = 0;
while i < bytes.len() {
match bytes[i] {
b'\\' => {
// Skip escaped character entirely
i += 2;
continue;
}
b'$' if i + 1 < bytes.len() && bytes[i + 1] == b'(' => {
// Start of $(...) command substitution
cmd_sub_depth += 1;
i += 2;
continue;
}
b'(' if cmd_sub_depth == 0 => {
results.push(i);
}
b')' if cmd_sub_depth > 0 => {
cmd_sub_depth -= 1;
}
b')' => {
results.push(i);
}
_ => {}
}
i += 1;
}
results
}
/// Check if a line contains a single-bracket test `[ ... ]` (not `[[ ... ]]`).
fn has_single_bracket_test(line: &str) -> bool {
let bytes = line.as_bytes();
for (i, &b) in bytes.iter().enumerate() {
if b == b'[' {
// Check next char is space (single bracket test)
if i + 1 < bytes.len() && bytes[i + 1] == b' ' {
// But NOT `[[` (double bracket)
if i > 0 && bytes[i - 1] == b'[' {
continue;
}
if i + 1 < bytes.len() && bytes[i + 1] == b'[' {
continue;
}
return true;
}
}
}
false
}
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;
}
// Skip lines with [[ (double bracket handles parens fine)
if line.contains("[[") {
continue;
}
// Only check lines that contain a single-bracket test
if !has_single_bracket_test(line) {
continue;
}
// Find bare parentheses (not $( or \() within the line
for col in find_bare_parens(line) {
let start_col = col + 1;
let end_col = col + 2;
let diagnostic = Diagnostic::new(
"SC1028",
Severity::Error,
"Parentheses inside `[ ]` need escaping: use `\\(` and `\\)`".to_string(),
Span::new(line_num, start_col, line_num, end_col),
);
result.add(diagnostic);
}
}
result
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_sc1028_unescaped_paren() {
let code = "[ (expr) ]";
let result = check(code);
assert_eq!(result.diagnostics.len(), 2); // ( and )
assert_eq!(result.diagnostics[0].code, "SC1028");
assert_eq!(result.diagnostics[0].severity, Severity::Error);
}
#[test]
fn test_sc1028_unescaped_paren_with_file_test() {
let code = "[ ( -f file ) ]";
let result = check(code);
assert_eq!(result.diagnostics.len(), 2); // ( and )
}
#[test]
fn test_sc1028_escaped_paren_ok() {
let code = r"[ \( -f file \) ]";
let result = check(code);
assert_eq!(result.diagnostics.len(), 0);
}
#[test]
fn test_sc1028_double_bracket_ok() {
let code = "[[ ( -f file ) ]]";
let result = check(code);
assert_eq!(result.diagnostics.len(), 0);
}
#[test]
fn test_sc1028_comment_ok() {
let code = "# [ (expr) ]";
let result = check(code);
assert_eq!(result.diagnostics.len(), 0);
}
#[test]
fn test_sc1028_command_substitution_ok() {
// $( ) inside [ ] should NOT trigger — it's command substitution, not grouping
let code = "[ -n \"$(echo hello)\" ]";
let result = check(code);
assert_eq!(result.diagnostics.len(), 0);
}
#[test]
fn test_sc1028_no_bracket_test() {
let code = "echo (hello)";
let result = check(code);
assert_eq!(result.diagnostics.len(), 0);
}
}