1use std::collections::BTreeSet;
31
32use kaish_types::plan::{Plan, PlannedCommand, PlannedRedirect, PlannedValue, PLAN_RENDER_LIMIT};
33use kaish_types::Value;
34
35use super::types::{
36 Arg, Assignment, BinaryOp, CaseStmt, Command, Expr, ForLoop, IfStmt, ListElem, Pipeline,
37 RecordKey, Redirect, Stmt, StringPart, TestExpr, ToolDef, VarPath, VarSegment, WhileLoop,
38};
39
40pub struct StatementPlan {
42 pub plan: Plan,
44 pub presented_keys: Vec<String>,
51}
52
53#[derive(Debug, Clone, PartialEq, Eq)]
59pub struct PlannedStatement {
60 pub index: usize,
62 pub plan: Plan,
64}
65
66pub fn plan_program(
91 source: &str,
92) -> Result<Vec<PlannedStatement>, Vec<crate::parser::ParseError>> {
93 let program = crate::parser::parse(source)?;
94 Ok(program
95 .statements
96 .iter()
97 .enumerate()
98 .filter(|(_, stmt)| !matches!(stmt, Stmt::Empty))
101 .map(|(index, stmt)| PlannedStatement {
102 index,
103 plan: plan_statement(stmt).plan,
104 })
105 .collect())
106}
107
108pub fn plan_statement(stmt: &Stmt) -> StatementPlan {
112 let collected = collect(stmt);
113 let free: Vec<String> = collected
118 .reads
119 .difference(&collected.binds)
120 .cloned()
121 .collect();
122 let bound: Vec<String> = collected.binds.into_iter().collect();
123 StatementPlan {
124 plan: Plan::new(
125 truncate_rendering(render_stmt(stmt)),
126 stmt.kind_name(),
127 collected.commands,
128 )
129 .with_variables(free, bound),
130 presented_keys: collected.keys,
131 }
132}
133
134pub fn strip_confirm_tokens(rendered: &str) -> String {
147 rendered
148 .split_whitespace()
149 .filter(|word| {
150 !word.starts_with(&format!("--{CONFIRM_KEY}=")) && !word.starts_with(&format!("{CONFIRM_KEY}="))
151 })
152 .collect::<Vec<_>>()
153 .join(" ")
154}
155
156pub fn redact_keys(source: &str, keys: &[String]) -> String {
161 let mut out = source.to_string();
162 for key in keys {
163 for spelling in [format!("--{CONFIRM_KEY}={key}"), format!("{CONFIRM_KEY}={key}")] {
164 out = out.replace(&format!(" {spelling}"), "");
168 out = out.replace(&spelling, "");
169 }
170 }
171 out
172}
173
174fn truncate_rendering(rendered: String) -> String {
181 if rendered.len() <= PLAN_RENDER_LIMIT {
182 return rendered;
183 }
184 let mut cut = PLAN_RENDER_LIMIT;
186 while cut > 0 && !rendered.is_char_boundary(cut) {
187 cut -= 1;
188 }
189 let mut out = rendered[..cut].to_string();
190 out.push_str(&format!(
191 "… [rendering truncated at {PLAN_RENDER_LIMIT} bytes]"
192 ));
193 out
194}
195
196#[derive(Default)]
201struct Collected {
202 commands: Vec<PlannedCommand>,
203 keys: Vec<String>,
204 reads: BTreeSet<String>,
209 binds: BTreeSet<String>,
212}
213
214impl Collected {
215 fn read_path(&mut self, path: &VarPath) {
218 for (i, segment) in path.segments.iter().enumerate() {
219 match segment {
220 VarSegment::Field(name) if i == 0 => {
221 self.reads.insert(name.clone());
222 }
223 VarSegment::Dynamic(v) => {
224 self.reads.insert(v.clone());
225 }
226 _ => {}
227 }
228 }
229 }
230
231 fn bind_path(&mut self, path: &VarPath) {
234 if let Some(VarSegment::Field(name)) = path.segments.first() {
235 self.binds.insert(name.clone());
236 }
237 for segment in path.segments.iter().skip(1) {
238 if let VarSegment::Dynamic(v) = segment {
239 self.reads.insert(v.clone());
240 }
241 }
242 }
243
244 fn read_arithmetic(&mut self, expr: &str) {
248 let mut name = String::new();
249 for c in expr.chars() {
250 if c == '_' || c.is_ascii_alphabetic() || (!name.is_empty() && c.is_ascii_digit()) {
251 name.push(c);
252 } else if !name.is_empty() {
253 self.reads.insert(std::mem::take(&mut name));
254 }
255 }
256 if !name.is_empty() {
257 self.reads.insert(name);
258 }
259 }
260}
261
262fn collect(stmt: &Stmt) -> Collected {
269 let mut out = Collected::default();
270 collect_stmt(stmt, false, &mut out);
271 out
272}
273
274pub fn planned_commands(stmt: &Stmt) -> Vec<PlannedCommand> {
276 collect(stmt).commands
277}
278
279fn collect_stmt(stmt: &Stmt, background: bool, out: &mut Collected) {
280 match stmt {
281 Stmt::Assignment(a) => {
282 out.bind_path(&a.path);
283 collect_expr(&a.value, background, out)
284 }
285 Stmt::Command(cmd) => collect_command(cmd, background, out),
286 Stmt::Pipeline(p) => {
287 for cmd in &p.commands {
288 collect_command(cmd, background || p.background, out);
289 }
290 }
291 Stmt::If(s) => {
292 collect_expr(&s.condition, background, out);
293 collect_block(&s.then_branch, background, out);
294 if let Some(else_branch) = &s.else_branch {
295 collect_block(else_branch, background, out);
296 }
297 }
298 Stmt::For(s) => {
299 out.binds.insert(s.variable.clone());
300 for item in &s.items {
301 collect_expr(item, background, out);
302 }
303 collect_block(&s.body, background, out);
304 }
305 Stmt::While(s) => {
306 collect_expr(&s.condition, background, out);
307 collect_block(&s.body, background, out);
308 }
309 Stmt::Case(s) => {
310 collect_expr(&s.expr, background, out);
311 for branch in &s.branches {
312 collect_block(&branch.body, background, out);
313 }
314 }
315 Stmt::Return(e) | Stmt::Exit(e) => {
316 if let Some(e) = e {
317 collect_expr(e, background, out);
318 }
319 }
320 Stmt::ToolDef(def) => {
321 for param in &def.params {
322 out.binds.insert(param.name.clone());
323 if let Some(default) = ¶m.default {
324 collect_expr(default, background, out);
325 }
326 }
327 collect_block(&def.body, background, out)
328 }
329 Stmt::Test(t) => collect_test(t, background, out),
330 Stmt::AndChain { left, right } | Stmt::OrChain { left, right } => {
331 collect_stmt(left, background, out);
332 collect_stmt(right, background, out);
333 }
334 Stmt::EnvScoped { assignments, body } => {
335 for a in assignments {
336 out.bind_path(&a.path);
337 collect_expr(&a.value, background, out);
338 }
339 collect_stmt(body, background, out);
340 }
341 Stmt::Break(_) | Stmt::Continue(_) | Stmt::Empty => {}
342 }
343}
344
345fn collect_block(stmts: &[Stmt], background: bool, out: &mut Collected) {
346 for stmt in stmts {
347 collect_stmt(stmt, background, out);
348 }
349}
350
351fn collect_command(cmd: &Command, background: bool, out: &mut Collected) {
352 let args: Vec<PlannedValue> = cmd.args.iter().map(|arg| plan_arg(arg).1).collect();
353 let redirects = cmd
354 .redirects
355 .iter()
356 .map(|r| PlannedRedirect::new(r.kind.to_string(), plan_redirect_target(&r.target)))
357 .collect();
358 out.commands.push(PlannedCommand::new(
359 cmd.name.clone(),
360 args,
361 redirects,
362 background,
363 ));
364 for arg in &cmd.args {
368 if let Some(key) = presented_key(arg) {
369 out.keys.push(key);
370 }
371 }
372 for arg in &cmd.args {
375 match arg {
376 Arg::Positional(e) => collect_expr(e, background, out),
377 Arg::Named { value, .. } | Arg::WordAssign { value, .. } => {
378 collect_expr(value, background, out)
379 }
380 Arg::ShortFlag(_) | Arg::LongFlag(_) | Arg::DoubleDash => {}
381 }
382 }
383 for redirect in &cmd.redirects {
384 collect_expr(&redirect.target, background, out);
385 }
386}
387
388fn collect_expr(expr: &Expr, background: bool, out: &mut Collected) {
389 match expr {
390 Expr::Command(cmd) => collect_command(cmd, background, out),
391 Expr::CommandSubst(stmts) => collect_block(stmts, background, out),
392 Expr::BinaryOp { left, right, .. } => {
393 collect_expr(left, background, out);
394 collect_expr(right, background, out);
395 }
396 Expr::Interpolated(parts) => collect_parts(parts, background, out),
397 Expr::HereDocBody { parts, .. } => {
398 for part in parts {
399 collect_part(&part.part, background, out);
400 }
401 }
402 Expr::Test(t) => collect_test(t, background, out),
403 Expr::VarWithDefault { path, default } => {
404 out.read_path(path);
405 collect_parts(default, background, out)
406 }
407 Expr::ListLiteral(elems) => {
408 for elem in elems {
409 match elem {
410 ListElem::Item(e) | ListElem::Spread(e) => collect_expr(e, background, out),
411 }
412 }
413 }
414 Expr::RecordLiteral(entries) => {
415 for entry in entries {
416 if let RecordKey::Interpolated(parts) = &entry.key {
417 collect_parts(parts, background, out);
418 }
419 collect_expr(&entry.value, background, out);
420 }
421 }
422 Expr::VarRef(path) | Expr::VarLength(path) => out.read_path(path),
423 Expr::Arithmetic(e) => out.read_arithmetic(e),
424 Expr::Literal(_)
427 | Expr::Positional(_)
428 | Expr::AllArgs
429 | Expr::ArgCount
430 | Expr::LastExitCode
431 | Expr::CurrentPid
432 | Expr::GlobPattern(_) => {}
433 }
434}
435
436fn collect_parts(parts: &[StringPart], background: bool, out: &mut Collected) {
437 for part in parts {
438 collect_part(part, background, out);
439 }
440}
441
442fn collect_part(part: &StringPart, background: bool, out: &mut Collected) {
443 match part {
444 StringPart::CommandSubst(stmts) => collect_block(stmts, background, out),
445 StringPart::VarWithDefault { path, default } => {
446 out.read_path(path);
447 collect_parts(default, background, out)
448 }
449 StringPart::Var(path) | StringPart::VarLength(path) => out.read_path(path),
450 StringPart::Arithmetic(e) => out.read_arithmetic(e),
451 StringPart::Literal(_)
453 | StringPart::Positional(_)
454 | StringPart::AllArgs
455 | StringPart::ArgCount
456 | StringPart::LastExitCode
457 | StringPart::CurrentPid => {}
458 }
459}
460
461fn collect_test(test: &TestExpr, background: bool, out: &mut Collected) {
462 match test {
463 TestExpr::FileTest { path, .. } => collect_expr(path, background, out),
464 TestExpr::StringTest { value, .. } => collect_expr(value, background, out),
465 TestExpr::Comparison { left, right, .. }
466 | TestExpr::In { left, right }
467 | TestExpr::NotIn { left, right } => {
468 collect_expr(left, background, out);
469 collect_expr(right, background, out);
470 }
471 TestExpr::And { left, right } | TestExpr::Or { left, right } => {
472 collect_test(left, background, out);
473 collect_test(right, background, out);
474 }
475 TestExpr::Not { expr } => collect_test(expr, background, out),
476 }
477}
478
479pub fn render_stmt(stmt: &Stmt) -> String {
483 match stmt {
484 Stmt::Assignment(a) => render_assignment(a),
485 Stmt::Command(cmd) => render_command(cmd),
486 Stmt::Pipeline(p) => render_pipeline(p),
487 Stmt::If(s) => render_if(s),
488 Stmt::For(s) => render_for(s),
489 Stmt::While(s) => render_while(s),
490 Stmt::Case(s) => render_case(s),
491 Stmt::Break(n) => render_keyword("break", n.map(|n| n.to_string())),
492 Stmt::Continue(n) => render_keyword("continue", n.map(|n| n.to_string())),
493 Stmt::Return(e) => render_keyword("return", e.as_ref().map(|e| render_expr(e))),
494 Stmt::Exit(e) => render_keyword("exit", e.as_ref().map(|e| render_expr(e))),
495 Stmt::ToolDef(def) => render_tooldef(def),
496 Stmt::Test(t) => format!("[[ {} ]]", render_test(t)),
497 Stmt::AndChain { left, right } => {
498 format!("{} && {}", render_stmt(left), render_stmt(right))
499 }
500 Stmt::OrChain { left, right } => {
501 format!("{} || {}", render_stmt(left), render_stmt(right))
502 }
503 Stmt::EnvScoped { assignments, body } => {
504 let prefix: Vec<String> = assignments.iter().map(render_assignment).collect();
505 format!("{} {}", prefix.join(" "), render_stmt(body))
506 }
507 Stmt::Empty => String::new(),
508 }
509}
510
511fn render_keyword(word: &str, operand: Option<String>) -> String {
512 match operand {
513 Some(operand) => format!("{word} {operand}"),
514 None => word.to_string(),
515 }
516}
517
518fn render_block(stmts: &[Stmt]) -> String {
519 stmts
520 .iter()
521 .filter(|s| !matches!(s, Stmt::Empty))
522 .map(render_stmt)
523 .collect::<Vec<_>>()
524 .join("; ")
525}
526
527fn render_assignment(a: &Assignment) -> String {
528 let path = render_varpath(&a.path);
529 if a.local {
530 format!("local {} = {}", path, render_expr(&a.value))
531 } else {
532 format!("{}={}", path, render_expr(&a.value))
533 }
534}
535
536pub fn render_command(cmd: &Command) -> String {
538 let mut parts = vec![cmd.name.clone()];
539 for arg in &cmd.args {
540 parts.push(plan_arg(arg).0);
541 }
542 for redirect in &cmd.redirects {
543 parts.push(render_redirect(redirect));
544 }
545 parts.join(" ")
546}
547
548const CONFIRM_KEY: &str = "confirm";
554
555const CONFIRM_KEY_KIND: &str = "confirm-key";
559
560fn presented_key(arg: &Arg) -> Option<String> {
568 let value = match arg {
569 Arg::Named { key, value } | Arg::WordAssign { key, value } if key == CONFIRM_KEY => value,
570 _ => return None,
571 };
572 match value {
573 Expr::Literal(Value::String(s)) => Some(s.clone()),
574 _ => None,
575 }
576}
577
578fn plan_arg(arg: &Arg) -> (String, PlannedValue) {
587 if presented_key(arg).is_some() {
588 let value = PlannedValue::redacted(CONFIRM_KEY_KIND, None);
589 let text = match arg {
590 Arg::WordAssign { key, .. } => format!("{key}={}", value.display()),
593 _ => format!("--{CONFIRM_KEY}={}", value.display()),
594 };
595 return (text, value);
596 }
597 let (text, value) = match arg {
598 Arg::Positional(e) => {
599 let value = PlannedValue::Plain(render_expr(e));
600 (value.display(), value)
601 }
602 Arg::Named { key, value: e } => {
603 let value = PlannedValue::Plain(render_expr(e));
604 (format!("--{key}={}", value.display()), value)
605 }
606 Arg::WordAssign { key, value: e } => {
607 let value = PlannedValue::Plain(render_expr(e));
608 (format!("{key}={}", value.display()), value)
609 }
610 Arg::ShortFlag(f) => {
611 let text = format!("-{f}");
612 (text.clone(), PlannedValue::Plain(text))
613 }
614 Arg::LongFlag(f) => {
615 let text = format!("--{f}");
616 (text.clone(), PlannedValue::Plain(text))
617 }
618 Arg::DoubleDash => ("--".to_string(), PlannedValue::Plain("--".to_string())),
619 };
620 let structured = if value.is_redacted() {
626 value
627 } else {
628 PlannedValue::Plain(text.clone())
629 };
630 (text, structured)
631}
632
633fn plan_redirect_target(target: &Expr) -> PlannedValue {
636 PlannedValue::Plain(render_expr(target))
637}
638
639fn render_redirect(redirect: &Redirect) -> String {
640 match redirect.kind {
643 super::types::RedirectKind::MergeStderr | super::types::RedirectKind::MergeStdout => {
644 redirect.kind.to_string()
645 }
646 _ => format!(
647 "{} {}",
648 redirect.kind,
649 plan_redirect_target(&redirect.target).display()
650 ),
651 }
652}
653
654fn render_pipeline(p: &Pipeline) -> String {
655 let body = p
656 .commands
657 .iter()
658 .map(render_command)
659 .collect::<Vec<_>>()
660 .join(" | ");
661 if p.background {
662 format!("{body} &")
663 } else {
664 body
665 }
666}
667
668fn render_if(s: &IfStmt) -> String {
669 let mut out = format!(
670 "if {}; then {}",
671 render_expr(&s.condition),
672 render_block(&s.then_branch)
673 );
674 if let Some(else_branch) = &s.else_branch {
675 let rendered = render_block(else_branch);
676 if !rendered.is_empty() {
677 out.push_str(&format!("; else {rendered}"));
678 }
679 }
680 out.push_str("; fi");
681 out
682}
683
684fn render_for(s: &ForLoop) -> String {
685 let items: Vec<String> = s.items.iter().map(render_expr).collect();
686 format!(
687 "for {} in {}; do {}; done",
688 s.variable,
689 items.join(" "),
690 render_block(&s.body)
691 )
692}
693
694fn render_while(s: &WhileLoop) -> String {
695 format!(
696 "while {}; do {}; done",
697 render_expr(&s.condition),
698 render_block(&s.body)
699 )
700}
701
702fn render_case(s: &CaseStmt) -> String {
703 let branches: Vec<String> = s
704 .branches
705 .iter()
706 .map(|b| format!("{}) {} ;;", b.patterns.join("|"), render_block(&b.body)))
707 .collect();
708 format!("case {} in {} esac", render_expr(&s.expr), branches.join(" "))
709}
710
711fn render_tooldef(def: &ToolDef) -> String {
712 let params: Vec<String> = def
713 .params
714 .iter()
715 .map(|p| match &p.default {
716 Some(default) => format!("{}={}", p.name, render_expr(default)),
717 None => p.name.clone(),
718 })
719 .collect();
720 format!(
721 "tool {}({}) {{ {} }}",
722 def.name,
723 params.join(", "),
724 render_block(&def.body)
725 )
726}
727
728pub fn render_expr(expr: &Expr) -> String {
731 match expr {
732 Expr::Literal(v) => render_literal(v),
733 Expr::VarRef(path) => format!("${{{}}}", render_varpath(path)),
734 Expr::Interpolated(parts) => format!("\"{}\"", render_parts(parts)),
735 Expr::HereDocBody { parts, strip_tabs } => {
736 let dash = if *strip_tabs { "-" } else { "" };
737 let body: Vec<String> = parts.iter().map(|sp| render_part(&sp.part)).collect();
738 format!("<<{dash}EOF\n{}\nEOF", body.join(""))
739 }
740 Expr::BinaryOp { left, op, right } => {
741 let op = match op {
742 BinaryOp::And => "&&",
743 BinaryOp::Or => "||",
744 };
745 format!("{} {} {}", render_expr(left), op, render_expr(right))
746 }
747 Expr::CommandSubst(stmts) => format!("$({})", render_block(stmts)),
748 Expr::Test(t) => format!("[[ {} ]]", render_test(t)),
749 Expr::Positional(n) => format!("${n}"),
750 Expr::AllArgs => "$@".to_string(),
751 Expr::ArgCount => "$#".to_string(),
752 Expr::VarLength(path) => format!("${{#{}}}", render_varpath(path)),
753 Expr::VarWithDefault { path, default } => {
754 format!("${{{}:-{}}}", render_varpath(path), render_parts(default))
755 }
756 Expr::Arithmetic(e) => format!("$(({e}))"),
757 Expr::Command(cmd) => render_command(cmd),
758 Expr::LastExitCode => "$?".to_string(),
759 Expr::CurrentPid => "$$".to_string(),
760 Expr::GlobPattern(p) => p.clone(),
761 Expr::ListLiteral(elems) => {
762 let parts: Vec<String> = elems
763 .iter()
764 .map(|e| match e {
765 ListElem::Item(e) => render_expr(e),
766 ListElem::Spread(e) => format!("...{}", render_expr(e)),
767 })
768 .collect();
769 format!("[{}]", parts.join(" "))
770 }
771 Expr::RecordLiteral(entries) => {
772 let parts: Vec<String> = entries
773 .iter()
774 .map(|entry| {
775 let key = match &entry.key {
776 RecordKey::Bare(k) => k.clone(),
777 RecordKey::Quoted(k) => format!("\"{k}\""),
778 RecordKey::Interpolated(parts) => format!("\"{}\"", render_parts(parts)),
779 };
780 format!("{key}: {}", render_expr(&entry.value))
781 })
782 .collect();
783 format!("{{{}}}", parts.join(", "))
784 }
785 }
786}
787
788fn render_literal(value: &Value) -> String {
790 match value {
791 Value::String(s) => quote_word(s),
792 Value::Int(i) => i.to_string(),
793 Value::Float(f) => f.to_string(),
794 Value::Bool(b) => b.to_string(),
795 Value::Null => "null".to_string(),
796 Value::Json(j) => j.to_string(),
797 Value::Bytes(b) => format!("<bytes len={}>", b.len()),
801 }
802}
803
804fn quote_word(s: &str) -> String {
806 let needs_quotes = s.is_empty()
807 || s.chars()
808 .any(|c| c.is_whitespace() || "\"'$`&|;<>(){}[]*?#!~\\".contains(c));
809 if !needs_quotes {
810 return s.to_string();
811 }
812 format!("'{}'", s.replace('\'', "'\\''"))
815}
816
817fn render_parts(parts: &[StringPart]) -> String {
818 parts.iter().map(render_part).collect::<Vec<_>>().join("")
819}
820
821fn render_part(part: &StringPart) -> String {
822 match part {
823 StringPart::Literal(s) => s.replace('\\', "\\\\").replace('"', "\\\""),
824 StringPart::Var(path) => format!("${{{}}}", render_varpath(path)),
825 StringPart::VarWithDefault { path, default } => {
826 format!("${{{}:-{}}}", render_varpath(path), render_parts(default))
827 }
828 StringPart::VarLength(path) => format!("${{#{}}}", render_varpath(path)),
829 StringPart::Positional(n) => format!("${n}"),
830 StringPart::AllArgs => "$@".to_string(),
831 StringPart::ArgCount => "$#".to_string(),
832 StringPart::Arithmetic(e) => format!("$(({e}))"),
833 StringPart::CommandSubst(stmts) => format!("$({})", render_block(stmts)),
834 StringPart::LastExitCode => "$?".to_string(),
835 StringPart::CurrentPid => "$$".to_string(),
836 }
837}
838
839fn render_test(test: &TestExpr) -> String {
840 match test {
841 TestExpr::FileTest { op, path } => format!("{} {}", op, render_expr(path)),
842 TestExpr::StringTest { op, value } => format!("{} {}", op, render_expr(value)),
843 TestExpr::Comparison { left, op, right } => {
844 format!("{} {} {}", render_expr(left), op, render_expr(right))
845 }
846 TestExpr::And { left, right } => {
847 format!("{} && {}", render_test(left), render_test(right))
848 }
849 TestExpr::Or { left, right } => {
850 format!("{} || {}", render_test(left), render_test(right))
851 }
852 TestExpr::Not { expr } => format!("! {}", render_test(expr)),
853 TestExpr::In { left, right } => {
854 format!("{} in {}", render_expr(left), render_expr(right))
855 }
856 TestExpr::NotIn { left, right } => {
857 format!("{} not in {}", render_expr(left), render_expr(right))
858 }
859 }
860}
861
862fn render_varpath(path: &VarPath) -> String {
865 let mut out = String::new();
866 for (i, segment) in path.segments.iter().enumerate() {
867 match segment {
868 VarSegment::Field(name) => {
869 if i > 0 {
870 out.push('.');
871 }
872 out.push_str(name);
873 }
874 VarSegment::Index(idx) => out.push_str(&format!("[{idx}]")),
875 VarSegment::Key(k) => out.push_str(&format!("[{k}]")),
876 VarSegment::Dynamic(v) => out.push_str(&format!("[${v}]")),
877 VarSegment::Slice(a, b) => out.push_str(&format!(
878 "[{}:{}]",
879 a.map(|n| n.to_string()).unwrap_or_default(),
880 b.map(|n| n.to_string()).unwrap_or_default()
881 )),
882 }
883 }
884 out
885}
886
887#[cfg(test)]
888#[allow(clippy::unwrap_used, clippy::expect_used)]
889mod tests {
890 use super::*;
891 use crate::parser::parse;
892
893 fn planned_of(source: &str) -> StatementPlan {
894 let program = parse(source).expect("the fixture parses");
895 let stmt = program
896 .statements
897 .into_iter()
898 .find(|s| !matches!(s, Stmt::Empty))
899 .expect("one statement");
900 plan_statement(&stmt)
901 }
902
903 fn plan_of(source: &str) -> Plan {
904 planned_of(source).plan
905 }
906
907 #[test]
908 fn a_variable_renders_unexpanded() {
909 let plan = plan_of("rm -r \"${HOME}/build\"");
910 assert!(
911 plan.rendered.contains("${HOME}"),
912 "the plan must keep the variable as written: {}",
913 plan.rendered
914 );
915 }
916
917 #[test]
918 fn a_substitution_renders_unexpanded_and_plans_its_own_command() {
919 let plan = plan_of("rm $(cat list.txt)");
920 assert!(
921 plan.rendered.contains("$(cat list.txt)"),
922 "got: {}",
923 plan.rendered
924 );
925 let names: Vec<&str> = plan.commands.iter().map(|c| c.name.as_str()).collect();
926 assert_eq!(names, vec!["rm", "cat"], "the substitution runs too");
927 }
928
929 #[test]
930 fn a_loop_body_belongs_to_the_enclosing_statement() {
931 let plan = plan_of("for f in a b; do rm $f; done");
932 assert_eq!(plan.statement_kind, "for");
933 let names: Vec<&str> = plan.commands.iter().map(|c| c.name.as_str()).collect();
934 assert_eq!(names, vec!["rm"]);
935 assert!(plan.rendered.starts_with("for f in a b; do rm"));
936 }
937
938 #[test]
939 fn every_redirect_form_renders() {
940 let plan = plan_of("cmd > out.txt 2> err.txt < in.txt");
941 let kinds: Vec<&str> = plan.commands[0]
942 .redirects
943 .iter()
944 .map(|r| r.kind.as_str())
945 .collect();
946 assert_eq!(kinds, vec![">", "2>", "<"]);
947 assert_eq!(
948 plan.commands[0].redirects[0].target,
949 PlannedValue::Plain("out.txt".to_string())
950 );
951 assert!(plan.rendered.contains("> out.txt"), "got: {}", plan.rendered);
952 }
953
954 #[test]
955 fn a_redirect_target_stays_unexpanded() {
956 let plan = plan_of("echo hi > ${LOG}");
957 assert_eq!(
958 plan.commands[0].redirects[0].target,
959 PlannedValue::Plain("${LOG}".to_string())
960 );
961 }
962
963 #[test]
964 fn a_merge_redirect_renders_as_its_operator_alone() {
965 let plan = plan_of("cmd 2>&1");
966 assert!(
967 plan.rendered.ends_with("2>&1"),
968 "a merge redirect has no filename: {}",
969 plan.rendered
970 );
971 }
972
973 #[test]
974 fn every_argument_form_renders() {
975 let plan = plan_of("tool -v --force --key=value word -- --after");
976 let args = &plan.commands[0].args;
977 assert_eq!(
978 args,
979 &vec![
980 PlannedValue::Plain("-v".to_string()),
981 PlannedValue::Plain("--force".to_string()),
982 PlannedValue::Plain("--key=value".to_string()),
983 PlannedValue::Plain("word".to_string()),
984 PlannedValue::Plain("--".to_string()),
985 PlannedValue::Plain("--after".to_string()),
986 ]
987 );
988 }
989
990 #[test]
991 fn a_backgrounded_pipeline_marks_every_command() {
992 let plan = plan_of("a | b &");
993 assert!(plan.commands.iter().all(|c| c.background));
994 assert!(plan.rendered.ends_with('&'), "got: {}", plan.rendered);
995 }
996
997 #[test]
998 fn a_pipeline_renders_every_stage() {
999 let plan = plan_of("cat f | grep x | wc -l");
1000 let names: Vec<&str> = plan.commands.iter().map(|c| c.name.as_str()).collect();
1001 assert_eq!(names, vec!["cat", "grep", "wc"]);
1002 assert_eq!(plan.rendered, "cat f | grep x | wc -l");
1003 }
1004
1005 #[test]
1006 fn an_and_chain_plans_both_sides() {
1007 let plan = plan_of("mkdir d && rm -r d");
1008 assert_eq!(plan.statement_kind, "and_chain");
1009 let names: Vec<&str> = plan.commands.iter().map(|c| c.name.as_str()).collect();
1010 assert_eq!(names, vec!["mkdir", "rm"]);
1011 }
1012
1013 #[test]
1014 fn an_if_plans_its_condition_and_both_branches() {
1015 let plan = plan_of("if grep -q x f; then echo hit; else echo miss; fi");
1016 let names: Vec<&str> = plan.commands.iter().map(|c| c.name.as_str()).collect();
1017 assert_eq!(names, vec!["grep", "echo", "echo"]);
1018 }
1019
1020 #[test]
1021 fn a_quoted_word_keeps_its_spaces_inside_quotes() {
1022 let plan = plan_of("echo 'two words'");
1023 assert_eq!(plan.rendered, "echo 'two words'");
1024 }
1025
1026 #[test]
1027 fn an_interpolated_string_keeps_its_variables() {
1028 let plan = plan_of("echo \"hello ${NAME}\"");
1029 assert_eq!(plan.rendered, "echo \"hello ${NAME}\"");
1030 }
1031
1032 #[test]
1033 fn a_bracket_path_renders_with_brackets_not_dots() {
1034 let plan = plan_of("echo ${servers[web]}");
1035 assert_eq!(plan.rendered, "echo ${servers[web]}");
1036 }
1037
1038 #[test]
1039 fn rendering_truncates_at_the_limit_with_a_loud_marker() {
1040 let long = "x".repeat(PLAN_RENDER_LIMIT * 2);
1041 let plan = plan_of(&format!("echo {long}"));
1042 assert!(
1043 plan.rendered.contains("[rendering truncated at 8192 bytes]"),
1044 "expected the marker, got {} bytes ending in {:?}",
1045 plan.rendered.len(),
1046 &plan.rendered[plan.rendered.len().saturating_sub(48)..]
1047 );
1048 assert_eq!(plan.commands.len(), 1);
1050 assert_eq!(plan.commands[0].name, "echo");
1051 }
1052
1053 #[test]
1054 fn a_short_rendering_carries_no_marker() {
1055 let plan = plan_of("echo hi");
1056 assert_eq!(plan.rendered, "echo hi");
1057 }
1058
1059 #[test]
1062 fn a_literal_key_is_lifted_and_redacted() {
1063 let planned = planned_of("rm --confirm=deadbeef target.txt");
1064 assert_eq!(planned.presented_keys, vec!["deadbeef".to_string()]);
1065 assert_eq!(planned.plan.rendered, "rm --confirm=<confirm-key> target.txt");
1066 assert_eq!(
1067 planned.plan.commands[0].args,
1068 vec![
1069 PlannedValue::redacted("confirm-key", None),
1070 PlannedValue::Plain("target.txt".to_string()),
1071 ]
1072 );
1073 }
1074
1075 #[test]
1076 fn the_bare_word_assign_spelling_is_lifted_too() {
1077 let planned = planned_of("dd if=a of=b confirm=deadbeef");
1080 assert_eq!(planned.presented_keys, vec!["deadbeef".to_string()]);
1081 assert!(
1082 planned.plan.rendered.ends_with("confirm=<confirm-key>"),
1083 "got: {}",
1084 planned.plan.rendered
1085 );
1086 }
1087
1088 #[test]
1089 fn a_variable_carried_key_is_neither_lifted_nor_redacted() {
1090 let planned = planned_of("rm --confirm=${key} target.txt");
1093 assert!(planned.presented_keys.is_empty());
1094 assert_eq!(planned.plan.rendered, "rm --confirm=${key} target.txt");
1095 }
1096
1097 #[test]
1098 fn a_key_inside_a_loop_body_is_still_lifted() {
1099 let planned = planned_of("for f in a b; do rm --confirm=deadbeef $f; done");
1100 assert_eq!(planned.presented_keys, vec!["deadbeef".to_string()]);
1101 }
1102
1103 #[test]
1104 fn redaction_takes_the_whole_token_and_leaves_one_space() {
1105 let source = "rm --confirm=deadbeef target.txt";
1106 assert_eq!(
1107 redact_keys(source, &["deadbeef".to_string()]),
1108 "rm target.txt"
1109 );
1110 }
1111
1112 #[test]
1113 fn redaction_leaves_a_source_that_never_presented_a_key_alone() {
1114 let source = "rm target.txt";
1115 assert_eq!(redact_keys(source, &[]), source);
1116 assert_eq!(redact_keys(source, &["deadbeef".to_string()]), source);
1117 }
1118
1119 #[test]
1122 fn reads_cover_interpolation_length_subscript_and_arithmetic() {
1123 let plan = plan_of(
1124 "echo \"${greeting} ${#items} ${servers[$env]}\" $((base + offset))",
1125 );
1126 assert_eq!(
1127 plan.free_variables,
1128 vec!["base", "env", "greeting", "items", "offset", "servers"],
1129 "every lexical read is listed, sorted"
1130 );
1131 assert!(plan.bound_variables.is_empty());
1132 }
1133
1134 #[test]
1135 fn a_subscripted_assignment_binds_the_root_and_reads_the_subscript() {
1136 let plan = plan_of("counts[$key]=1");
1137 assert_eq!(plan.free_variables, vec!["key"]);
1138 assert_eq!(plan.bound_variables, vec!["counts"]);
1139 }
1140
1141 #[test]
1142 fn an_env_prefix_binds_its_name_for_the_one_command() {
1143 let plan = plan_of("MODE=fast deploy ${TARGET}");
1144 assert_eq!(plan.free_variables, vec!["TARGET"]);
1145 assert_eq!(plan.bound_variables, vec!["MODE"]);
1146 }
1147
1148 #[test]
1151 fn plan_program_indexes_agree_with_the_parsed_program() {
1152 let source = "echo one\n\n# a comment\necho two && echo three\nX=5";
1153 let program = parse(source).expect("the fixture parses");
1154 let expected: Vec<(usize, String)> = program
1155 .statements
1156 .iter()
1157 .enumerate()
1158 .filter(|(_, s)| !matches!(s, Stmt::Empty))
1159 .map(|(i, s)| (i, s.kind_name().to_string()))
1160 .collect();
1161 let plans = plan_program(source).expect("the fixture parses");
1162 assert_eq!(
1163 plans
1164 .iter()
1165 .map(|p| (p.index, p.plan.statement_kind.clone()))
1166 .collect::<Vec<_>>(),
1167 expected,
1168 "an index here must be usable against Capture::Statement's index"
1169 );
1170 }
1171
1172 #[test]
1173 fn plan_program_redacts_a_presented_key_and_returns_no_copy_of_it() {
1174 let plans = plan_program("rm --confirm=deadbeef x.txt").expect("parses");
1177 assert_eq!(plans[0].plan.rendered, "rm --confirm=<confirm-key> x.txt");
1178 }
1179
1180 #[test]
1181 fn plan_program_returns_the_parse_errors_for_a_broken_source() {
1182 let errors = plan_program("echo 'unclosed").expect_err("must not parse");
1183 assert!(!errors.is_empty());
1184 }
1185
1186 #[test]
1187 fn redaction_covers_both_spellings_across_a_multi_statement_source() {
1188 let source = "echo one\ndd if=a confirm=deadbeef\nrm --confirm=deadbeef x";
1189 let redacted = redact_keys(source, &["deadbeef".to_string()]);
1190 assert!(!redacted.contains("deadbeef"), "got: {redacted}");
1191 assert_eq!(redacted, "echo one\ndd if=a\nrm x");
1192 }
1193}