use std::collections::BTreeSet;
use kaish_types::plan::{
Plan, PlannedCommand, PlannedHeredoc, PlannedRedirect, PlannedValue, PLAN_RENDER_LIMIT,
};
use kaish_types::Value;
use super::types::{
Arg, Assignment, BinaryOp, CaseStmt, Command, Expr, ForLoop, IfStmt, ListElem, Pipeline,
PipelineStage, RecordKey, Redirect, RedirectKind, Stmt, StringPart, TestExpr, ToolDef, VarPath,
VarSegment,
WhileLoop,
};
pub struct StatementPlan {
pub plan: Plan,
pub presented_keys: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct PlannedStatement {
pub index: usize,
pub plan: Plan,
}
pub fn plan_program(
source: &str,
) -> Result<Vec<PlannedStatement>, Vec<crate::parser::ParseError>> {
let program = crate::parser::parse(source)?;
Ok(program
.statements
.iter()
.filter(|stmt| !matches!(stmt, Stmt::Empty))
.enumerate()
.map(|(index, stmt)| PlannedStatement {
index,
plan: plan_statement(stmt).plan,
})
.collect())
}
pub(crate) fn plan_statement(stmt: &Stmt) -> StatementPlan {
let collected = collect(stmt);
let free: Vec<String> = collected
.reads
.difference(&collected.binds)
.cloned()
.collect();
let bound: Vec<String> = collected.binds.into_iter().collect();
StatementPlan {
plan: Plan::new(
truncate_rendering(render_stmt(stmt)),
stmt.kind_name(),
collected.commands,
)
.with_variables(free, bound),
presented_keys: collected.keys,
}
}
pub fn strip_confirm_tokens(rendered: &str) -> String {
rendered
.split_whitespace()
.filter(|word| {
!word.starts_with(&format!("--{CONFIRM_KEY}=")) && !word.starts_with(&format!("{CONFIRM_KEY}="))
})
.collect::<Vec<_>>()
.join(" ")
}
pub fn redact_keys(source: &str, keys: &[String]) -> String {
let mut out = source.to_string();
for key in keys {
for spelling in [format!("--{CONFIRM_KEY}={key}"), format!("{CONFIRM_KEY}={key}")] {
out = out.replace(&format!(" {spelling}"), "");
out = out.replace(&spelling, "");
}
}
out
}
fn truncate_rendering(rendered: String) -> String {
if rendered.len() <= PLAN_RENDER_LIMIT {
return rendered;
}
let mut cut = PLAN_RENDER_LIMIT;
while cut > 0 && !rendered.is_char_boundary(cut) {
cut -= 1;
}
let mut out = rendered[..cut].to_string();
out.push_str(&format!(
"… [rendering truncated at {PLAN_RENDER_LIMIT} bytes]"
));
out
}
#[derive(Default)]
struct Collected<'a> {
commands: Vec<PlannedCommand>,
keys: Vec<String>,
reads: BTreeSet<String>,
binds: BTreeSet<String>,
heredoc_targets: Vec<&'a Expr>,
}
impl<'a> Collected<'a> {
fn take_heredocs(&mut self, cmd: &'a Command) -> Vec<PlannedHeredoc> {
cmd.redirects
.iter()
.filter_map(|r| match &r.kind {
RedirectKind::HereDoc(meta) => Some((meta, &r.target)),
_ => None,
})
.map(|(meta, target)| {
let index = self.heredoc_targets.len();
self.heredoc_targets.push(target);
let free = if meta.literal {
Vec::new()
} else {
let mut body_reads = Collected::default();
collect_expr(target, false, &mut body_reads);
body_reads.reads.into_iter().collect()
};
PlannedHeredoc::new(
index,
meta.delimiter.clone(),
meta.literal,
meta.strip_tabs,
PlannedValue::Plain(meta.body.clone()),
meta.body_offset,
)
.with_free_variables(free)
})
.collect()
}
fn read_path(&mut self, path: &VarPath) {
for (i, segment) in path.segments.iter().enumerate() {
match segment {
VarSegment::Field(name) if i == 0 => {
self.reads.insert(name.clone());
}
VarSegment::Dynamic(v) => {
self.reads.insert(v.clone());
}
_ => {}
}
}
}
fn bind_path(&mut self, path: &VarPath) {
if let Some(VarSegment::Field(name)) = path.segments.first() {
self.binds.insert(name.clone());
}
for segment in path.segments.iter().skip(1) {
if let VarSegment::Dynamic(v) = segment {
self.reads.insert(v.clone());
}
}
}
fn read_arithmetic(&mut self, expr: &str) {
let mut name = String::new();
for c in expr.chars() {
if c == '_' || c.is_ascii_alphabetic() || (!name.is_empty() && c.is_ascii_digit()) {
name.push(c);
} else if !name.is_empty() {
self.reads.insert(std::mem::take(&mut name));
}
}
if !name.is_empty() {
self.reads.insert(name);
}
}
}
fn collect<'a>(stmt: &'a Stmt) -> Collected<'a> {
let mut out = Collected::default();
collect_stmt(stmt, false, &mut out);
out
}
pub(crate) fn heredoc_targets(stmt: &Stmt) -> Vec<&Expr> {
collect(stmt).heredoc_targets
}
pub fn planned_commands(stmt: &Stmt) -> Vec<PlannedCommand> {
collect(stmt).commands
}
fn collect_stmt<'a>(stmt: &'a Stmt, background: bool, out: &mut Collected<'a>) {
match stmt {
Stmt::Assignment(a) => {
out.bind_path(&a.path);
collect_expr(&a.value, background, out)
}
Stmt::Command(cmd) => collect_command(cmd, background, out),
Stmt::Pipeline(p) => {
for stage in &p.stages {
match stage {
PipelineStage::Command(cmd) => {
collect_command(cmd, background || p.background, out)
}
PipelineStage::Compound(stmt) => {
collect_stmt(stmt, background || p.background, out)
}
}
}
}
Stmt::If(s) => {
collect_expr(&s.condition, background, out);
collect_block(&s.then_branch, background, out);
if let Some(else_branch) = &s.else_branch {
collect_block(else_branch, background, out);
}
}
Stmt::For(s) => {
out.binds.insert(s.variable.clone());
for item in &s.items {
collect_expr(item, background, out);
}
collect_block(&s.body, background, out);
}
Stmt::While(s) => {
collect_expr(&s.condition, background, out);
collect_block(&s.body, background, out);
}
Stmt::Case(s) => {
collect_expr(&s.expr, background, out);
for branch in &s.branches {
collect_block(&branch.body, background, out);
}
}
Stmt::Return(e) | Stmt::Exit(e) => {
if let Some(e) = e {
collect_expr(e, background, out);
}
}
Stmt::ToolDef(def) => {
for param in &def.params {
out.binds.insert(param.name.clone());
if let Some(default) = ¶m.default {
collect_expr(default, background, out);
}
}
collect_block(&def.body, background, out)
}
Stmt::Test(t) => collect_test(t, background, out),
Stmt::AndChain { left, right } | Stmt::OrChain { left, right } => {
collect_stmt(left, background, out);
collect_stmt(right, background, out);
}
Stmt::EnvScoped { assignments, body } => {
for a in assignments {
out.bind_path(&a.path);
collect_expr(&a.value, background, out);
}
collect_stmt(body, background, out);
}
Stmt::Break(_) | Stmt::Continue(_) | Stmt::Empty => {}
}
}
fn collect_block<'a>(stmts: &'a [Stmt], background: bool, out: &mut Collected<'a>) {
for stmt in stmts {
collect_stmt(stmt, background, out);
}
}
fn collect_command<'a>(cmd: &'a Command, background: bool, out: &mut Collected<'a>) {
let args: Vec<PlannedValue> = cmd.args.iter().map(|arg| plan_arg(arg).1).collect();
let redirects = cmd
.redirects
.iter()
.map(|r| PlannedRedirect::new(r.kind.to_string(), plan_redirect_target(r)))
.collect();
let heredocs = out.take_heredocs(cmd);
out.commands.push(
PlannedCommand::new(cmd.name.clone(), args, redirects, background)
.with_heredocs(heredocs),
);
for arg in &cmd.args {
if let Some(key) = presented_key(arg) {
out.keys.push(key);
}
}
for arg in &cmd.args {
match arg {
Arg::Positional(e) => collect_expr(e, background, out),
Arg::Named { value, .. } | Arg::WordAssign { value, .. } => {
collect_expr(value, background, out)
}
Arg::ShortFlag(_) | Arg::LongFlag(_) | Arg::DoubleDash => {}
}
}
for redirect in &cmd.redirects {
collect_expr(&redirect.target, background, out);
}
}
fn collect_expr<'a>(expr: &'a Expr, background: bool, out: &mut Collected<'a>) {
match expr {
Expr::Command(cmd) => collect_command(cmd, background, out),
Expr::CommandSubst(stmts) => collect_block(stmts, background, out),
Expr::Not(inner) => collect_expr(inner, background, out),
Expr::BinaryOp { left, right, .. } => {
collect_expr(left, background, out);
collect_expr(right, background, out);
}
Expr::Interpolated(parts) => collect_parts(parts, background, out),
Expr::HereDocBody { parts, .. } => {
for part in parts {
collect_part(&part.part, background, out);
}
}
Expr::Test(t) => collect_test(t, background, out),
Expr::VarWithDefault { path, default } => {
out.read_path(path);
collect_parts(default, background, out)
}
Expr::ListLiteral(elems) => {
for elem in elems {
match elem {
ListElem::Item(e) | ListElem::Spread(e) => collect_expr(e, background, out),
}
}
}
Expr::RecordLiteral(entries) => {
for entry in entries {
if let RecordKey::Interpolated(parts) = &entry.key {
collect_parts(parts, background, out);
}
collect_expr(&entry.value, background, out);
}
}
Expr::VarRef(path) | Expr::VarLength(path) => out.read_path(path),
Expr::Arithmetic(e) => out.read_arithmetic(e),
Expr::Literal(_)
| Expr::Positional(_)
| Expr::AllArgs
| Expr::ArgCount
| Expr::LastExitCode
| Expr::CurrentPid
| Expr::GlobPattern(_) => {}
}
}
fn collect_parts<'a>(parts: &'a [StringPart], background: bool, out: &mut Collected<'a>) {
for part in parts {
collect_part(part, background, out);
}
}
fn collect_part<'a>(part: &'a StringPart, background: bool, out: &mut Collected<'a>) {
match part {
StringPart::CommandSubst(stmts) => collect_block(stmts, background, out),
StringPart::VarWithDefault { path, default } => {
out.read_path(path);
collect_parts(default, background, out)
}
StringPart::Var(path) | StringPart::VarLength(path) => out.read_path(path),
StringPart::Arithmetic(e) => out.read_arithmetic(e),
StringPart::Literal(_)
| StringPart::Positional(_)
| StringPart::AllArgs
| StringPart::ArgCount
| StringPart::LastExitCode
| StringPart::CurrentPid => {}
}
}
fn collect_test<'a>(test: &'a TestExpr, background: bool, out: &mut Collected<'a>) {
match test {
TestExpr::FileTest { path, .. } => collect_expr(path, background, out),
TestExpr::StringTest { value, .. } => collect_expr(value, background, out),
TestExpr::Comparison { left, right, .. }
| TestExpr::In { left, right }
| TestExpr::NotIn { left, right } => {
collect_expr(left, background, out);
collect_expr(right, background, out);
}
TestExpr::And { left, right } | TestExpr::Or { left, right } => {
collect_test(left, background, out);
collect_test(right, background, out);
}
TestExpr::Not { expr } => collect_test(expr, background, out),
}
}
pub(crate) fn render_stmt(stmt: &Stmt) -> String {
match stmt {
Stmt::Assignment(a) => render_assignment(a),
Stmt::Command(cmd) => render_command(cmd),
Stmt::Pipeline(p) => render_pipeline(p),
Stmt::If(s) => render_if(s),
Stmt::For(s) => render_for(s),
Stmt::While(s) => render_while(s),
Stmt::Case(s) => render_case(s),
Stmt::Break(n) => render_keyword("break", n.map(|n| n.to_string())),
Stmt::Continue(n) => render_keyword("continue", n.map(|n| n.to_string())),
Stmt::Return(e) => render_keyword("return", e.as_ref().map(|e| render_expr(e))),
Stmt::Exit(e) => render_keyword("exit", e.as_ref().map(|e| render_expr(e))),
Stmt::ToolDef(def) => render_tooldef(def),
Stmt::Test(t) => format!("[[ {} ]]", render_test(t)),
Stmt::AndChain { left, right } => {
format!("{} && {}", render_stmt(left), render_stmt(right))
}
Stmt::OrChain { left, right } => {
format!("{} || {}", render_stmt(left), render_stmt(right))
}
Stmt::EnvScoped { assignments, body } => {
let prefix: Vec<String> = assignments.iter().map(render_assignment).collect();
format!("{} {}", prefix.join(" "), render_stmt(body))
}
Stmt::Empty => String::new(),
}
}
fn render_keyword(word: &str, operand: Option<String>) -> String {
match operand {
Some(operand) => format!("{word} {operand}"),
None => word.to_string(),
}
}
fn render_block(stmts: &[Stmt]) -> String {
stmts
.iter()
.filter(|s| !matches!(s, Stmt::Empty))
.map(render_stmt)
.collect::<Vec<_>>()
.join("; ")
}
fn render_assignment(a: &Assignment) -> String {
let path = render_varpath(&a.path);
if a.local {
format!("local {} = {}", path, render_expr(&a.value))
} else {
format!("{}={}", path, render_expr(&a.value))
}
}
pub(crate) fn render_command(cmd: &Command) -> String {
let mut parts = vec![cmd.name.clone()];
for arg in &cmd.args {
parts.push(plan_arg(arg).0);
}
for redirect in &cmd.redirects {
parts.push(render_redirect(redirect));
}
parts.join(" ")
}
const CONFIRM_KEY: &str = "confirm";
const CONFIRM_KEY_KIND: &str = "confirm-key";
fn presented_key(arg: &Arg) -> Option<String> {
let value = match arg {
Arg::Named { key, value } | Arg::WordAssign { key, value } if key == CONFIRM_KEY => value,
_ => return None,
};
match value {
Expr::Literal(Value::String(s)) => Some(s.clone()),
_ => None,
}
}
fn plan_arg(arg: &Arg) -> (String, PlannedValue) {
if presented_key(arg).is_some() {
let value = PlannedValue::redacted(CONFIRM_KEY_KIND, None);
let text = match arg {
Arg::WordAssign { key, .. } => format!("{key}={}", value.display()),
_ => format!("--{CONFIRM_KEY}={}", value.display()),
};
return (text, value);
}
let (text, value) = match arg {
Arg::Positional(e) => {
let value = PlannedValue::Plain(render_expr(e));
(value.display(), value)
}
Arg::Named { key, value: e } => {
let value = PlannedValue::Plain(render_expr(e));
(format!("--{key}={}", value.display()), value)
}
Arg::WordAssign { key, value: e } => {
let value = PlannedValue::Plain(render_expr(e));
(format!("{key}={}", value.display()), value)
}
Arg::ShortFlag(f) => {
let text = format!("-{f}");
(text.clone(), PlannedValue::Plain(text))
}
Arg::LongFlag(f) => {
let text = format!("--{f}");
(text.clone(), PlannedValue::Plain(text))
}
Arg::DoubleDash => ("--".to_string(), PlannedValue::Plain("--".to_string())),
};
let structured = if value.is_redacted() {
value
} else {
PlannedValue::Plain(text.clone())
};
(text, structured)
}
fn plan_redirect_target(redirect: &Redirect) -> PlannedValue {
match &redirect.kind {
RedirectKind::HereDoc(meta) => {
let quote = if meta.literal { "'" } else { "" };
PlannedValue::Plain(format!("{quote}{}{quote}", meta.delimiter))
}
_ => PlannedValue::Plain(render_expr(&redirect.target)),
}
}
fn render_redirect(redirect: &Redirect) -> String {
match &redirect.kind {
RedirectKind::MergeStderr | RedirectKind::MergeStdout => redirect.kind.to_string(),
RedirectKind::HereDoc(meta) => {
let dash = if meta.strip_tabs { "-" } else { "" };
let quote = if meta.literal { "'" } else { "" };
format!(
"<<{dash}{quote}{delim}{quote}\n{body}{delim}",
delim = meta.delimiter,
body = meta.body,
)
}
_ => format!(
"{} {}",
redirect.kind,
plan_redirect_target(redirect).display()
),
}
}
fn render_pipeline(p: &Pipeline) -> String {
let body = p
.stages
.iter()
.map(|stage| match stage {
PipelineStage::Command(cmd) => render_command(cmd),
PipelineStage::Compound(stmt) => render_stmt(stmt),
})
.collect::<Vec<_>>()
.join(" | ");
if p.background {
format!("{body} &")
} else {
body
}
}
fn render_if(s: &IfStmt) -> String {
let mut out = format!(
"if {}; then {}",
render_expr(&s.condition),
render_block(&s.then_branch)
);
if let Some(else_branch) = &s.else_branch {
let rendered = render_block(else_branch);
if !rendered.is_empty() {
out.push_str(&format!("; else {rendered}"));
}
}
out.push_str("; fi");
out
}
fn render_for(s: &ForLoop) -> String {
let items: Vec<String> = s.items.iter().map(render_expr).collect();
format!(
"for {} in {}; do {}; done",
s.variable,
items.join(" "),
render_block(&s.body)
)
}
fn render_while(s: &WhileLoop) -> String {
format!(
"while {}; do {}; done",
render_expr(&s.condition),
render_block(&s.body)
)
}
fn render_case(s: &CaseStmt) -> String {
let branches: Vec<String> = s
.branches
.iter()
.map(|b| format!("{}) {} ;;", b.patterns.join("|"), render_block(&b.body)))
.collect();
format!("case {} in {} esac", render_expr(&s.expr), branches.join(" "))
}
fn render_tooldef(def: &ToolDef) -> String {
let params: Vec<String> = def
.params
.iter()
.map(|p| match &p.default {
Some(default) => format!("{}={}", p.name, render_expr(default)),
None => p.name.clone(),
})
.collect();
format!(
"tool {}({}) {{ {} }}",
def.name,
params.join(", "),
render_block(&def.body)
)
}
pub(crate) fn render_expr(expr: &Expr) -> String {
match expr {
Expr::Not(inner) => format!("! {}", render_expr(inner)),
Expr::Literal(v) => render_literal(v),
Expr::VarRef(path) => format!("${{{}}}", render_varpath(path)),
Expr::Interpolated(parts) => format!("\"{}\"", render_parts(parts)),
Expr::HereDocBody { parts, strip_tabs } => {
let dash = if *strip_tabs { "-" } else { "" };
let body: Vec<String> = parts.iter().map(|sp| render_part(&sp.part)).collect();
format!("<<{dash}EOF\n{}\nEOF", body.join(""))
}
Expr::BinaryOp { left, op, right } => {
let op = match op {
BinaryOp::And => "&&",
BinaryOp::Or => "||",
};
format!("{} {} {}", render_expr(left), op, render_expr(right))
}
Expr::CommandSubst(stmts) => format!("$({})", render_block(stmts)),
Expr::Test(t) => format!("[[ {} ]]", render_test(t)),
Expr::Positional(n) => format!("${n}"),
Expr::AllArgs => "$@".to_string(),
Expr::ArgCount => "$#".to_string(),
Expr::VarLength(path) => format!("${{#{}}}", render_varpath(path)),
Expr::VarWithDefault { path, default } => {
format!("${{{}:-{}}}", render_varpath(path), render_parts(default))
}
Expr::Arithmetic(e) => format!("$(({e}))"),
Expr::Command(cmd) => render_command(cmd),
Expr::LastExitCode => "$?".to_string(),
Expr::CurrentPid => "$$".to_string(),
Expr::GlobPattern(p) => p.clone(),
Expr::ListLiteral(elems) => {
let parts: Vec<String> = elems
.iter()
.map(|e| match e {
ListElem::Item(e) => render_expr(e),
ListElem::Spread(e) => format!("...{}", render_expr(e)),
})
.collect();
format!("[{}]", parts.join(" "))
}
Expr::RecordLiteral(entries) => {
let parts: Vec<String> = entries
.iter()
.map(|entry| {
let key = match &entry.key {
RecordKey::Bare(k) => k.clone(),
RecordKey::Quoted(k) => format!("\"{k}\""),
RecordKey::Interpolated(parts) => format!("\"{}\"", render_parts(parts)),
};
format!("{key}: {}", render_expr(&entry.value))
})
.collect();
format!("{{{}}}", parts.join(", "))
}
}
}
fn render_literal(value: &Value) -> String {
match value {
Value::String(s) => quote_word(s),
Value::Int(i) => i.to_string(),
Value::Float(f) => f.to_string(),
Value::Bool(b) => b.to_string(),
Value::Null => "null".to_string(),
Value::Json(j) => j.to_string(),
Value::Bytes(b) => format!("<bytes len={}>", b.len()),
}
}
fn quote_word(s: &str) -> String {
let needs_quotes = s.is_empty()
|| s.chars()
.any(|c| c.is_whitespace() || "\"'$`&|;<>(){}[]*?#!~\\".contains(c));
if !needs_quotes {
return s.to_string();
}
format!("'{}'", s.replace('\'', "'\\''"))
}
fn render_parts(parts: &[StringPart]) -> String {
parts.iter().map(render_part).collect::<Vec<_>>().join("")
}
fn render_part(part: &StringPart) -> String {
match part {
StringPart::Literal(s) => s.replace('\\', "\\\\").replace('"', "\\\""),
StringPart::Var(path) => format!("${{{}}}", render_varpath(path)),
StringPart::VarWithDefault { path, default } => {
format!("${{{}:-{}}}", render_varpath(path), render_parts(default))
}
StringPart::VarLength(path) => format!("${{#{}}}", render_varpath(path)),
StringPart::Positional(n) => format!("${n}"),
StringPart::AllArgs => "$@".to_string(),
StringPart::ArgCount => "$#".to_string(),
StringPart::Arithmetic(e) => format!("$(({e}))"),
StringPart::CommandSubst(stmts) => format!("$({})", render_block(stmts)),
StringPart::LastExitCode => "$?".to_string(),
StringPart::CurrentPid => "$$".to_string(),
}
}
fn render_test(test: &TestExpr) -> String {
match test {
TestExpr::FileTest { op, path } => format!("{} {}", op, render_expr(path)),
TestExpr::StringTest { op, value } => format!("{} {}", op, render_expr(value)),
TestExpr::Comparison { left, op, right } => {
format!("{} {} {}", render_expr(left), op, render_expr(right))
}
TestExpr::And { left, right } => {
format!("{} && {}", render_test(left), render_test(right))
}
TestExpr::Or { left, right } => {
format!("{} || {}", render_test(left), render_test(right))
}
TestExpr::Not { expr } => format!("! {}", render_test(expr)),
TestExpr::In { left, right } => {
format!("{} in {}", render_expr(left), render_expr(right))
}
TestExpr::NotIn { left, right } => {
format!("{} not in {}", render_expr(left), render_expr(right))
}
}
}
fn render_varpath(path: &VarPath) -> String {
let mut out = String::new();
for (i, segment) in path.segments.iter().enumerate() {
match segment {
VarSegment::Field(name) => {
if i > 0 {
out.push('.');
}
out.push_str(name);
}
VarSegment::Index(idx) => out.push_str(&format!("[{idx}]")),
VarSegment::Key(k) => out.push_str(&format!("[{k}]")),
VarSegment::Dynamic(v) => out.push_str(&format!("[${v}]")),
VarSegment::Slice(a, b) => out.push_str(&format!(
"[{}:{}]",
a.map(|n| n.to_string()).unwrap_or_default(),
b.map(|n| n.to_string()).unwrap_or_default()
)),
}
}
out
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
use crate::parser::parse;
fn planned_of(source: &str) -> StatementPlan {
let program = parse(source).expect("the fixture parses");
let stmt = program
.statements
.into_iter()
.find(|s| !matches!(s, Stmt::Empty))
.expect("one statement");
plan_statement(&stmt)
}
fn plan_of(source: &str) -> Plan {
planned_of(source).plan
}
#[test]
fn a_variable_renders_unexpanded() {
let plan = plan_of("rm -r \"${HOME}/build\"");
assert!(
plan.rendered.contains("${HOME}"),
"the plan must keep the variable as written: {}",
plan.rendered
);
}
#[test]
fn a_substitution_renders_unexpanded_and_plans_its_own_command() {
let plan = plan_of("rm $(cat list.txt)");
assert!(
plan.rendered.contains("$(cat list.txt)"),
"got: {}",
plan.rendered
);
let names: Vec<&str> = plan.commands.iter().map(|c| c.name.as_str()).collect();
assert_eq!(names, vec!["rm", "cat"], "the substitution runs too");
}
#[test]
fn a_loop_body_belongs_to_the_enclosing_statement() {
let plan = plan_of("for f in a b; do rm $f; done");
assert_eq!(plan.statement_kind, "for");
let names: Vec<&str> = plan.commands.iter().map(|c| c.name.as_str()).collect();
assert_eq!(names, vec!["rm"]);
assert!(plan.rendered.starts_with("for f in a b; do rm"));
}
#[test]
fn every_redirect_form_renders() {
let plan = plan_of("cmd > out.txt 2> err.txt < in.txt");
let kinds: Vec<&str> = plan.commands[0]
.redirects
.iter()
.map(|r| r.kind.as_str())
.collect();
assert_eq!(kinds, vec![">", "2>", "<"]);
assert_eq!(
plan.commands[0].redirects[0].target,
PlannedValue::Plain("out.txt".to_string())
);
assert!(plan.rendered.contains("> out.txt"), "got: {}", plan.rendered);
}
#[test]
fn a_redirect_target_stays_unexpanded() {
let plan = plan_of("echo hi > ${LOG}");
assert_eq!(
plan.commands[0].redirects[0].target,
PlannedValue::Plain("${LOG}".to_string())
);
}
#[test]
fn a_merge_redirect_renders_as_its_operator_alone() {
let plan = plan_of("cmd 2>&1");
assert!(
plan.rendered.ends_with("2>&1"),
"a merge redirect has no filename: {}",
plan.rendered
);
}
#[test]
fn every_argument_form_renders() {
let plan = plan_of("tool -v --force --key=value word -- --after");
let args = &plan.commands[0].args;
assert_eq!(
args,
&vec![
PlannedValue::Plain("-v".to_string()),
PlannedValue::Plain("--force".to_string()),
PlannedValue::Plain("--key=value".to_string()),
PlannedValue::Plain("word".to_string()),
PlannedValue::Plain("--".to_string()),
PlannedValue::Plain("--after".to_string()),
]
);
}
#[test]
fn a_backgrounded_pipeline_marks_every_command() {
let plan = plan_of("a | b &");
assert!(plan.commands.iter().all(|c| c.background));
assert!(plan.rendered.ends_with('&'), "got: {}", plan.rendered);
}
#[test]
fn a_pipeline_renders_every_stage() {
let plan = plan_of("cat f | grep x | wc -l");
let names: Vec<&str> = plan.commands.iter().map(|c| c.name.as_str()).collect();
assert_eq!(names, vec!["cat", "grep", "wc"]);
assert_eq!(plan.rendered, "cat f | grep x | wc -l");
}
#[test]
fn an_and_chain_plans_both_sides() {
let plan = plan_of("mkdir d && rm -r d");
assert_eq!(plan.statement_kind, "and_chain");
let names: Vec<&str> = plan.commands.iter().map(|c| c.name.as_str()).collect();
assert_eq!(names, vec!["mkdir", "rm"]);
}
#[test]
fn an_if_plans_its_condition_and_both_branches() {
let plan = plan_of("if grep -q x f; then echo hit; else echo miss; fi");
let names: Vec<&str> = plan.commands.iter().map(|c| c.name.as_str()).collect();
assert_eq!(names, vec!["grep", "echo", "echo"]);
}
#[test]
fn a_quoted_word_keeps_its_spaces_inside_quotes() {
let plan = plan_of("echo 'two words'");
assert_eq!(plan.rendered, "echo 'two words'");
}
#[test]
fn an_interpolated_string_keeps_its_variables() {
let plan = plan_of("echo \"hello ${NAME}\"");
assert_eq!(plan.rendered, "echo \"hello ${NAME}\"");
}
#[test]
fn a_bracket_path_renders_with_brackets_not_dots() {
let plan = plan_of("echo ${servers[web]}");
assert_eq!(plan.rendered, "echo ${servers[web]}");
}
#[test]
fn rendering_truncates_at_the_limit_with_a_loud_marker() {
let long = "x".repeat(PLAN_RENDER_LIMIT * 2);
let plan = plan_of(&format!("echo {long}"));
assert!(
plan.rendered.contains("[rendering truncated at 8192 bytes]"),
"expected the marker, got {} bytes ending in {:?}",
plan.rendered.len(),
&plan.rendered[plan.rendered.len().saturating_sub(48)..]
);
assert_eq!(plan.commands.len(), 1);
assert_eq!(plan.commands[0].name, "echo");
}
#[test]
fn a_short_rendering_carries_no_marker() {
let plan = plan_of("echo hi");
assert_eq!(plan.rendered, "echo hi");
}
#[test]
fn a_literal_key_is_lifted_and_redacted() {
let planned = planned_of("rm --confirm=deadbeef target.txt");
assert_eq!(planned.presented_keys, vec!["deadbeef".to_string()]);
assert_eq!(planned.plan.rendered, "rm --confirm=<confirm-key> target.txt");
assert_eq!(
planned.plan.commands[0].args,
vec![
PlannedValue::redacted("confirm-key", None),
PlannedValue::Plain("target.txt".to_string()),
]
);
}
#[test]
fn the_bare_word_assign_spelling_is_lifted_too() {
let planned = planned_of("dd if=a of=b confirm=deadbeef");
assert_eq!(planned.presented_keys, vec!["deadbeef".to_string()]);
assert!(
planned.plan.rendered.ends_with("confirm=<confirm-key>"),
"got: {}",
planned.plan.rendered
);
}
#[test]
fn a_variable_carried_key_is_neither_lifted_nor_redacted() {
let planned = planned_of("rm --confirm=${key} target.txt");
assert!(planned.presented_keys.is_empty());
assert_eq!(planned.plan.rendered, "rm --confirm=${key} target.txt");
}
#[test]
fn a_key_inside_a_loop_body_is_still_lifted() {
let planned = planned_of("for f in a b; do rm --confirm=deadbeef $f; done");
assert_eq!(planned.presented_keys, vec!["deadbeef".to_string()]);
}
#[test]
fn redaction_takes_the_whole_token_and_leaves_one_space() {
let source = "rm --confirm=deadbeef target.txt";
assert_eq!(
redact_keys(source, &["deadbeef".to_string()]),
"rm target.txt"
);
}
#[test]
fn redaction_leaves_a_source_that_never_presented_a_key_alone() {
let source = "rm target.txt";
assert_eq!(redact_keys(source, &[]), source);
assert_eq!(redact_keys(source, &["deadbeef".to_string()]), source);
}
#[test]
fn reads_cover_interpolation_length_subscript_and_arithmetic() {
let plan = plan_of(
"echo \"${greeting} ${#items} ${servers[$env]}\" $((base + offset))",
);
assert_eq!(
plan.free_variables,
vec!["base", "env", "greeting", "items", "offset", "servers"],
"every lexical read is listed, sorted"
);
assert!(plan.bound_variables.is_empty());
}
#[test]
fn a_subscripted_assignment_binds_the_root_and_reads_the_subscript() {
let plan = plan_of("counts[$key]=1");
assert_eq!(plan.free_variables, vec!["key"]);
assert_eq!(plan.bound_variables, vec!["counts"]);
}
#[test]
fn an_env_prefix_binds_its_name_for_the_one_command() {
let plan = plan_of("MODE=fast deploy ${TARGET}");
assert_eq!(plan.free_variables, vec!["TARGET"]);
assert_eq!(plan.bound_variables, vec!["MODE"]);
}
#[test]
fn plan_program_indexes_are_dense_and_ordered() {
let source = "echo one\n\n# a comment\necho two && echo three\nX=5";
let program = parse(source).expect("the fixture parses");
let expected: Vec<String> = program
.statements
.iter()
.filter(|s| !matches!(s, Stmt::Empty))
.map(|s| s.kind_name().to_string())
.collect();
let plans = plan_program(source).expect("the fixture parses");
assert_eq!(
plans.iter().map(|p| p.plan.statement_kind.clone()).collect::<Vec<_>>(),
expected,
"every non-empty statement is planned, in source order"
);
for (position, planned) in plans.iter().enumerate() {
assert_eq!(
planned.index, position,
"index must be the position in the returned list"
);
}
}
#[test]
fn a_leading_comment_does_not_shift_the_indexes() {
let plans = plan_program("# lead\necho a\necho b").expect("parses");
assert_eq!(plans.len(), 2);
assert_eq!(plans[0].index, 0, "a leading comment must not shift index");
assert_eq!(plans[1].index, 1);
}
#[test]
fn plan_program_redacts_a_presented_key_and_returns_no_copy_of_it() {
let plans = plan_program("rm --confirm=deadbeef x.txt").expect("parses");
assert_eq!(plans[0].plan.rendered, "rm --confirm=<confirm-key> x.txt");
}
#[test]
fn plan_program_returns_the_parse_errors_for_a_broken_source() {
let errors = plan_program("echo 'unclosed").expect_err("must not parse");
assert!(!errors.is_empty());
}
#[test]
fn redaction_covers_both_spellings_across_a_multi_statement_source() {
let source = "echo one\ndd if=a confirm=deadbeef\nrm --confirm=deadbeef x";
let redacted = redact_keys(source, &["deadbeef".to_string()]);
assert!(!redacted.contains("deadbeef"), "got: {redacted}");
assert_eq!(redacted, "echo one\ndd if=a\nrm x");
}
}