use super::credential_print::{OperatorCtx, heredoc_operators};
use super::heredoc_line::line_runs_a_shell;
use super::shell_lex::QuoteScan;
struct Delimiter {
word: String,
strip_tabs: bool,
quoted: bool,
ambiguous: bool,
}
#[derive(Clone)]
pub(crate) struct DataBody {
pub(crate) span: (usize, usize),
pub(crate) operator_line: (usize, usize),
pub(crate) expands: bool,
}
pub(super) struct HeredocBodies {
spans: Vec<(usize, usize)>,
frames: Vec<(usize, usize)>,
data: Vec<DataBody>,
expanding: Vec<(usize, usize)>,
bodies: Vec<DataBody>,
unscannable: bool,
lost: bool,
}
struct Partial {
expanding: Vec<(usize, usize)>,
bodies: Vec<DataBody>,
}
enum Abandon {
NoConfidence(Partial),
Delimiter,
Unterminated(Partial),
}
pub(crate) const HEREDOC_DELIMITER_REASON: &str = "this command opens a here-document the \
guard cannot delimit: its delimiter word is not letters, digits, `_`, `.` and `-` (bare, \
in one pair of quotes, or `\\`-escaped), or its `<<` sits in arithmetic or in a `$(…)` or \
backtick that closes on the same line while a later line matches the word, or a `\\`-newline \
line continuation joins two `<`, follows a `#` on a here-document operator line, or joins an \
unquoted body's line to the line that would end it (#9180). The shell may end that body at a different line than the \
guard finds, so the guard cannot tell which lines run (#9150). Use a plain delimiter such as \
`<<'EOF'` in plain command position, with no `\\` at the end of the operator line or of the \
body line before the terminator.";
impl HeredocBodies {
pub(super) fn scan(command: &str) -> Self {
let ops = heredoc_operators(command);
let mut found = Self::scan_with(command, &ops);
found.lost &= ops.iter().any(|(_, ctx)| *ctx == OperatorCtx::Code);
found
}
fn scan_with(command: &str, ops: &[(usize, OperatorCtx)]) -> Self {
let quotes = QuoteScan::new(command);
if quotes.balanced {
return Self::settle(Self::collect(command, Some("es), ops));
}
match Self::collect(command, None, ops) {
Ok(found)
if !found.spans.is_empty()
&& QuoteScan::new(&blank_spans(command, &found.spans)).balanced =>
{
found
}
Ok(found) => Self {
expanding: found.expanding,
bodies: found.bodies,
lost: !found.spans.is_empty(),
..Self::empty()
},
Err(why) => Self::settle(Err(why)),
}
}
pub(super) fn lost_confidence(&self) -> bool {
self.lost
}
pub(super) fn bodies(&self) -> &[DataBody] {
&self.bodies
}
pub(super) fn is_unscannable(&self) -> bool {
self.unscannable
}
fn settle(collected: Result<Self, Abandon>) -> Self {
match collected {
Ok(found) => found,
Err(Abandon::Unterminated(partial) | Abandon::NoConfidence(partial)) => Self {
expanding: partial.expanding,
bodies: partial.bodies,
lost: true,
..Self::empty()
},
Err(Abandon::Delimiter) => Self {
unscannable: true,
..Self::empty()
},
}
}
fn collect(
command: &str,
quotes: Option<&QuoteScan>,
ops: &[(usize, OperatorCtx)],
) -> Result<Self, Abandon> {
let lines = line_spans(command);
let mut spans = Vec::new();
let mut frames = Vec::new();
let mut data = Vec::new();
let mut expanding = Vec::new();
let mut bodies = Vec::new();
let mut line = 0;
while line < lines.len() {
let (start, mut end) = lines[line];
let mut joined = false;
while line + 1 < lines.len() && continues(command, lines[line], quotes) {
if fuses_a_redirect(command, lines[line].1) {
return Err(Abandon::Delimiter);
}
line += 1;
end = lines[line].1;
joined = true;
}
let operator_line = &command[start..end];
let delimiters = match quotes {
Some(quotes) => delimiters_on(operator_line, (start, start), quotes, ops),
None => {
let own = QuoteScan::new(operator_line);
let read = delimiters_on(operator_line, (start, 0), &own, ops);
if !own.balanced && read.is_some() {
return Err(Abandon::NoConfidence(Partial { expanding, bodies }));
}
read
}
};
let delimiters = delimiters.ok_or(Abandon::Delimiter)?;
if joined && !delimiters.is_empty() && has_comment_mark(command, (start, end), quotes) {
return Err(Abandon::Delimiter);
}
let framing = !delimiters.is_empty() && !line_runs_a_shell(operator_line);
line += 1;
for delimiter in delimiters {
if delimiter.ambiguous {
if !matches!(body_span(command, &lines, line, &delimiter), Ok(None)) {
return Err(Abandon::Delimiter);
}
continue;
}
let Some(body) = body_span(command, &lines, line, &delimiter)? else {
let rest = lines.get(line).map_or(command.len(), |l| l.0);
if rest < command.len() {
if !delimiter.quoted {
expanding.push((rest, command.len()));
}
bodies.push(DataBody {
span: (rest, command.len()),
operator_line: (start, end),
expands: !delimiter.quoted,
});
}
return Err(Abandon::Unterminated(Partial { expanding, bodies }));
};
if body.span.0 < body.span.1 {
bodies.push(DataBody {
span: body.span,
operator_line: (start, end),
expands: !delimiter.quoted,
});
spans.push(body.span);
if !delimiter.quoted {
expanding.push(body.span);
}
if framing {
data.push(DataBody {
span: body.span,
operator_line: (start, end),
expands: !delimiter.quoted,
});
}
}
if framing {
frames.push((body.span.0.saturating_sub(1), body.frame_end));
}
line = body.next_line;
}
}
Ok(Self {
spans,
frames,
data,
expanding,
bodies,
unscannable: false,
lost: false,
})
}
fn empty() -> Self {
Self {
spans: Vec::new(),
frames: Vec::new(),
data: Vec::new(),
expanding: Vec::new(),
bodies: Vec::new(),
unscannable: false,
lost: false,
}
}
pub(super) fn shell_body_starting_at(&self, idx: usize) -> Option<usize> {
self.spans
.iter()
.find(|span| span.0 == idx && !self.data.iter().any(|d| d.span == **span))
.map(|span| span.1)
}
#[cfg(test)]
pub(super) fn spans(&self) -> &[(usize, usize)] {
&self.spans
}
pub(super) fn data(&self) -> &[DataBody] {
&self.data
}
pub(super) fn expanding(&self) -> &[(usize, usize)] {
&self.expanding
}
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 spans: Vec<(usize, usize)> = data_bodies(command).iter().map(|b| b.span).collect();
let texts = spans
.iter()
.map(|&(s, e)| command[s..e].to_string())
.collect();
(blank_spans(command, &spans), texts)
}
pub(crate) fn data_bodies(command: &str) -> Vec<DataBody> {
HeredocBodies::scan(command).data
}
fn continues(command: &str, (start, end): (usize, usize), quotes: Option<&QuoteScan>) -> bool {
let bytes = &command.as_bytes()[start..end];
let run = bytes.iter().rev().take_while(|b| **b == b'\\').count();
if run.is_multiple_of(2) || end >= command.len() {
return false;
}
match quotes {
Some(quotes) => !quotes.balanced || quotes.allows_substitution(end - 1),
None => {
let own = QuoteScan::new(&command[start..end]);
!own.balanced || own.allows_substitution(bytes.len() - 1)
}
}
}
fn fuses_a_redirect(command: &str, end: usize) -> bool {
let bytes = command.as_bytes();
let before = bytes[..end].iter().rev().find(|b| **b != b'\\');
before == Some(&b'<') && bytes.get(end + 1) == Some(&b'<')
}
fn has_comment_mark(
command: &str,
(start, end): (usize, usize),
quotes: Option<&QuoteScan>,
) -> bool {
let own;
let (quotes, offset) = match quotes {
Some(quotes) => (quotes, start),
None => {
own = QuoteScan::new(&command[start..end]);
(&own, 0)
}
};
command.as_bytes()[start..end]
.iter()
.enumerate()
.any(|(i, b)| *b == b'#' && quotes.is_unquoted(offset + i))
}
pub(crate) fn blank_spans(command: &str, spans: &[(usize, usize)]) -> String {
let mut bytes = command.as_bytes().to_vec();
for &(start, end) in spans {
for byte in &mut bytes[start..end] {
if *byte != b'\n' {
*byte = b' ';
}
}
}
String::from_utf8_lossy(&bytes).into_owned()
}
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,
(at, offset): (usize, usize),
quotes: &QuoteScan,
ops: &[(usize, OperatorCtx)],
) -> Option<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 Some(&(_, ctx)) = ops.iter().find(|(pos, _)| *pos == at + i) else {
i += 2;
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 ambiguous = ctx == OperatorCtx::Ambiguous;
let (word, quoted, end) = if ambiguous {
loose_word(bytes, j)
} else {
delimiter_word(bytes, j)?
};
if !word.is_empty() {
found.push(Delimiter {
word,
strip_tabs,
quoted,
ambiguous,
});
}
i = end.max(i + 2);
}
Some(found)
}
pub(super) fn delimiter_word(bytes: &[u8], start: usize) -> Option<(String, bool, usize)> {
let mut word = String::new();
let mut quoted = false;
let mut j = start;
while let Some(&byte) = bytes.get(j) {
match byte {
b'\'' | b'"' => {
let len = bytes[j + 1..].iter().position(|b| *b == byte)?;
let inner = &bytes[j + 1..j + 1 + len];
if inner.is_empty() || !inner.iter().all(|b| is_word_byte(*b)) {
return None;
}
word.extend(inner.iter().map(|b| char::from(*b)));
quoted = true;
j += len + 2;
}
b'\\' => {
let next = bytes.get(j + 1).filter(|b| is_word_byte(**b))?;
word.push(char::from(*next));
quoted = true;
j += 2;
}
b'\r' if j + 1 == bytes.len() => {
word.push('\r');
j += 1;
}
b'<' | b'>' if bytes.get(j + 1) == Some(&b'(') => return None,
b' ' | b'\t' | b'<' | b'>' | b'|' | b';' | b'&' | b')' => break,
_ if is_word_byte(byte) => {
word.push(char::from(byte));
j += 1;
}
_ => return None,
}
}
Some((word, quoted, j))
}
fn is_word_byte(byte: u8) -> bool {
byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'.' | b'-')
}
fn loose_word(bytes: &[u8], start: usize) -> (String, bool, usize) {
let mut word = Vec::new();
let mut quote = None;
let mut j = start;
while let Some(&byte) = bytes.get(j) {
match quote {
Some(open) if byte == open => quote = None,
Some(_) => word.push(byte),
None if byte == b'\'' || byte == b'"' => quote = Some(byte),
None if byte == b'\\' => {
j += 1;
word.extend(bytes.get(j));
}
None if is_word_break(byte) => break,
None => word.push(byte),
}
j += 1;
}
let word = String::from_utf8_lossy(&word).into_owned();
(word, true, j.min(bytes.len()))
}
fn is_word_break(byte: u8) -> bool {
matches!(
byte,
b' ' | b'\t' | b'<' | 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,
) -> Result<Option<Body>, Abandon> {
let Some(&(body_start, _)) = lines.get(from) else {
return Ok(None);
};
let ends = |text: &str, last| is_terminator(text, &delimiter.word, delimiter.strip_tabs, last);
let mut logical: Option<Logical> = None;
for (index, (start, end)) in lines.iter().enumerate().skip(from) {
let last = index + 1 == lines.len();
let text = &command[*start..*end];
let whole = logical.take().map(|head| head.join(text, delimiter));
if let Some(whole) = &whole
&& ends(text, last) != whole.text.as_deref().is_some_and(|w| ends(w, last))
{
return Err(Abandon::Delimiter);
}
if ends(text, last) {
return Ok(Some(Body {
span: (body_start, *start),
frame_end: *end,
next_line: index + 1,
}));
}
if !delimiter.quoted && odd_trailing_backslashes(text) {
let mut whole = whole.unwrap_or_else(|| {
Logical {
text: Some(String::new()),
}
.join(text, delimiter)
});
if let Some(kept) = &mut whole.text {
kept.pop();
}
logical = Some(whole);
}
}
Ok(None)
}
struct Logical {
text: Option<String>,
}
impl Logical {
fn join(self, text: &str, delimiter: &Delimiter) -> Self {
let Some(mut head) = self.text else {
return self;
};
let text = if delimiter.strip_tabs && head.is_empty() {
text.trim_start_matches('\t')
} else {
text
};
if head.len() + text.len() > delimiter.word.len() + 1 {
return Self { text: None };
}
head.push_str(text);
Self { text: Some(head) }
}
}
fn odd_trailing_backslashes(text: &str) -> bool {
text.bytes().rev().take_while(|b| *b == b'\\').count() % 2 == 1
}
pub(super) fn is_terminator(line: &str, word: &str, strip_tabs: bool, last: bool) -> bool {
let line = if strip_tabs {
line.trim_start_matches('\t')
} else {
line
};
line == word || (last && line == word.trim_end_matches('\r'))
}
#[cfg(test)]
#[path = "heredoc_tests.rs"]
mod tests;