use crate::shell::{Connector, Parsed, Simple};
#[derive(Debug, Clone)]
pub struct Dump {
pub path: String,
pub extent: Extent,
pub window: String,
pub at: usize,
pub end: usize,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Extent {
Whole,
Lines(u64),
Bytes(u64),
}
const DUMPERS: &[&str] = &["cat", "head", "tail", "sed", "bat", "more", "less", "nl"];
fn path_like(text: &str) -> bool {
text.len() > 1
&& !text.starts_with('-')
&& !text
.chars()
.any(|c| matches!(c, '$' | '`' | '*' | '?' | '[' | '{' | '\n'))
}
fn sed_extent(script: &str) -> Option<Extent> {
let body = script.strip_suffix('p')?;
if body == "$" || body.contains('$') {
return Some(Extent::Whole);
}
let (a, b) = match body.split_once(',') {
Some((a, b)) => (a, b),
None => (body, body),
};
let a: u64 = a.trim().parse().ok()?;
let b: u64 = b.trim().parse().ok()?;
Some(Extent::Lines(b.saturating_sub(a) + 1))
}
fn sed_window(script: &str) -> String {
let inexact = || crate::session_state::INEXACT.to_string();
let Some(body) = script.strip_suffix('p') else {
return inexact();
};
let (a, b) = match body.split_once(',') {
Some((a, b)) => (a, b),
None => (body, body),
};
match (a.trim().parse::<u64>(), b.trim().parse::<u64>()) {
(Ok(a), Ok(b)) if a >= 1 && b >= a => format!("{}:{}", a - 1, b - a + 1),
_ => inexact(),
}
}
fn harmless_flag(program: &str, flag: &str) -> bool {
match program {
"sed" => matches!(flag, "-s" | "-E" | "-r"),
"cat" | "nl" | "more" | "less" => !flag.starts_with("--") && flag.len() > 1,
_ => false,
}
}
fn detect(cmd: &Simple) -> Vec<Dump> {
let Some(program) = cmd.program() else {
return Vec::new();
};
if !DUMPERS.contains(&program) {
return Vec::new();
}
if cmd.next.is_some_and(|c| c.is_pipe())
|| cmd.heredoc
|| cmd
.redirects
.iter()
.any(|(op, _)| op.starts_with('>') || op == "1>" || op.contains('<'))
{
return Vec::new();
}
let args = cmd.args();
let mut extent = Extent::Whole;
let mut window = "full".to_string();
let mut inexact = false;
let mut quiet = program != "sed";
let mut files: Vec<&str> = Vec::new();
let mut script_seen = program != "sed";
let mut skip = false;
for (i, w) in args.iter().enumerate() {
if skip {
skip = false;
continue;
}
let t = w.text.as_str();
if !w.quoted && t.starts_with('-') {
let digits = t.trim_start_matches('-');
if program != "sed" && !digits.is_empty() && digits.chars().all(|c| c.is_ascii_digit())
{
extent = Extent::Lines(digits.parse().unwrap_or(0));
window = match (program, digits.parse::<u64>()) {
("head", Ok(n)) => format!("0:{n}"),
("tail", Ok(n)) => format!("tail:{n}"),
_ => crate::session_state::INEXACT.to_string(),
};
} else if matches!(t, "-n" | "-c") && matches!(program, "head" | "tail") {
if let Some(v) = args.get(i + 1) {
window = match (program, t, v.text.parse::<u64>()) {
("head", "-n", Ok(n)) => format!("0:{n}"),
("tail", "-n", Ok(n)) if !v.text.starts_with('+') => format!("tail:{n}"),
_ => crate::session_state::INEXACT.to_string(),
};
extent = match v.text.trim_start_matches('+').parse::<u64>() {
Ok(n) if t == "-n" => Extent::Lines(n),
Ok(n) => Extent::Bytes(n),
Err(_) => Extent::Whole,
};
}
skip = true;
} else if matches!(t, "-e" | "--expression") && program == "sed" {
let Some(v) = args.get(i + 1) else {
return Vec::new();
};
let Some(e) = sed_extent(&v.text) else {
return Vec::new();
};
extent = e;
window = sed_window(&v.text);
script_seen = true;
skip = true;
} else if program == "sed" && matches!(t, "-n" | "--quiet" | "--silent") {
quiet = true;
} else if !harmless_flag(program, t) {
inexact = true;
}
continue;
}
if !script_seen {
let Some(e) = sed_extent(t) else {
return Vec::new();
};
extent = e;
window = sed_window(t);
script_seen = true;
continue;
}
if w.quoted || w.expanded || !path_like(t) {
continue;
}
files.push(t);
}
if inexact || !quiet {
window = crate::session_state::INEXACT.to_string();
}
files
.into_iter()
.map(|p| Dump {
path: p.to_string(),
extent,
window: window.clone(),
at: cmd.at,
end: cmd.end,
})
.collect()
}
pub fn runs_unconditionally(src: &str, at: usize) -> bool {
use crate::analysis::ir::Cmd;
fn may_exit(text: &str) -> bool {
text.split(|c: char| !(c.is_alphanumeric() || c == '_'))
.any(|w| matches!(w, "exit" | "return" | "set" | "exec" | "trap" | "kill"))
}
fn find(cmd: &Cmd, src: &str, at: usize, ok: bool) -> Option<bool> {
let span = cmd.span();
if !(span.start <= at && at < span.end) {
return None;
}
match cmd {
Cmd::Simple(_) => Some(ok),
Cmd::Seq { items, .. } => {
let mut exits = false;
for item in items {
let sp = item.cmd.span();
if sp.start <= at && at < sp.end {
return find(&item.cmd, src, at, ok && !exits && !item.background);
}
exits |= src.get(sp).is_none_or(may_exit);
}
Some(false)
}
Cmd::AndOr { first, rest, .. } => {
if let Some(r) = find(first, src, at, ok) {
return Some(r);
}
rest.iter()
.find_map(|(_, c)| find(c, src, at, false))
.or(Some(false))
}
Cmd::Pipeline { cmds, .. } => cmds
.iter()
.find_map(|c| find(c, src, at, ok))
.or(Some(false)),
Cmd::Group { body, .. } => find(body, src, at, ok).or(Some(false)),
_ => Some(false),
}
}
match crate::analysis::frontend::parse(src) {
Ok(cmd) => find(&cmd, src, at, true).unwrap_or(false),
Err(_) => false,
}
}
pub fn dumps(parsed: &Parsed) -> Vec<Dump> {
let mut out = Vec::new();
for cmd in parsed.judgeable() {
if cmd.prev == Some(Connector::Pipe) {
continue;
}
if cmd.nested.is_some() {
continue;
}
out.extend(detect(cmd));
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::shell::lex;
fn of(command: &str) -> Vec<(String, Extent)> {
dumps(&lex(command))
.into_iter()
.map(|d| (d.path, d.extent))
.collect()
}
#[test]
fn a_reader_into_the_result_is_a_dump() {
assert_eq!(
of("cat src/main.rs"),
vec![("src/main.rs".into(), Extent::Whole)]
);
assert_eq!(
of("cat -n src/main.rs"),
vec![("src/main.rs".into(), Extent::Whole)]
);
assert_eq!(
of("cd /x && cat CLAUDE.md"),
vec![("CLAUDE.md".into(), Extent::Whole)]
);
assert_eq!(
of("sed -n '1,400p' app/lib/grid.ts"),
vec![("app/lib/grid.ts".into(), Extent::Lines(400))]
);
assert_eq!(
of("sed -n 115,200p app/x.tsx"),
vec![("app/x.tsx".into(), Extent::Lines(86))]
);
assert_eq!(
of("head -200 big.log"),
vec![("big.log".into(), Extent::Lines(200))]
);
assert_eq!(
of("head -n 30 f.rs"),
vec![("f.rs".into(), Extent::Lines(30))]
);
assert_eq!(
of("tail -n 300 f.log"),
vec![("f.log".into(), Extent::Lines(300))]
);
assert_eq!(of("cat a.rs b.rs").len(), 2);
}
#[test]
fn a_reader_that_feeds_something_else_is_not() {
assert!(of("cat f | grep x").is_empty());
assert!(of("cat a > b").is_empty());
assert!(of("cat > notes.md <<'EOF'\nhi\nEOF\n").is_empty());
assert!(of("echo x | cat").is_empty());
assert!(of("sed -i 's/a/b/' f.rs").is_empty());
assert!(of("sed 's/a/b/' f.rs").is_empty());
assert!(of("cat $F").is_empty());
assert!(of("cat *.rs").is_empty());
assert!(of("git log | head -5").is_empty());
assert!(of("ls").is_empty());
assert!(of("X=$(cat notes.md)").is_empty());
assert!(of("echo \"$(cat notes.md)\"").is_empty());
assert!(of("diff <(cat a.txt) <(cat b.txt)").is_empty());
}
}
#[cfg(test)]
mod window_tests {
use super::*;
use crate::shell::lex;
fn win(command: &str) -> String {
dumps(&lex(command)).remove(0).window
}
#[test]
fn a_dump_states_its_window_exactly_or_not_at_all() {
assert_eq!(win("cat f.txt"), "full");
assert_eq!(win("head -20 f.txt"), "0:20");
assert_eq!(win("head -n 20 f.txt"), "0:20");
assert_eq!(win("tail -20 f.txt"), "tail:20");
assert_eq!(win("tail -n 20 f.txt"), "tail:20");
assert_eq!(win("sed -n '101,120p' f.txt"), "100:20");
assert_eq!(win("sed -n 5p f.txt"), "4:1");
assert_eq!(win("tail -n +5 f.txt"), "?");
assert_eq!(win("head -c 100 f.txt"), "?");
assert_eq!(win("head -n5 f.txt"), "?");
assert_eq!(win("sed -n '5,$p' f.txt"), "?");
}
#[test]
fn only_an_unconditional_clause_is_certain() {
let at = |c: &str| c.find("cat").unwrap();
for (c, want) in [
("cat f", true),
("cd /x; cat f", true),
("true || cat f", false),
("true && cat f", false),
("if true; then cat f; fi", false),
("for i in 1; do cat f; done", false),
("(cat f)", false),
("exit 1; cat f", false),
("cat f &", false),
] {
assert_eq!(runs_unconditionally(c, at(c)), want, "{c}");
}
}
}