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
//! SC1044: End token not found (unterminated heredoc)
//!
//! # Examples
//!
//! Bad:
//! ```bash
//! cat <<EOF
//! hello world
//! # missing closing EOF
//! ```
//!
//! Good:
//! ```bash
//! cat <<EOF
//! hello world
//! EOF
//! ```
//!
//! # Rationale
//!
//! Every heredoc must be terminated by its delimiter on its own line.
//! If the closing delimiter is never found, the shell will consume the rest
//! of the script as heredoc content, causing confusing errors.
//!
//! # `<<<` is a herestring, not a heredoc
//!
//! ```bash
//! grep -c . <<<'hello' # one line of input. No body, no terminator.
//! ```
//!
//! The delimiter regex is unanchored, so on `<<<'hello'` it matched the `<<`
//! at offset 1 — the *second and third* angle brackets — read `'hello'` as a
//! quoted terminator, and reported an unterminated heredoc for a construct
//! that has no body to terminate. A gating ERROR, on correct shell.
//!
//! Only the quoted spellings were affected, because `<<<$var` and `<<<word`
//! leave a non-word character (`$`) or make the third `<` part of a match that
//! still starts at offset 0. That is why this survived: the two forms a script
//! is most likely to contain are the two that behaved.
//!
//! SC1078's scanner already models this correctly — it classifies a `<<` into
//! `Heredoc` / `Herestring` / `Neither` — so the rule here is the outlier, and
//! the fix is the same distinction: a `<<` preceded by another `<` is the tail
//! of a herestring and starts nothing.
use crate::linter::{Diagnostic, LintResult, Severity, Span};
use regex::Regex;
/// Regex to match heredoc start and capture the delimiter (no backreferences)
#[allow(clippy::expect_used)]
static HEREDOC_START: std::sync::LazyLock<Regex> = std::sync::LazyLock::new(|| {
Regex::new(r#"<<-?\s*\\?(?:'(\w+)'|"(\w+)"|(\w+))"#).expect("valid heredoc start regex")
});
/// Extract the delimiter from captures (whichever alternative matched)
fn extract_delimiter<'a>(caps: &'a regex::Captures<'a>) -> Option<&'a str> {
caps.get(1)
.or_else(|| caps.get(2))
.or_else(|| caps.get(3))
.map(|m| m.as_str())
}
/// Where a heredoc opens on a line, and the word that must close it.
struct HeredocOpen {
/// Byte offset of the `<<` within the line.
start: usize,
/// Byte length of the whole `<<DELIM` match, for the span.
len: usize,
delimiter: String,
/// `<<-` strips leading tabs from the terminator line too.
strips_tabs: bool,
}
/// The first `<<` on the line that actually opens a heredoc.
///
/// The regex has no lookbehind, so a match that begins immediately after
/// another `<` is the tail of a `<<<` herestring and is skipped. Scanning ON
/// rather than returning `None` at the first herestring keeps a real heredoc
/// later on the same line visible: `grep . <<<"$x" && cat <<EOF` opens one.
fn first_heredoc_start(line: &str) -> Option<HeredocOpen> {
HEREDOC_START.captures_iter(line).find_map(|caps| {
let m = caps.get(0)?;
// Group 0 begins at the `<<`; a `<` right before it means `<<<`.
if m.start()
.checked_sub(1)
.is_some_and(|prev| line.as_bytes()[prev] == b'<')
{
return None;
}
Some(HeredocOpen {
start: m.start(),
len: m.len(),
delimiter: extract_delimiter(&caps)?.to_string(),
strips_tabs: line[m.start()..].starts_with("<<-"),
})
})
}
/// Index of the line that closes `open`, searching from `from` onwards.
fn terminator_line(lines: &[&str], from: usize, open: &HeredocOpen) -> Option<usize> {
lines.iter().enumerate().skip(from).find_map(|(j, line)| {
let candidate = if open.strips_tabs {
line.trim_start_matches('\t')
} else {
line
};
(candidate.trim() == open.delimiter).then_some(j)
})
}
/// Check for unterminated heredocs
pub fn check(source: &str) -> LintResult {
let mut result = LintResult::new();
let lines: Vec<&str> = source.lines().collect();
let mut i = 0;
while i < lines.len() {
let line = lines[i];
if line.trim_start().starts_with('#') {
i += 1;
continue;
}
let Some(open) = first_heredoc_start(line) else {
i += 1;
continue;
};
match terminator_line(&lines, i + 1, &open) {
Some(j) => i = j + 1,
None => {
let line_num = i + 1;
let col = open.start + 1;
result.add(Diagnostic::new(
"SC1044",
Severity::Error,
format!(
"Couldn't find end token '{}' for this heredoc",
open.delimiter
),
Span::new(line_num, col, line_num, col + open.len),
));
i += 1;
}
}
}
result
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_sc1044_detects_unterminated_heredoc() {
let script = "cat <<EOF\nhello world\n# no closing delimiter";
let result = check(script);
assert_eq!(result.diagnostics.len(), 1);
assert_eq!(result.diagnostics[0].code, "SC1044");
assert_eq!(result.diagnostics[0].severity, Severity::Error);
assert!(result.diagnostics[0].message.contains("EOF"));
}
#[test]
fn test_sc1044_no_flag_terminated_heredoc() {
let script = "cat <<EOF\nhello world\nEOF";
let result = check(script);
assert_eq!(result.diagnostics.len(), 0);
}
#[test]
fn test_sc1044_no_flag_strip_heredoc_with_tabs() {
let script = "cat <<-EOF\n\thello\n\tEOF";
let result = check(script);
assert_eq!(result.diagnostics.len(), 0);
}
#[test]
fn test_sc1044_detects_wrong_delimiter() {
// Opening says EOF but closing says END
let script = "cat <<EOF\nhello\nEND";
let result = check(script);
assert_eq!(result.diagnostics.len(), 1);
}
#[test]
fn test_sc1044_multiple_heredocs_one_unterminated() {
let script = "cat <<EOF\nhello\nEOF\ncat <<MARKER\nworld";
let result = check(script);
assert_eq!(result.diagnostics.len(), 1);
assert!(result.diagnostics[0].message.contains("MARKER"));
}
#[test]
fn test_sc1044_no_false_positive_comment() {
let script = "# cat <<EOF\nhello\n# not a heredoc";
let result = check(script);
assert_eq!(result.diagnostics.len(), 0);
}
/// A herestring has no body and no terminator. Every spelling of one, and
/// one row per QUOTING FORM rather than one for the form as a whole —
/// `<<<$var` and `<<<word` were always fine and would have signed off on
/// the bug, which is exactly how it shipped.
#[test]
fn test_sc1044_herestring_is_not_a_heredoc() {
for script in [
"grep -c . <<<'hello'",
"grep -c . <<<\"hello\"",
"grep -c . <<<\"$x\"",
"grep -c . <<<word",
"grep -qxF -- \"$j\" <<<'literal with spaces'",
"read -r a b <<<\"$line\"",
] {
let result = check(script);
assert_eq!(
result.diagnostics.len(),
0,
"herestring reported as an unterminated heredoc: {script}"
);
}
}
/// Skipping the herestring must SCAN ON, not give up on the line: a real
/// heredoc opened after one is still unterminated and must still be caught.
#[test]
fn test_sc1044_real_heredoc_after_a_herestring_still_flagged() {
let script = "grep . <<<'x' && cat <<EOF\nbody\n# no terminator";
let result = check(script);
assert_eq!(result.diagnostics.len(), 1);
assert!(result.diagnostics[0].message.contains("EOF"));
}
/// ...and a terminated one after a herestring is still clean.
#[test]
fn test_sc1044_terminated_heredoc_after_a_herestring_is_clean() {
let script = "grep . <<<'x' && cat <<EOF\nbody\nEOF";
let result = check(script);
assert_eq!(result.diagnostics.len(), 0);
}
}