use super::*;
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum AnchorCmd {
ReadFull { operand: String },
ReadWindow {
operand: String,
start: usize,
end: Option<usize>,
},
WriteFull {
operand: String,
content: String,
heredoc: bool,
},
Append {
operand: String,
content: String,
heredoc: bool,
},
}
#[derive(Debug, Default, PartialEq, Eq)]
pub(crate) struct BashAnchors {
pub(crate) read: Option<AnchorCmd>,
pub(crate) writes: Vec<AnchorCmd>,
pub(crate) multi_segment: bool,
}
pub(crate) fn bash_anchors(command: &str) -> BashAnchors {
let mut out = BashAnchors::default();
let (stripped, bodies) = strip_heredoc_bodies_keeping(command);
let mask = shell_mask(&stripped);
let segments = split_segments(&stripped, &mask);
let non_empty = segments.iter().filter(|s| !s.trim().is_empty()).count();
out.multi_segment = non_empty > 1;
let mut body_at = 0usize;
for seg_raw in &segments {
let seg = seg_raw.trim();
let openers = heredoc_delims(seg_raw).len();
let seg_bodies = &bodies[body_at..(body_at + openers).min(bodies.len())];
body_at += openers;
if seg.is_empty() {
continue;
}
let off = seg.as_ptr() as usize - stripped.as_ptr() as usize;
let seg_mask = &mask[off..off + seg.len()];
let Some(parts) = segment_parts(seg, seg_mask) else {
continue; };
if seg_bodies.len() > 1 {
continue; }
if let Some(body) = seg_bodies.first() {
if let Some(a) = classify_heredoc(seg_raw, &parts.words, parts.redirect, body) {
out.writes.push(a);
}
continue;
}
match classify_plain(&parts.words, parts.redirect) {
Some(a @ (AnchorCmd::WriteFull { .. } | AnchorCmd::Append { .. })) => {
out.writes.push(a);
}
Some(a) if non_empty == 1 => out.read = Some(a),
_ => {}
}
}
out
}
struct SegmentParts<'a> {
words: Vec<MaskedTok<'a>>,
redirect: Option<(bool, String)>,
}
fn segment_parts<'a>(seg: &'a str, seg_mask: &'a str) -> Option<SegmentParts<'a>> {
if seg_mask.contains('`')
|| seg_mask.contains("$(")
|| seg_mask.contains('(')
|| seg_mask.contains('&')
{
return None;
}
let toks = masked_tokens(seg, seg_mask);
let mut words: Vec<MaskedTok> = Vec::new();
let mut redirect: Option<(bool, String)> = None;
let mut skip_next_as_delim = false;
let mut i = 0usize;
while i < toks.len() {
let t = toks[i];
i += 1;
if skip_next_as_delim {
skip_next_as_delim = false;
continue;
}
let m = t.masked;
if m.starts_with("<<<") {
return None; }
if m.starts_with("<<") {
let rest = m.trim_start_matches("<<").trim_start_matches('-');
if rest.is_empty() {
skip_next_as_delim = true;
}
continue;
}
if m == ">" || m == ">>" {
let target = toks.get(i)?;
i += 1;
if redirect.is_some() {
return None; }
redirect = Some((m == ">>", literal_token(*target)?));
continue;
}
if let Some(rest) = m.strip_prefix(">>").or_else(|| m.strip_prefix('>')) {
if !rest.is_empty() {
if redirect.is_some() {
return None;
}
let orig_rest = &t.orig[t.orig.len() - rest.len()..];
redirect = Some((
m.starts_with(">>"),
literal_token(MaskedTok {
orig: orig_rest,
masked: rest,
})?,
));
continue;
}
}
if m.contains('<') || m.contains('>') {
return None; }
words.push(t);
}
Some(SegmentParts { words, redirect })
}
fn classify_heredoc(
seg_raw: &str,
words: &[MaskedTok],
redirect: Option<(bool, String)>,
body: &str,
) -> Option<AnchorCmd> {
if !heredoc_delimiter_quoted(seg_raw) && body.contains(['$', '`', '\\']) {
return None; }
let content = if body.is_empty() {
String::new()
} else {
format!("{body}\n")
};
let names: Vec<&str> = words.iter().map(|w| w.orig).collect();
match (names.as_slice(), redirect) {
(["cat"], Some((append, target))) => Some(if append {
AnchorCmd::Append {
operand: target,
content,
heredoc: true,
}
} else {
AnchorCmd::WriteFull {
operand: target,
content,
heredoc: true,
}
}),
(["tee", t], None) if !t.starts_with('-') => Some(AnchorCmd::WriteFull {
operand: literal_token(*words.last()?)?,
content,
heredoc: true,
}),
(["tee", "-a", t], None) if !t.starts_with('-') => Some(AnchorCmd::Append {
operand: literal_token(*words.last()?)?,
content,
heredoc: true,
}),
_ => None, }
}
fn classify_plain(words: &[MaskedTok], redirect: Option<(bool, String)>) -> Option<AnchorCmd> {
let first = words.first()?.orig;
match first {
"cat" | "head" | "sed" => {
if redirect.is_some() {
return None; }
classify_read(first, words)
}
"echo" | "printf" => {
let (append, target) = redirect?;
let content = literal_output(first, &words[1..])?;
Some(if append {
AnchorCmd::Append {
operand: target,
content,
heredoc: false,
}
} else {
AnchorCmd::WriteFull {
operand: target,
content,
heredoc: false,
}
})
}
"truncate" => {
let names: Vec<&str> = words.iter().map(|w| w.orig).collect();
if let ["truncate", "-s", "0", _] = names.as_slice() {
Some(AnchorCmd::WriteFull {
operand: literal_token(*words.last()?)?,
content: String::new(),
heredoc: false,
})
} else {
None
}
}
_ => None,
}
}
fn classify_read(cmd: &str, words: &[MaskedTok]) -> Option<AnchorCmd> {
match cmd {
"cat" if words.len() == 2 => Some(AnchorCmd::ReadFull {
operand: literal_token(words[1])?,
}),
"head" => {
let (n, op) = match words.len() {
4 if words[1].orig == "-n" => (words[2].orig.parse::<usize>().ok()?, words[3]),
3 => (
words[1].orig.strip_prefix("-n")?.parse::<usize>().ok()?,
words[2],
),
_ => return None,
};
let operand = literal_token(op)?;
(n >= 1).then_some(AnchorCmd::ReadWindow {
operand,
start: 1,
end: Some(n),
})
}
"sed" if words.len() == 4 && words[1].orig == "-n" => {
let script = strip_quotes(words[2].orig);
let (start, end) = parse_sed_window(script)?;
Some(AnchorCmd::ReadWindow {
operand: literal_token(words[3])?,
start,
end,
})
}
_ => None,
}
}
fn parse_sed_window(script: &str) -> Option<(usize, Option<usize>)> {
let body = script.strip_suffix('p')?;
match body.split_once(',') {
None => {
let a: usize = body.parse().ok()?;
(a >= 1).then_some((a, Some(a)))
}
Some((a, b)) => {
let a: usize = a.parse().ok()?;
if a < 1 {
return None;
}
if b == "$" {
return Some((a, None));
}
let b: usize = b.parse().ok()?;
(b >= a).then_some((a, Some(b)))
}
}
}
fn literal_output(cmd: &str, args: &[MaskedTok]) -> Option<String> {
let mut rest = args;
let mut newline = true;
if cmd == "echo" {
while let Some(first) = rest.first() {
match first.orig {
"-n" => {
newline = false;
rest = &rest[1..];
}
"-E" => rest = &rest[1..],
"-e" | "-ne" | "-en" => return None,
_ => break,
}
}
let mut parts: Vec<String> = Vec::with_capacity(rest.len());
for t in rest {
parts.push(literal_token(*t)?);
}
let mut out = parts.join(" ");
if newline {
out.push('\n');
}
return Some(out);
}
if rest.len() != 1 {
return None;
}
let fmt = literal_token(rest[0])?;
(!fmt.contains('%') && !fmt.contains('\\')).then_some(fmt)
}
fn literal_token(t: MaskedTok) -> Option<String> {
let orig = t.orig;
let b = orig.as_bytes();
if b.len() >= 2 && (b[0] == b'\'' || b[0] == b'"') && b[b.len() - 1] == b[0] {
let inner = &orig[1..orig.len() - 1];
if inner.as_bytes().contains(&b[0]) {
return None; }
if b[0] == b'"' && inner.contains(['$', '`', '\\']) {
return None;
}
return Some(inner.to_string());
}
if orig.contains(['$', '`', '\\', '*', '?', '[', ']', '{', '}', '~', '\'', '"']) {
return None;
}
Some(orig.to_string())
}
fn heredoc_delimiter_quoted(seg_raw: &str) -> bool {
let first_line = seg_raw.split('\n').next().unwrap_or(seg_raw);
let Some(pos) = first_line.find("<<") else {
return false;
};
let rest = first_line[pos + 2..]
.trim_start_matches('-')
.trim_start_matches(' ');
matches!(rest.as_bytes().first(), Some(b'\'') | Some(b'"'))
}