use super::shell_lex::QuoteScan;
struct Delimiter {
word: String,
strip_tabs: bool,
}
pub(super) struct HeredocBodies {
spans: Vec<(usize, usize)>,
frames: Vec<(usize, usize)>,
data_spans: Vec<(usize, usize)>,
}
impl HeredocBodies {
pub(super) fn scan(command: &str) -> Self {
let quotes = QuoteScan::new(command);
if !quotes.balanced {
return Self::empty();
}
let lines = line_spans(command);
let mut spans = Vec::new();
let mut frames = Vec::new();
let mut data_spans = Vec::new();
let mut line = 0;
while line < lines.len() {
let (start, end) = lines[line];
let operator_line = &command[start..end];
let delimiters = delimiters_on(operator_line, start, "es);
let framing = !delimiters.is_empty() && !line_runs_a_shell(operator_line);
line += 1;
for delimiter in delimiters {
let Some(body) = body_span(command, &lines, line, &delimiter) else {
return Self::empty();
};
if body.span.0 < body.span.1 {
spans.push(body.span);
if framing {
data_spans.push(body.span);
}
}
if framing {
frames.push((body.span.0.saturating_sub(1), body.frame_end));
}
line = body.next_line;
}
}
Self {
spans,
frames,
data_spans,
}
}
fn empty() -> Self {
Self {
spans: Vec::new(),
frames: Vec::new(),
data_spans: Vec::new(),
}
}
pub(super) fn contains(&self, idx: usize) -> bool {
self.spans
.iter()
.any(|(start, end)| idx >= *start && idx < *end)
}
pub(super) fn suppresses_separator(&self, idx: usize) -> bool {
self.frames
.iter()
.any(|(start, end)| idx >= *start && idx < *end)
}
}
pub(crate) fn split_heredoc_bodies(command: &str) -> (String, Vec<String>) {
let bodies = HeredocBodies::scan(command);
if bodies.data_spans.is_empty() {
return (command.to_string(), Vec::new());
}
let mut argv_text = String::with_capacity(command.len());
let mut texts = Vec::with_capacity(bodies.data_spans.len());
let mut cursor = 0;
for &(start, end) in &bodies.data_spans {
argv_text.push_str(&command[cursor..start]);
let body = &command[start..end];
for c in body.chars() {
if c == '\n' {
argv_text.push('\n');
} else {
argv_text.extend(std::iter::repeat_n(' ', c.len_utf8()));
}
}
texts.push(body.to_string());
cursor = end;
}
argv_text.push_str(&command[cursor..]);
(argv_text, texts)
}
fn line_runs_a_shell(line: &str) -> bool {
line.split_whitespace().any(|token| {
let base = token.rsplit('/').next().unwrap_or(token);
super::shell_lex::DASH_C_SHELLS.contains(&base)
})
}
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')'
)
}
struct Body {
span: (usize, usize),
frame_end: usize,
next_line: usize,
}
fn body_span(
command: &str,
lines: &[(usize, usize)],
from: usize,
delimiter: &Delimiter,
) -> Option<Body> {
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 {
span: (body_start, *start),
frame_end: *end,
next_line: 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_frames_cover_the_terminator_line() {
let command = "cat <<'EOF' > f\nbody\nEOF\nnext";
let bodies = HeredocBodies::scan(command);
let opening_newline = command.find('\n').expect("operator line ends");
assert!(bodies.suppresses_separator(opening_newline));
assert!(!bodies.suppresses_separator(opening_newline - 1));
let terminator = command.find("EOF\nnext").expect("terminator line");
for idx in terminator..terminator + 3 {
assert!(bodies.suppresses_separator(idx));
}
assert!(!bodies.suppresses_separator(terminator + 3));
}
#[test]
fn heredoc_frames_are_empty_for_a_shell_operator_line() {
for command in [
"bash <<'EOF'\nbody\nEOF",
"sudo /bin/sh <<EOF\nbody\nEOF",
"true && zsh <<EOF\nbody\nEOF",
] {
let bodies = HeredocBodies::scan(command);
let newline = command.find('\n').expect("operator line ends");
assert!(
!bodies.suppresses_separator(newline),
"{command} should not be framed"
);
assert!(bodies.contains(newline + 1), "{command} body span");
}
}
#[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 splits_a_heredoc_body_out_of_the_argv_text() {
let command = "cat >> verb.rs <<'RSEOF'\nstruct VerbStub {\n}\nRSEOF";
let (argv_text, bodies) = split_heredoc_bodies(command);
assert_eq!(argv_text.len(), command.len(), "byte offsets are preserved");
assert!(argv_text.starts_with("cat >> verb.rs <<'RSEOF'"));
assert!(argv_text.trim_end().ends_with("RSEOF"), "{argv_text:?}");
assert!(!argv_text.contains('{'), "{argv_text:?}");
assert_eq!(bodies, vec!["struct VerbStub {\n}\n".to_string()]);
}
#[test]
fn leaves_a_shell_heredoc_body_in_the_argv_text() {
let command = "bash <<'EOF'\nls .env\nEOF";
let (argv_text, bodies) = split_heredoc_bodies(command);
assert_eq!(argv_text, command);
assert!(bodies.is_empty());
}
#[test]
fn splits_nothing_without_a_heredoc() {
for command in ["awk '{print}' f", "cat <<EOF\nno terminator"] {
let (argv_text, bodies) = split_heredoc_bodies(command);
assert_eq!(argv_text, command);
assert!(bodies.is_empty(), "{command:?}");
}
}
#[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");
}
}