use super::shell_lex::QuoteScan;
struct Delimiter {
word: String,
strip_tabs: bool,
}
pub(super) struct HeredocBodies {
spans: Vec<(usize, usize)>,
}
impl HeredocBodies {
pub(super) fn scan(command: &str) -> Self {
let quotes = QuoteScan::new(command);
if !quotes.balanced {
return Self { spans: Vec::new() };
}
let lines = line_spans(command);
let mut spans = Vec::new();
let mut line = 0;
while line < lines.len() {
let (start, end) = lines[line];
let delimiters = delimiters_on(&command[start..end], start, "es);
line += 1;
for delimiter in delimiters {
let Some((body, next)) = body_span(command, &lines, line, &delimiter) else {
return Self { spans: Vec::new() };
};
if body.0 < body.1 {
spans.push(body);
}
line = next;
}
}
Self { spans }
}
pub(super) fn contains(&self, idx: usize) -> bool {
self.spans
.iter()
.any(|(start, end)| idx >= *start && idx < *end)
}
}
fn line_spans(command: &str) -> Vec<(usize, usize)> {
let mut spans = Vec::new();
let mut start = 0;
for (idx, byte) in command.bytes().enumerate() {
if byte == b'\n' {
spans.push((start, idx));
start = idx + 1;
}
}
spans.push((start, command.len()));
spans
}
fn delimiters_on(line: &str, offset: usize, quotes: &QuoteScan) -> Vec<Delimiter> {
let bytes = line.as_bytes();
let mut found = Vec::new();
let mut i = 0;
while i + 1 < bytes.len() {
if !quotes.is_unquoted(offset + i) || bytes[i] != b'<' || bytes[i + 1] != b'<' {
i += 1;
continue;
}
if bytes.get(i + 2) == Some(&b'<') {
i += 3;
continue;
}
let mut j = i + 2;
let strip_tabs = bytes.get(j) == Some(&b'-');
if strip_tabs {
j += 1;
}
while matches!(bytes.get(j), Some(b' ' | b'\t')) {
j += 1;
}
let word_start = j;
while j < bytes.len() && !is_word_break(bytes[j]) {
j += 1;
}
let word: String = line[word_start..j]
.chars()
.filter(|c| !matches!(c, '\'' | '"' | '\\'))
.collect();
if !word.is_empty() {
found.push(Delimiter { word, strip_tabs });
}
i = j.max(i + 2);
}
found
}
fn is_word_break(byte: u8) -> bool {
matches!(
byte,
b' ' | b'\t' | b'<' | b'>' | b'|' | b';' | b'&' | b'(' | b')'
)
}
fn body_span(
command: &str,
lines: &[(usize, usize)],
from: usize,
delimiter: &Delimiter,
) -> Option<((usize, usize), usize)> {
let body_start = lines.get(from)?.0;
for (index, (start, end)) in lines.iter().enumerate().skip(from) {
let text = command[*start..*end].trim_end_matches('\r');
let candidate = if delimiter.strip_tabs {
text.trim_start_matches('\t')
} else {
text
};
if candidate == delimiter.word {
return Some(((body_start, *start), index + 1));
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn heredoc_bodies_cover_a_quoted_delimiter_body() {
let command = "python3 <<'PY'\nprint(len(k) > 3)\nPY";
let bodies = HeredocBodies::scan(command);
let gt = command.find('>').expect("comparison operator");
assert!(bodies.contains(gt), "the script's `>` must be body content");
}
#[test]
fn heredoc_bodies_exclude_the_operator_line() {
let command = "python3 <<'PY' > out.rs\nprint(1)\nPY";
let bodies = HeredocBodies::scan(command);
let gt = command.find('>').expect("redirect");
assert!(!bodies.contains(gt), "the operator line must stay live");
}
#[test]
fn heredoc_bodies_ignore_a_here_string() {
let command = "grep x <<< 'a > b'\necho done > f.rs";
let bodies = HeredocBodies::scan(command);
let redirect = command.rfind('>').expect("redirect");
assert!(!bodies.contains(redirect));
}
#[test]
fn heredoc_bodies_claim_nothing_when_unterminated() {
let unterminated = "python3 <<'PY'\nprint(1)\n";
assert!(!HeredocBodies::scan(unterminated).contains(20));
let shift = "echo $((1 << 3))\necho x > f.rs";
let redirect = shift.rfind('>').expect("redirect");
assert!(!HeredocBodies::scan(shift).contains(redirect));
}
#[test]
fn heredoc_bodies_handle_tab_stripped_delimiter() {
let command = "cat <<-EOF\n\ta > b\n\tEOF";
let bodies = HeredocBodies::scan(command);
let gt = command.find('>').expect("arrow");
assert!(bodies.contains(gt));
}
#[test]
fn heredoc_bodies_ignore_quoted_operator() {
let command = "grep -n '<<EOF' f\necho x > g.rs";
let bodies = HeredocBodies::scan(command);
let redirect = command.rfind('>').expect("redirect");
assert!(!bodies.contains(redirect));
}
#[test]
fn heredoc_bodies_span_two_heredocs_on_one_line() {
let command = "diff <<A <<B\na > b\nA\nc > d\nB";
let bodies = HeredocBodies::scan(command);
let first = command.find('>').expect("first arrow");
let second = command.rfind('>').expect("second arrow");
assert!(bodies.contains(first), "first body claimed");
assert!(bodies.contains(second), "second body claimed");
}
}