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
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
//! SC1078: Did you forget to close this double-quoted string?
//!
//! Reports a double-quoted string that is opened and never closed **in the
//! whole file**.
//!
//! ## Why this is a whole-source scan and not a per-line quote count
//!
//! This rule used to flag any line with an odd number of unescaped double
//! quotes. That is not what bash means by an unterminated string. Two real
//! constructs are odd-on-a-line and perfectly valid:
//!
//! ```sh
//! DIRS="one two # opens here...
//! three four" # ...and closes here. Both lines flagged before.
//!
//! echo "intel's timer runs daily." # the apostrophe is LITERAL inside "..."
//! ```
//!
//! The second case was a state bug: the counter tracked single-quote state but
//! not double-quote state, so an apostrophe inside a double-quoted string
//! opened a phantom single-quoted region that swallowed the closing quote.
//!
//! The tell for the multi-line case was that the old rule flagged BOTH the
//! opening and the closing line — a genuinely unterminated string can only be
//! opened once. Scanning the source as one stream reports it once, at the line
//! where the string actually opens, which is the line the author must edit.
//!
//! Heredoc bodies are skipped between their start marker and terminator:
//! their contents are data, and an apostrophe in prose would otherwise poison
//! the quote state for the rest of the file.
use crate::linter::{Diagnostic, LintResult, Severity, Span};
/// Where an unterminated double-quoted string was opened.
struct OpenQuote {
line: usize,
col: usize,
}
/// Check for unclosed double-quoted strings.
pub fn check(source: &str) -> LintResult {
let mut result = LintResult::new();
let mut open: Option<OpenQuote> = None;
let mut heredoc_end: Option<String> = None;
for (idx, line) in source.lines().enumerate() {
let line_num = idx + 1;
// Inside a heredoc: the body is data until the terminator.
if let Some(term) = &heredoc_end {
if line.trim() == term.as_str() {
heredoc_end = None;
}
continue;
}
// A whole-line comment cannot open a string — but only when we are not
// already inside one, where `#` is an ordinary character.
if open.is_none() && line.trim_start().starts_with('#') {
continue;
}
if open.is_none() {
heredoc_end = heredoc_terminator(line);
}
scan_line(line, line_num, &mut open);
}
if let Some(q) = open {
result.add(Diagnostic::new(
"SC1078",
Severity::Error,
"Did you forget to close this double-quoted string?",
Span::new(q.line, q.col + 1, q.line, q.col + 2),
));
}
result
}
/// Advance the double-quote state across one line.
///
/// Three states, one step function each: inside `'...'` nothing is special but
/// the closing quote; inside `"..."` a backslash escapes and only `"` closes;
/// otherwise quotes open and an unquoted `#` ends the line.
fn scan_line(line: &str, line_num: usize, open: &mut Option<OpenQuote>) {
let bytes = line.as_bytes();
let mut i = 0;
let mut in_single = false;
while i < bytes.len() {
if in_single {
in_single = bytes[i] != b'\'';
i += 1;
} else if open.is_some() {
i = step_in_double(bytes, i, open);
} else {
match step_neutral(bytes, i, line_num, open, &mut in_single) {
Some(next) => i = next,
None => return, // a comment began; the rest is prose
}
}
}
}
/// One byte inside a double-quoted string. Returns the next index.
fn step_in_double(bytes: &[u8], i: usize, open: &mut Option<OpenQuote>) -> usize {
match bytes[i] {
b'\\' => i + 2, // escapes the next byte, whatever it is
b'"' => {
*open = None;
i + 1
}
_ => i + 1,
}
}
/// One byte outside any string. Returns the next index, or `None` if a comment
/// started and the remainder of the line must not be scanned.
fn step_neutral(
bytes: &[u8],
i: usize,
line_num: usize,
open: &mut Option<OpenQuote>,
in_single: &mut bool,
) -> Option<usize> {
match bytes[i] {
b'\\' => return Some(i + 2),
b'\'' => *in_single = true,
b'"' => {
*open = Some(OpenQuote {
line: line_num,
col: i,
})
}
// An unquoted `#` at the start of a word begins a comment.
b'#' if i == 0 || bytes[i - 1].is_ascii_whitespace() => return None,
_ => {}
}
Some(i + 1)
}
/// What follows a `<<` on the line.
enum Redirect {
/// A heredoc whose body ends at this terminator word.
Heredoc(String),
/// `<<<` is a herestring: no body, no terminator. Resume scanning here.
Herestring(usize),
/// `<<` not followed by a word (e.g. a left-shift in arithmetic).
Neither,
}
/// If `line` starts a heredoc, return the terminator word to watch for.
fn heredoc_terminator(line: &str) -> Option<String> {
let bytes = line.as_bytes();
let mut i = 0;
while i + 1 < bytes.len() {
if bytes[i] == b'<' && bytes[i + 1] == b'<' {
match classify_redirect(line, i + 2) {
Redirect::Heredoc(word) => return Some(word),
Redirect::Herestring(next) => {
i = next;
continue;
}
Redirect::Neither => {}
}
}
i += 1;
}
None
}
/// Classify what follows a `<<` that starts at `start`.
///
/// Accepts the optional `-` of `<<-`, surrounding spaces, and a quoted
/// terminator (`<<'EOF'`), since all four spellings delimit the same body.
fn classify_redirect(line: &str, start: usize) -> Redirect {
let bytes = line.as_bytes();
let mut j = start;
if bytes.get(j) == Some(&b'<') {
return Redirect::Herestring(j + 1);
}
if bytes.get(j) == Some(&b'-') {
j += 1;
}
while bytes.get(j) == Some(&b' ') {
j += 1;
}
if matches!(bytes.get(j), Some(b'\'') | Some(b'"')) {
j += 1;
}
let word_start = j;
while j < bytes.len() && (bytes[j].is_ascii_alphanumeric() || bytes[j] == b'_') {
j += 1;
}
if j > word_start {
Redirect::Heredoc(line[word_start..j].to_string())
} else {
Redirect::Neither
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_sc1078_unclosed_double_quote() {
let script = "echo \"hello world";
let result = check(script);
assert_eq!(result.diagnostics.len(), 1);
assert_eq!(result.diagnostics[0].code, "SC1078");
assert_eq!(result.diagnostics[0].severity, Severity::Error);
}
#[test]
fn test_sc1078_closed_double_quote() {
let script = "echo \"hello world\"";
let result = check(script);
assert_eq!(result.diagnostics.len(), 0);
}
#[test]
fn test_sc1078_escaped_quote_not_flagged() {
let script = r#"echo "hello \" world""#;
let result = check(script);
assert_eq!(result.diagnostics.len(), 0);
}
#[test]
fn test_sc1078_comment_skipped() {
let script = "# echo \"unclosed";
let result = check(script);
assert_eq!(result.diagnostics.len(), 0);
}
#[test]
fn test_sc1078_single_quote_inside_not_counted() {
// Single-quoted section containing " should not affect count
let script = "echo 'he said \"hi\"'";
let result = check(script);
assert_eq!(result.diagnostics.len(), 0);
}
// ── regressions found on real infra scripts (paiml/bashrs) ──────────────
#[test]
fn test_sc1078_apostrophe_inside_double_quotes_is_literal() {
// machines/lambda-labs/rag:85. The `'` in "intel's" is an ordinary
// character inside "..."; the old counter treated it as opening a
// single-quoted span, which swallowed the closing double quote.
let script = r#"echo "corpus built; intel's timer runs daily." >&2"#;
assert_eq!(check(script).diagnostics.len(), 0);
}
#[test]
fn test_sc1078_multiline_string_is_valid() {
// machines/unas/nas-sweep.sh:30-32. Valid bash, and the old rule
// flagged BOTH lines — a string can only be opened once.
let script = "DIRS=\"one two\n three four\"\necho \"$DIRS\"";
assert_eq!(check(script).diagnostics.len(), 0);
}
#[test]
fn test_sc1078_multiline_unterminated_reports_once_at_the_opening() {
// Genuinely broken: report once, on the line the author must edit.
let script = "die \"usage: tool <dir>\nthis line never closes it";
let result = check(script);
assert_eq!(result.diagnostics.len(), 1);
assert_eq!(result.diagnostics[0].span.start_line, 1);
}
#[test]
fn test_sc1078_heredoc_body_does_not_poison_quote_state() {
// Prose in a heredoc is data. Without terminator tracking the
// apostrophe here would open a string for the rest of the file.
let script = "cat <<'EOF'\nit's fine to write \" here\nEOF\necho \"ok\"";
assert_eq!(check(script).diagnostics.len(), 0);
}
#[test]
fn test_sc1078_trailing_comment_is_not_scanned() {
let script = "ls -l # don't count this apostrophe\necho \"ok\"";
assert_eq!(check(script).diagnostics.len(), 0);
}
#[test]
fn test_sc1078_herestring_is_not_a_heredoc() {
// `<<<` must not swallow the rest of the file looking for a terminator.
let script = "grep x <<< \"$var\"\necho \"unclosed";
assert_eq!(check(script).diagnostics.len(), 1);
}
}