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
//! Heredoc region scanning, shared by every lint rule.
//!
//! GH-217: the rules see a flat stream of physical lines with no notion of
//! heredoc regions, so any line-oriented rule fires *inside* heredoc bodies. A
//! **quoted** heredoc body (`<<'EOF'` / `<<"EOF"`) is literal text by
//! definition — that is the entire point of quoting the delimiter — so shell
//! rules must not analyse it. Embedding Python, awk or jq that way is a common
//! idiom, and `SC1007` is `Severity::Error`, so the false positives block
//! commits.
//!
//! This lives in shared scanning code rather than in individual rules on
//! purpose. `sc2006` already had a private copy of this logic (issue #96) and
//! the other 384 rules did not, which is precisely how GH-217 happened: a
//! per-rule fix cannot generalise, and the next line-oriented rule would have
//! reintroduced the bug. Applying it once, where diagnostics are aggregated,
//! covers every rule that exists and every rule not yet written.
//!
//! **Unquoted heredocs are deliberately NOT skipped.** Their bodies undergo
//! parameter expansion and command substitution, so they really are shell and
//! rules like SC2006 should still fire there.
use std::collections::HashSet;
/// Parse the opening delimiter of a quoted heredoc, if this token starts one.
///
/// Returns the delimiter without quotes. Handles `<<'X'`, `<< 'X'`, `<<-'X'`
/// and the double-quoted forms.
fn parse_quoted_delimiter(rest: &str) -> Option<String> {
let rest = rest.strip_prefix('-').unwrap_or(rest);
let rest = rest.trim_start();
let quote = rest.chars().next()?;
if quote != '\'' && quote != '"' {
// Unquoted heredoc: body IS expanded, so it is genuinely shell. Skip.
return None;
}
let after = &rest[quote.len_utf8()..];
let end = after.find(quote)?;
let delim = &after[..end];
if delim.is_empty() {
return None;
}
Some(delim.to_string())
}
/// Every `<<`-opener on a line, in order. A single command may open more than
/// one (`cmd <<'A' <<'B'`), and POSIX consumes their bodies in that order.
fn quoted_openers_on_line(line: &str) -> Vec<String> {
let mut out = Vec::new();
let bytes = line.as_bytes();
let mut i = 0;
while i + 1 < bytes.len() {
if bytes[i] == b'<' && bytes[i + 1] == b'<' {
// `<<<` is a here-STRING, not a heredoc — it has no body.
if bytes.get(i + 2) == Some(&b'<') {
i += 3;
continue;
}
if let Some(delim) = parse_quoted_delimiter(&line[i + 2..]) {
out.push(delim);
}
i += 2;
} else {
i += 1;
}
}
out
}
/// 1-based line numbers that fall inside a **quoted** heredoc body.
///
/// The opening line itself is excluded — it is real shell and rules should
/// still see it. The terminating delimiter line is excluded too.
///
/// Implemented as a state machine rather than "regex-match every line", because
/// scanning every line for openers also matches openers that appear *inside* a
/// body (e.g. a heredoc containing shell examples), which silently extends the
/// suppressed region past where it should end.
pub fn quoted_heredoc_lines(source: &str) -> HashSet<usize> {
let mut inside = HashSet::new();
let mut pending: Vec<String> = Vec::new();
let mut active: Option<String> = None;
for (idx, line) in source.lines().enumerate() {
let line_num = idx + 1;
if let Some(delim) = active.clone() {
// `<<-` permits a tab-indented terminator; trimming is the lenient
// reading and matches the previous behaviour in sc2006.
if line.trim() == delim {
// Terminator reached; a second opener from the same command
// (`cmd <<'A' <<'B'`) starts consuming immediately.
active = if pending.is_empty() {
None
} else {
Some(pending.remove(0))
};
} else {
inside.insert(line_num);
}
continue;
}
let mut openers = quoted_openers_on_line(line);
if !openers.is_empty() {
active = Some(openers.remove(0));
pending = openers;
}
}
inside
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn embedded_python_body_is_a_region() {
// The exact reproduction from GH-217.
let src = "#!/bin/sh\npython3 - <<'PY'\np = 1\nq = 2\nPY\necho done\n";
let r = quoted_heredoc_lines(src);
assert!(r.contains(&3), "python line 3 must be inside the region");
assert!(r.contains(&4), "python line 4 must be inside the region");
assert!(!r.contains(&2), "the opening line is real shell");
assert!(!r.contains(&5), "the terminator is not body");
assert!(
!r.contains(&6),
"code after the heredoc must still be linted"
);
}
#[test]
fn unquoted_heredoc_is_not_a_region() {
// Body IS expanded, so it is shell and rules must still fire.
let src = "cat <<EOF\n`date`\nEOF\n";
assert!(quoted_heredoc_lines(src).is_empty());
}
#[test]
fn double_quoted_and_dash_forms() {
let src = "cat <<-\"EOF\"\n\tbody\n\tEOF\nafter\n";
let r = quoted_heredoc_lines(src);
assert!(r.contains(&2));
assert!(!r.contains(&4));
}
#[test]
fn here_string_has_no_body() {
let src = "cat <<<'literal'\necho after\n";
assert!(quoted_heredoc_lines(src).is_empty());
}
#[test]
fn opener_inside_a_body_does_not_extend_the_region() {
// A heredoc whose body documents another heredoc. The naive
// scan-every-line approach treats line 2 as a new opener and swallows
// everything after it.
let src = "cat <<'DOC'\nexample: cat <<'INNER'\nDOC\necho after\n";
let r = quoted_heredoc_lines(src);
assert!(r.contains(&2), "the doc line is body");
assert!(
!r.contains(&4),
"code after the outer heredoc is still linted"
);
}
#[test]
fn two_heredocs_on_one_line_consume_bodies_in_order() {
let src = "diff <<'A' <<'B'\na1\nA\nb1\nB\nafter\n";
let r = quoted_heredoc_lines(src);
assert!(r.contains(&2), "first body");
assert!(r.contains(&4), "second body");
assert!(!r.contains(&6), "code after both is linted");
}
}