1use std::collections::BTreeSet;
31
32use kaish_types::plan::{
33 Plan, PlannedCommand, PlannedHeredoc, PlannedRedirect, PlannedValue, PLAN_RENDER_LIMIT,
34};
35use kaish_types::Value;
36
37use super::types::{
38 Arg, Assignment, BinaryOp, CaseStmt, Command, Expr, ForLoop, IfStmt, ListElem, Pipeline,
39 PipelineStage, RecordKey, Redirect, RedirectKind, Stmt, StringPart, TestExpr, ToolDef, VarPath,
40 VarSegment,
41 WhileLoop,
42};
43
44pub struct StatementPlan {
46 pub plan: Plan,
48 pub presented_keys: Vec<String>,
55}
56
57#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
63pub struct PlannedStatement {
64 pub index: usize,
68 pub plan: Plan,
70}
71
72pub fn plan_program(
97 source: &str,
98) -> Result<Vec<PlannedStatement>, Vec<crate::parser::ParseError>> {
99 let program = crate::parser::parse(source)?;
100 Ok(program
101 .statements
102 .iter()
103 .filter(|stmt| !matches!(stmt, Stmt::Empty))
108 .enumerate()
109 .map(|(index, stmt)| PlannedStatement {
110 index,
111 plan: plan_statement(stmt).plan,
112 })
113 .collect())
114}
115
116pub(crate) fn plan_statement(stmt: &Stmt) -> StatementPlan {
120 let collected = collect(stmt);
121 let free: Vec<String> = collected
126 .reads
127 .difference(&collected.binds)
128 .cloned()
129 .collect();
130 let bound: Vec<String> = collected.binds.into_iter().collect();
131 StatementPlan {
132 plan: Plan::new(
133 truncate_rendering(render_stmt(stmt)),
134 stmt.kind_name(),
135 collected.commands,
136 )
137 .with_variables(free, bound),
138 presented_keys: collected.keys,
139 }
140}
141
142pub fn strip_confirm_tokens(rendered: &str) -> String {
155 rendered
156 .split_whitespace()
157 .filter(|word| {
158 !word.starts_with(&format!("--{CONFIRM_KEY}=")) && !word.starts_with(&format!("{CONFIRM_KEY}="))
159 })
160 .collect::<Vec<_>>()
161 .join(" ")
162}
163
164pub fn redact_keys(source: &str, keys: &[String]) -> String {
169 let mut out = source.to_string();
170 for key in keys {
171 for spelling in [format!("--{CONFIRM_KEY}={key}"), format!("{CONFIRM_KEY}={key}")] {
172 out = out.replace(&format!(" {spelling}"), "");
176 out = out.replace(&spelling, "");
177 }
178 }
179 out
180}
181
182fn truncate_rendering(rendered: String) -> String {
189 if rendered.len() <= PLAN_RENDER_LIMIT {
190 return rendered;
191 }
192 let mut cut = PLAN_RENDER_LIMIT;
194 while cut > 0 && !rendered.is_char_boundary(cut) {
195 cut -= 1;
196 }
197 let mut out = rendered[..cut].to_string();
198 out.push_str(&format!(
199 "… [rendering truncated at {PLAN_RENDER_LIMIT} bytes]"
200 ));
201 out
202}
203
204#[derive(Default)]
209struct Collected<'a> {
210 commands: Vec<PlannedCommand>,
211 keys: Vec<String>,
212 reads: BTreeSet<String>,
217 binds: BTreeSet<String>,
220 heredoc_targets: Vec<&'a Expr>,
233}
234
235impl<'a> Collected<'a> {
236 fn take_heredocs(&mut self, cmd: &'a Command) -> Vec<PlannedHeredoc> {
239 cmd.redirects
240 .iter()
241 .filter_map(|r| match &r.kind {
242 RedirectKind::HereDoc(meta) => Some((meta, &r.target)),
243 _ => None,
244 })
245 .map(|(meta, target)| {
246 let index = self.heredoc_targets.len();
247 self.heredoc_targets.push(target);
248 let free = if meta.literal {
253 Vec::new()
254 } else {
255 let mut body_reads = Collected::default();
256 collect_expr(target, false, &mut body_reads);
257 body_reads.reads.into_iter().collect()
258 };
259 PlannedHeredoc::new(
260 index,
261 meta.delimiter.clone(),
262 meta.literal,
263 meta.strip_tabs,
264 PlannedValue::Plain(meta.body.clone()),
265 meta.body_offset,
266 )
267 .with_free_variables(free)
268 })
269 .collect()
270 }
271
272 fn read_path(&mut self, path: &VarPath) {
275 for (i, segment) in path.segments.iter().enumerate() {
276 match segment {
277 VarSegment::Field(name) if i == 0 => {
278 self.reads.insert(name.clone());
279 }
280 VarSegment::Dynamic(v) => {
281 self.reads.insert(v.clone());
282 }
283 _ => {}
284 }
285 }
286 }
287
288 fn bind_path(&mut self, path: &VarPath) {
291 if let Some(VarSegment::Field(name)) = path.segments.first() {
292 self.binds.insert(name.clone());
293 }
294 for segment in path.segments.iter().skip(1) {
295 if let VarSegment::Dynamic(v) = segment {
296 self.reads.insert(v.clone());
297 }
298 }
299 }
300
301 fn read_arithmetic(&mut self, expr: &str) {
305 let mut name = String::new();
306 for c in expr.chars() {
307 if c == '_' || c.is_ascii_alphabetic() || (!name.is_empty() && c.is_ascii_digit()) {
308 name.push(c);
309 } else if !name.is_empty() {
310 self.reads.insert(std::mem::take(&mut name));
311 }
312 }
313 if !name.is_empty() {
314 self.reads.insert(name);
315 }
316 }
317}
318
319fn collect<'a>(stmt: &'a Stmt) -> Collected<'a> {
326 let mut out = Collected::default();
327 collect_stmt(stmt, false, &mut out);
328 out
329}
330
331pub(crate) fn heredoc_targets(stmt: &Stmt) -> Vec<&Expr> {
338 collect(stmt).heredoc_targets
339}
340
341pub fn planned_commands(stmt: &Stmt) -> Vec<PlannedCommand> {
343 collect(stmt).commands
344}
345
346fn collect_stmt<'a>(stmt: &'a Stmt, background: bool, out: &mut Collected<'a>) {
347 match stmt {
348 Stmt::Assignment(a) => {
349 out.bind_path(&a.path);
350 collect_expr(&a.value, background, out)
351 }
352 Stmt::Command(cmd) => collect_command(cmd, background, out),
353 Stmt::Pipeline(p) => {
354 for stage in &p.stages {
355 match stage {
356 PipelineStage::Command(cmd) => {
357 collect_command(cmd, background || p.background, out)
358 }
359 PipelineStage::Compound(stmt) => {
362 collect_stmt(stmt, background || p.background, out)
363 }
364 }
365 }
366 }
367 Stmt::If(s) => {
368 collect_expr(&s.condition, background, out);
369 collect_block(&s.then_branch, background, out);
370 if let Some(else_branch) = &s.else_branch {
371 collect_block(else_branch, background, out);
372 }
373 }
374 Stmt::For(s) => {
375 out.binds.insert(s.variable.clone());
376 for item in &s.items {
377 collect_expr(item, background, out);
378 }
379 collect_block(&s.body, background, out);
380 }
381 Stmt::While(s) => {
382 collect_expr(&s.condition, background, out);
383 collect_block(&s.body, background, out);
384 }
385 Stmt::Case(s) => {
386 collect_expr(&s.expr, background, out);
387 for branch in &s.branches {
388 collect_block(&branch.body, background, out);
389 }
390 }
391 Stmt::Return(e) | Stmt::Exit(e) => {
392 if let Some(e) = e {
393 collect_expr(e, background, out);
394 }
395 }
396 Stmt::ToolDef(def) => {
397 for param in &def.params {
398 out.binds.insert(param.name.clone());
399 if let Some(default) = ¶m.default {
400 collect_expr(default, background, out);
401 }
402 }
403 collect_block(&def.body, background, out)
404 }
405 Stmt::Test(t) => collect_test(t, background, out),
406 Stmt::AndChain { left, right } | Stmt::OrChain { left, right } => {
407 collect_stmt(left, background, out);
408 collect_stmt(right, background, out);
409 }
410 Stmt::EnvScoped { assignments, body } => {
411 for a in assignments {
412 out.bind_path(&a.path);
413 collect_expr(&a.value, background, out);
414 }
415 collect_stmt(body, background, out);
416 }
417 Stmt::Break(_) | Stmt::Continue(_) | Stmt::Empty => {}
418 }
419}
420
421fn collect_block<'a>(stmts: &'a [Stmt], background: bool, out: &mut Collected<'a>) {
422 for stmt in stmts {
423 collect_stmt(stmt, background, out);
424 }
425}
426
427fn collect_command<'a>(cmd: &'a Command, background: bool, out: &mut Collected<'a>) {
428 let args: Vec<PlannedValue> = cmd.args.iter().map(|arg| plan_arg(arg).1).collect();
429 let redirects = cmd
430 .redirects
431 .iter()
432 .map(|r| PlannedRedirect::new(r.kind.to_string(), plan_redirect_target(r)))
433 .collect();
434 let heredocs = out.take_heredocs(cmd);
435 out.commands.push(
436 PlannedCommand::new(cmd.name.clone(), args, redirects, background)
437 .with_heredocs(heredocs),
438 );
439 for arg in &cmd.args {
443 if let Some(key) = presented_key(arg) {
444 out.keys.push(key);
445 }
446 }
447 for arg in &cmd.args {
450 match arg {
451 Arg::Positional(e) => collect_expr(e, background, out),
452 Arg::Named { value, .. } | Arg::WordAssign { value, .. } => {
453 collect_expr(value, background, out)
454 }
455 Arg::ShortFlag(_) | Arg::LongFlag(_) | Arg::DoubleDash => {}
456 }
457 }
458 for redirect in &cmd.redirects {
459 collect_expr(&redirect.target, background, out);
460 }
461}
462
463fn collect_expr<'a>(expr: &'a Expr, background: bool, out: &mut Collected<'a>) {
464 match expr {
465 Expr::Command(cmd) => collect_command(cmd, background, out),
466 Expr::CommandSubst(stmts) => collect_block(stmts, background, out),
467 Expr::Not(inner) => collect_expr(inner, background, out),
469 Expr::BinaryOp { left, right, .. } => {
470 collect_expr(left, background, out);
471 collect_expr(right, background, out);
472 }
473 Expr::Interpolated(parts) => collect_parts(parts, background, out),
474 Expr::HereDocBody { parts, .. } => {
475 for part in parts {
476 collect_part(&part.part, background, out);
477 }
478 }
479 Expr::Test(t) => collect_test(t, background, out),
480 Expr::VarWithDefault { path, default } => {
481 out.read_path(path);
482 collect_parts(default, background, out)
483 }
484 Expr::ListLiteral(elems) => {
485 for elem in elems {
486 match elem {
487 ListElem::Item(e) | ListElem::Spread(e) => collect_expr(e, background, out),
488 }
489 }
490 }
491 Expr::RecordLiteral(entries) => {
492 for entry in entries {
493 if let RecordKey::Interpolated(parts) = &entry.key {
494 collect_parts(parts, background, out);
495 }
496 collect_expr(&entry.value, background, out);
497 }
498 }
499 Expr::VarRef(path) | Expr::VarLength(path) => out.read_path(path),
500 Expr::Arithmetic(e) => out.read_arithmetic(e),
501 Expr::Literal(_)
504 | Expr::Positional(_)
505 | Expr::AllArgs
506 | Expr::ArgCount
507 | Expr::LastExitCode
508 | Expr::CurrentPid
509 | Expr::GlobPattern(_) => {}
510 }
511}
512
513fn collect_parts<'a>(parts: &'a [StringPart], background: bool, out: &mut Collected<'a>) {
514 for part in parts {
515 collect_part(part, background, out);
516 }
517}
518
519fn collect_part<'a>(part: &'a StringPart, background: bool, out: &mut Collected<'a>) {
520 match part {
521 StringPart::CommandSubst(stmts) => collect_block(stmts, background, out),
522 StringPart::VarWithDefault { path, default } => {
523 out.read_path(path);
524 collect_parts(default, background, out)
525 }
526 StringPart::Var(path) | StringPart::VarLength(path) => out.read_path(path),
527 StringPart::Arithmetic(e) => out.read_arithmetic(e),
528 StringPart::Literal(_)
530 | StringPart::Positional(_)
531 | StringPart::AllArgs
532 | StringPart::ArgCount
533 | StringPart::LastExitCode
534 | StringPart::CurrentPid => {}
535 }
536}
537
538fn collect_test<'a>(test: &'a TestExpr, background: bool, out: &mut Collected<'a>) {
539 match test {
540 TestExpr::FileTest { path, .. } => collect_expr(path, background, out),
541 TestExpr::StringTest { value, .. } => collect_expr(value, background, out),
542 TestExpr::Comparison { left, right, .. }
543 | TestExpr::In { left, right }
544 | TestExpr::NotIn { left, right } => {
545 collect_expr(left, background, out);
546 collect_expr(right, background, out);
547 }
548 TestExpr::And { left, right } | TestExpr::Or { left, right } => {
549 collect_test(left, background, out);
550 collect_test(right, background, out);
551 }
552 TestExpr::Not { expr } => collect_test(expr, background, out),
553 }
554}
555
556pub(crate) fn render_stmt(stmt: &Stmt) -> String {
560 match stmt {
561 Stmt::Assignment(a) => render_assignment(a),
562 Stmt::Command(cmd) => render_command(cmd),
563 Stmt::Pipeline(p) => render_pipeline(p),
564 Stmt::If(s) => render_if(s),
565 Stmt::For(s) => render_for(s),
566 Stmt::While(s) => render_while(s),
567 Stmt::Case(s) => render_case(s),
568 Stmt::Break(n) => render_keyword("break", n.map(|n| n.to_string())),
569 Stmt::Continue(n) => render_keyword("continue", n.map(|n| n.to_string())),
570 Stmt::Return(e) => render_keyword("return", e.as_ref().map(|e| render_expr(e))),
571 Stmt::Exit(e) => render_keyword("exit", e.as_ref().map(|e| render_expr(e))),
572 Stmt::ToolDef(def) => render_tooldef(def),
573 Stmt::Test(t) => format!("[[ {} ]]", render_test(t)),
574 Stmt::AndChain { left, right } => {
575 format!("{} && {}", render_stmt(left), render_stmt(right))
576 }
577 Stmt::OrChain { left, right } => {
578 format!("{} || {}", render_stmt(left), render_stmt(right))
579 }
580 Stmt::EnvScoped { assignments, body } => {
581 let prefix: Vec<String> = assignments.iter().map(render_assignment).collect();
582 format!("{} {}", prefix.join(" "), render_stmt(body))
583 }
584 Stmt::Empty => String::new(),
585 }
586}
587
588fn render_keyword(word: &str, operand: Option<String>) -> String {
589 match operand {
590 Some(operand) => format!("{word} {operand}"),
591 None => word.to_string(),
592 }
593}
594
595fn render_block(stmts: &[Stmt]) -> String {
596 stmts
597 .iter()
598 .filter(|s| !matches!(s, Stmt::Empty))
599 .map(render_stmt)
600 .collect::<Vec<_>>()
601 .join("; ")
602}
603
604fn render_assignment(a: &Assignment) -> String {
605 let path = render_varpath(&a.path);
606 if a.local {
607 format!("local {} = {}", path, render_expr(&a.value))
608 } else {
609 format!("{}={}", path, render_expr(&a.value))
610 }
611}
612
613pub(crate) fn render_command(cmd: &Command) -> String {
615 let mut parts = vec![cmd.name.clone()];
616 for arg in &cmd.args {
617 parts.push(plan_arg(arg).0);
618 }
619 for redirect in &cmd.redirects {
620 parts.push(render_redirect(redirect));
621 }
622 parts.join(" ")
623}
624
625const CONFIRM_KEY: &str = "confirm";
631
632const CONFIRM_KEY_KIND: &str = "confirm-key";
636
637fn presented_key(arg: &Arg) -> Option<String> {
645 let value = match arg {
646 Arg::Named { key, value } | Arg::WordAssign { key, value } if key == CONFIRM_KEY => value,
647 _ => return None,
648 };
649 match value {
650 Expr::Literal(Value::String(s)) => Some(s.clone()),
651 _ => None,
652 }
653}
654
655fn plan_arg(arg: &Arg) -> (String, PlannedValue) {
664 if presented_key(arg).is_some() {
665 let value = PlannedValue::redacted(CONFIRM_KEY_KIND, None);
666 let text = match arg {
667 Arg::WordAssign { key, .. } => format!("{key}={}", value.display()),
670 _ => format!("--{CONFIRM_KEY}={}", value.display()),
671 };
672 return (text, value);
673 }
674 let (text, value) = match arg {
675 Arg::Positional(e) => {
676 let value = PlannedValue::Plain(render_expr(e));
677 (value.display(), value)
678 }
679 Arg::Named { key, value: e } => {
680 let value = PlannedValue::Plain(render_expr(e));
681 (format!("--{key}={}", value.display()), value)
682 }
683 Arg::WordAssign { key, value: e } => {
684 let value = PlannedValue::Plain(render_expr(e));
685 (format!("{key}={}", value.display()), value)
686 }
687 Arg::ShortFlag(f) => {
688 let text = format!("-{f}");
689 (text.clone(), PlannedValue::Plain(text))
690 }
691 Arg::LongFlag(f) => {
692 let text = format!("--{f}");
693 (text.clone(), PlannedValue::Plain(text))
694 }
695 Arg::DoubleDash => ("--".to_string(), PlannedValue::Plain("--".to_string())),
696 };
697 let structured = if value.is_redacted() {
703 value
704 } else {
705 PlannedValue::Plain(text.clone())
706 };
707 (text, structured)
708}
709
710fn plan_redirect_target(redirect: &Redirect) -> PlannedValue {
721 match &redirect.kind {
722 RedirectKind::HereDoc(meta) => {
723 let quote = if meta.literal { "'" } else { "" };
724 PlannedValue::Plain(format!("{quote}{}{quote}", meta.delimiter))
725 }
726 _ => PlannedValue::Plain(render_expr(&redirect.target)),
727 }
728}
729
730fn render_redirect(redirect: &Redirect) -> String {
731 match &redirect.kind {
734 RedirectKind::MergeStderr | RedirectKind::MergeStdout => redirect.kind.to_string(),
735 RedirectKind::HereDoc(meta) => {
740 let dash = if meta.strip_tabs { "-" } else { "" };
741 let quote = if meta.literal { "'" } else { "" };
742 format!(
743 "<<{dash}{quote}{delim}{quote}\n{body}{delim}",
744 delim = meta.delimiter,
745 body = meta.body,
746 )
747 }
748 _ => format!(
749 "{} {}",
750 redirect.kind,
751 plan_redirect_target(redirect).display()
752 ),
753 }
754}
755
756fn render_pipeline(p: &Pipeline) -> String {
757 let body = p
758 .stages
759 .iter()
760 .map(|stage| match stage {
761 PipelineStage::Command(cmd) => render_command(cmd),
762 PipelineStage::Compound(stmt) => render_stmt(stmt),
763 })
764 .collect::<Vec<_>>()
765 .join(" | ");
766 if p.background {
767 format!("{body} &")
768 } else {
769 body
770 }
771}
772
773fn render_if(s: &IfStmt) -> String {
774 let mut out = format!(
775 "if {}; then {}",
776 render_expr(&s.condition),
777 render_block(&s.then_branch)
778 );
779 if let Some(else_branch) = &s.else_branch {
780 let rendered = render_block(else_branch);
781 if !rendered.is_empty() {
782 out.push_str(&format!("; else {rendered}"));
783 }
784 }
785 out.push_str("; fi");
786 out
787}
788
789fn render_for(s: &ForLoop) -> String {
790 let items: Vec<String> = s.items.iter().map(render_expr).collect();
791 format!(
792 "for {} in {}; do {}; done",
793 s.variable,
794 items.join(" "),
795 render_block(&s.body)
796 )
797}
798
799fn render_while(s: &WhileLoop) -> String {
800 format!(
801 "while {}; do {}; done",
802 render_expr(&s.condition),
803 render_block(&s.body)
804 )
805}
806
807fn render_case(s: &CaseStmt) -> String {
808 let branches: Vec<String> = s
809 .branches
810 .iter()
811 .map(|b| format!("{}) {} ;;", b.patterns.join("|"), render_block(&b.body)))
812 .collect();
813 format!("case {} in {} esac", render_expr(&s.expr), branches.join(" "))
814}
815
816fn render_tooldef(def: &ToolDef) -> String {
817 let params: Vec<String> = def
818 .params
819 .iter()
820 .map(|p| match &p.default {
821 Some(default) => format!("{}={}", p.name, render_expr(default)),
822 None => p.name.clone(),
823 })
824 .collect();
825 format!(
826 "tool {}({}) {{ {} }}",
827 def.name,
828 params.join(", "),
829 render_block(&def.body)
830 )
831}
832
833pub(crate) fn render_expr(expr: &Expr) -> String {
836 match expr {
837 Expr::Not(inner) => format!("! {}", render_expr(inner)),
838 Expr::Literal(v) => render_literal(v),
839 Expr::VarRef(path) => format!("${{{}}}", render_varpath(path)),
840 Expr::Interpolated(parts) => format!("\"{}\"", render_parts(parts)),
841 Expr::HereDocBody { parts, strip_tabs } => {
842 let dash = if *strip_tabs { "-" } else { "" };
843 let body: Vec<String> = parts.iter().map(|sp| render_part(&sp.part)).collect();
844 format!("<<{dash}EOF\n{}\nEOF", body.join(""))
845 }
846 Expr::BinaryOp { left, op, right } => {
847 let op = match op {
848 BinaryOp::And => "&&",
849 BinaryOp::Or => "||",
850 };
851 format!("{} {} {}", render_expr(left), op, render_expr(right))
852 }
853 Expr::CommandSubst(stmts) => format!("$({})", render_block(stmts)),
854 Expr::Test(t) => format!("[[ {} ]]", render_test(t)),
855 Expr::Positional(n) => format!("${n}"),
856 Expr::AllArgs => "$@".to_string(),
857 Expr::ArgCount => "$#".to_string(),
858 Expr::VarLength(path) => format!("${{#{}}}", render_varpath(path)),
859 Expr::VarWithDefault { path, default } => {
860 format!("${{{}:-{}}}", render_varpath(path), render_parts(default))
861 }
862 Expr::Arithmetic(e) => format!("$(({e}))"),
863 Expr::Command(cmd) => render_command(cmd),
864 Expr::LastExitCode => "$?".to_string(),
865 Expr::CurrentPid => "$$".to_string(),
866 Expr::GlobPattern(p) => p.clone(),
867 Expr::ListLiteral(elems) => {
868 let parts: Vec<String> = elems
869 .iter()
870 .map(|e| match e {
871 ListElem::Item(e) => render_expr(e),
872 ListElem::Spread(e) => format!("...{}", render_expr(e)),
873 })
874 .collect();
875 format!("[{}]", parts.join(" "))
876 }
877 Expr::RecordLiteral(entries) => {
878 let parts: Vec<String> = entries
879 .iter()
880 .map(|entry| {
881 let key = match &entry.key {
882 RecordKey::Bare(k) => k.clone(),
883 RecordKey::Quoted(k) => format!("\"{k}\""),
884 RecordKey::Interpolated(parts) => format!("\"{}\"", render_parts(parts)),
885 };
886 format!("{key}: {}", render_expr(&entry.value))
887 })
888 .collect();
889 format!("{{{}}}", parts.join(", "))
890 }
891 }
892}
893
894fn render_literal(value: &Value) -> String {
896 match value {
897 Value::String(s) => quote_word(s),
898 Value::Int(i) => i.to_string(),
899 Value::Float(f) => f.to_string(),
900 Value::Bool(b) => b.to_string(),
901 Value::Null => "null".to_string(),
902 Value::Json(j) => j.to_string(),
903 Value::Bytes(b) => format!("<bytes len={}>", b.len()),
907 }
908}
909
910fn quote_word(s: &str) -> String {
912 let needs_quotes = s.is_empty()
913 || s.chars()
914 .any(|c| c.is_whitespace() || "\"'$`&|;<>(){}[]*?#!~\\".contains(c));
915 if !needs_quotes {
916 return s.to_string();
917 }
918 format!("'{}'", s.replace('\'', "'\\''"))
921}
922
923fn render_parts(parts: &[StringPart]) -> String {
924 parts.iter().map(render_part).collect::<Vec<_>>().join("")
925}
926
927fn render_part(part: &StringPart) -> String {
928 match part {
929 StringPart::Literal(s) => s.replace('\\', "\\\\").replace('"', "\\\""),
930 StringPart::Var(path) => format!("${{{}}}", render_varpath(path)),
931 StringPart::VarWithDefault { path, default } => {
932 format!("${{{}:-{}}}", render_varpath(path), render_parts(default))
933 }
934 StringPart::VarLength(path) => format!("${{#{}}}", render_varpath(path)),
935 StringPart::Positional(n) => format!("${n}"),
936 StringPart::AllArgs => "$@".to_string(),
937 StringPart::ArgCount => "$#".to_string(),
938 StringPart::Arithmetic(e) => format!("$(({e}))"),
939 StringPart::CommandSubst(stmts) => format!("$({})", render_block(stmts)),
940 StringPart::LastExitCode => "$?".to_string(),
941 StringPart::CurrentPid => "$$".to_string(),
942 }
943}
944
945fn render_test(test: &TestExpr) -> String {
946 match test {
947 TestExpr::FileTest { op, path } => format!("{} {}", op, render_expr(path)),
948 TestExpr::StringTest { op, value } => format!("{} {}", op, render_expr(value)),
949 TestExpr::Comparison { left, op, right } => {
950 format!("{} {} {}", render_expr(left), op, render_expr(right))
951 }
952 TestExpr::And { left, right } => {
953 format!("{} && {}", render_test(left), render_test(right))
954 }
955 TestExpr::Or { left, right } => {
956 format!("{} || {}", render_test(left), render_test(right))
957 }
958 TestExpr::Not { expr } => format!("! {}", render_test(expr)),
959 TestExpr::In { left, right } => {
960 format!("{} in {}", render_expr(left), render_expr(right))
961 }
962 TestExpr::NotIn { left, right } => {
963 format!("{} not in {}", render_expr(left), render_expr(right))
964 }
965 }
966}
967
968fn render_varpath(path: &VarPath) -> String {
971 let mut out = String::new();
972 for (i, segment) in path.segments.iter().enumerate() {
973 match segment {
974 VarSegment::Field(name) => {
975 if i > 0 {
976 out.push('.');
977 }
978 out.push_str(name);
979 }
980 VarSegment::Index(idx) => out.push_str(&format!("[{idx}]")),
981 VarSegment::Key(k) => out.push_str(&format!("[{k}]")),
982 VarSegment::Dynamic(v) => out.push_str(&format!("[${v}]")),
983 VarSegment::Slice(a, b) => out.push_str(&format!(
984 "[{}:{}]",
985 a.map(|n| n.to_string()).unwrap_or_default(),
986 b.map(|n| n.to_string()).unwrap_or_default()
987 )),
988 }
989 }
990 out
991}
992
993#[cfg(test)]
994#[allow(clippy::unwrap_used, clippy::expect_used)]
995mod tests {
996 use super::*;
997 use crate::parser::parse;
998
999 fn planned_of(source: &str) -> StatementPlan {
1000 let program = parse(source).expect("the fixture parses");
1001 let stmt = program
1002 .statements
1003 .into_iter()
1004 .find(|s| !matches!(s, Stmt::Empty))
1005 .expect("one statement");
1006 plan_statement(&stmt)
1007 }
1008
1009 fn plan_of(source: &str) -> Plan {
1010 planned_of(source).plan
1011 }
1012
1013 #[test]
1014 fn a_variable_renders_unexpanded() {
1015 let plan = plan_of("rm -r \"${HOME}/build\"");
1016 assert!(
1017 plan.rendered.contains("${HOME}"),
1018 "the plan must keep the variable as written: {}",
1019 plan.rendered
1020 );
1021 }
1022
1023 #[test]
1024 fn a_substitution_renders_unexpanded_and_plans_its_own_command() {
1025 let plan = plan_of("rm $(cat list.txt)");
1026 assert!(
1027 plan.rendered.contains("$(cat list.txt)"),
1028 "got: {}",
1029 plan.rendered
1030 );
1031 let names: Vec<&str> = plan.commands.iter().map(|c| c.name.as_str()).collect();
1032 assert_eq!(names, vec!["rm", "cat"], "the substitution runs too");
1033 }
1034
1035 #[test]
1036 fn a_loop_body_belongs_to_the_enclosing_statement() {
1037 let plan = plan_of("for f in a b; do rm $f; done");
1038 assert_eq!(plan.statement_kind, "for");
1039 let names: Vec<&str> = plan.commands.iter().map(|c| c.name.as_str()).collect();
1040 assert_eq!(names, vec!["rm"]);
1041 assert!(plan.rendered.starts_with("for f in a b; do rm"));
1042 }
1043
1044 #[test]
1045 fn every_redirect_form_renders() {
1046 let plan = plan_of("cmd > out.txt 2> err.txt < in.txt");
1047 let kinds: Vec<&str> = plan.commands[0]
1048 .redirects
1049 .iter()
1050 .map(|r| r.kind.as_str())
1051 .collect();
1052 assert_eq!(kinds, vec![">", "2>", "<"]);
1053 assert_eq!(
1054 plan.commands[0].redirects[0].target,
1055 PlannedValue::Plain("out.txt".to_string())
1056 );
1057 assert!(plan.rendered.contains("> out.txt"), "got: {}", plan.rendered);
1058 }
1059
1060 #[test]
1061 fn a_redirect_target_stays_unexpanded() {
1062 let plan = plan_of("echo hi > ${LOG}");
1063 assert_eq!(
1064 plan.commands[0].redirects[0].target,
1065 PlannedValue::Plain("${LOG}".to_string())
1066 );
1067 }
1068
1069 #[test]
1070 fn a_merge_redirect_renders_as_its_operator_alone() {
1071 let plan = plan_of("cmd 2>&1");
1072 assert!(
1073 plan.rendered.ends_with("2>&1"),
1074 "a merge redirect has no filename: {}",
1075 plan.rendered
1076 );
1077 }
1078
1079 #[test]
1080 fn every_argument_form_renders() {
1081 let plan = plan_of("tool -v --force --key=value word -- --after");
1082 let args = &plan.commands[0].args;
1083 assert_eq!(
1084 args,
1085 &vec![
1086 PlannedValue::Plain("-v".to_string()),
1087 PlannedValue::Plain("--force".to_string()),
1088 PlannedValue::Plain("--key=value".to_string()),
1089 PlannedValue::Plain("word".to_string()),
1090 PlannedValue::Plain("--".to_string()),
1091 PlannedValue::Plain("--after".to_string()),
1092 ]
1093 );
1094 }
1095
1096 #[test]
1097 fn a_backgrounded_pipeline_marks_every_command() {
1098 let plan = plan_of("a | b &");
1099 assert!(plan.commands.iter().all(|c| c.background));
1100 assert!(plan.rendered.ends_with('&'), "got: {}", plan.rendered);
1101 }
1102
1103 #[test]
1104 fn a_pipeline_renders_every_stage() {
1105 let plan = plan_of("cat f | grep x | wc -l");
1106 let names: Vec<&str> = plan.commands.iter().map(|c| c.name.as_str()).collect();
1107 assert_eq!(names, vec!["cat", "grep", "wc"]);
1108 assert_eq!(plan.rendered, "cat f | grep x | wc -l");
1109 }
1110
1111 #[test]
1112 fn an_and_chain_plans_both_sides() {
1113 let plan = plan_of("mkdir d && rm -r d");
1114 assert_eq!(plan.statement_kind, "and_chain");
1115 let names: Vec<&str> = plan.commands.iter().map(|c| c.name.as_str()).collect();
1116 assert_eq!(names, vec!["mkdir", "rm"]);
1117 }
1118
1119 #[test]
1120 fn an_if_plans_its_condition_and_both_branches() {
1121 let plan = plan_of("if grep -q x f; then echo hit; else echo miss; fi");
1122 let names: Vec<&str> = plan.commands.iter().map(|c| c.name.as_str()).collect();
1123 assert_eq!(names, vec!["grep", "echo", "echo"]);
1124 }
1125
1126 #[test]
1127 fn a_quoted_word_keeps_its_spaces_inside_quotes() {
1128 let plan = plan_of("echo 'two words'");
1129 assert_eq!(plan.rendered, "echo 'two words'");
1130 }
1131
1132 #[test]
1133 fn an_interpolated_string_keeps_its_variables() {
1134 let plan = plan_of("echo \"hello ${NAME}\"");
1135 assert_eq!(plan.rendered, "echo \"hello ${NAME}\"");
1136 }
1137
1138 #[test]
1139 fn a_bracket_path_renders_with_brackets_not_dots() {
1140 let plan = plan_of("echo ${servers[web]}");
1141 assert_eq!(plan.rendered, "echo ${servers[web]}");
1142 }
1143
1144 #[test]
1145 fn rendering_truncates_at_the_limit_with_a_loud_marker() {
1146 let long = "x".repeat(PLAN_RENDER_LIMIT * 2);
1147 let plan = plan_of(&format!("echo {long}"));
1148 assert!(
1149 plan.rendered.contains("[rendering truncated at 8192 bytes]"),
1150 "expected the marker, got {} bytes ending in {:?}",
1151 plan.rendered.len(),
1152 &plan.rendered[plan.rendered.len().saturating_sub(48)..]
1153 );
1154 assert_eq!(plan.commands.len(), 1);
1156 assert_eq!(plan.commands[0].name, "echo");
1157 }
1158
1159 #[test]
1160 fn a_short_rendering_carries_no_marker() {
1161 let plan = plan_of("echo hi");
1162 assert_eq!(plan.rendered, "echo hi");
1163 }
1164
1165 #[test]
1168 fn a_literal_key_is_lifted_and_redacted() {
1169 let planned = planned_of("rm --confirm=deadbeef target.txt");
1170 assert_eq!(planned.presented_keys, vec!["deadbeef".to_string()]);
1171 assert_eq!(planned.plan.rendered, "rm --confirm=<confirm-key> target.txt");
1172 assert_eq!(
1173 planned.plan.commands[0].args,
1174 vec![
1175 PlannedValue::redacted("confirm-key", None),
1176 PlannedValue::Plain("target.txt".to_string()),
1177 ]
1178 );
1179 }
1180
1181 #[test]
1182 fn the_bare_word_assign_spelling_is_lifted_too() {
1183 let planned = planned_of("dd if=a of=b confirm=deadbeef");
1186 assert_eq!(planned.presented_keys, vec!["deadbeef".to_string()]);
1187 assert!(
1188 planned.plan.rendered.ends_with("confirm=<confirm-key>"),
1189 "got: {}",
1190 planned.plan.rendered
1191 );
1192 }
1193
1194 #[test]
1195 fn a_variable_carried_key_is_neither_lifted_nor_redacted() {
1196 let planned = planned_of("rm --confirm=${key} target.txt");
1199 assert!(planned.presented_keys.is_empty());
1200 assert_eq!(planned.plan.rendered, "rm --confirm=${key} target.txt");
1201 }
1202
1203 #[test]
1204 fn a_key_inside_a_loop_body_is_still_lifted() {
1205 let planned = planned_of("for f in a b; do rm --confirm=deadbeef $f; done");
1206 assert_eq!(planned.presented_keys, vec!["deadbeef".to_string()]);
1207 }
1208
1209 #[test]
1210 fn redaction_takes_the_whole_token_and_leaves_one_space() {
1211 let source = "rm --confirm=deadbeef target.txt";
1212 assert_eq!(
1213 redact_keys(source, &["deadbeef".to_string()]),
1214 "rm target.txt"
1215 );
1216 }
1217
1218 #[test]
1219 fn redaction_leaves_a_source_that_never_presented_a_key_alone() {
1220 let source = "rm target.txt";
1221 assert_eq!(redact_keys(source, &[]), source);
1222 assert_eq!(redact_keys(source, &["deadbeef".to_string()]), source);
1223 }
1224
1225 #[test]
1228 fn reads_cover_interpolation_length_subscript_and_arithmetic() {
1229 let plan = plan_of(
1230 "echo \"${greeting} ${#items} ${servers[$env]}\" $((base + offset))",
1231 );
1232 assert_eq!(
1233 plan.free_variables,
1234 vec!["base", "env", "greeting", "items", "offset", "servers"],
1235 "every lexical read is listed, sorted"
1236 );
1237 assert!(plan.bound_variables.is_empty());
1238 }
1239
1240 #[test]
1241 fn a_subscripted_assignment_binds_the_root_and_reads_the_subscript() {
1242 let plan = plan_of("counts[$key]=1");
1243 assert_eq!(plan.free_variables, vec!["key"]);
1244 assert_eq!(plan.bound_variables, vec!["counts"]);
1245 }
1246
1247 #[test]
1248 fn an_env_prefix_binds_its_name_for_the_one_command() {
1249 let plan = plan_of("MODE=fast deploy ${TARGET}");
1250 assert_eq!(plan.free_variables, vec!["TARGET"]);
1251 assert_eq!(plan.bound_variables, vec!["MODE"]);
1252 }
1253
1254 #[test]
1266 fn plan_program_indexes_are_dense_and_ordered() {
1267 let source = "echo one\n\n# a comment\necho two && echo three\nX=5";
1268 let program = parse(source).expect("the fixture parses");
1269 let expected: Vec<String> = program
1270 .statements
1271 .iter()
1272 .filter(|s| !matches!(s, Stmt::Empty))
1273 .map(|s| s.kind_name().to_string())
1274 .collect();
1275 let plans = plan_program(source).expect("the fixture parses");
1276 assert_eq!(
1277 plans.iter().map(|p| p.plan.statement_kind.clone()).collect::<Vec<_>>(),
1278 expected,
1279 "every non-empty statement is planned, in source order"
1280 );
1281 for (position, planned) in plans.iter().enumerate() {
1282 assert_eq!(
1283 planned.index, position,
1284 "index must be the position in the returned list"
1285 );
1286 }
1287 }
1288
1289 #[test]
1293 fn a_leading_comment_does_not_shift_the_indexes() {
1294 let plans = plan_program("# lead\necho a\necho b").expect("parses");
1295 assert_eq!(plans.len(), 2);
1296 assert_eq!(plans[0].index, 0, "a leading comment must not shift index");
1297 assert_eq!(plans[1].index, 1);
1298 }
1299
1300 #[test]
1301 fn plan_program_redacts_a_presented_key_and_returns_no_copy_of_it() {
1302 let plans = plan_program("rm --confirm=deadbeef x.txt").expect("parses");
1305 assert_eq!(plans[0].plan.rendered, "rm --confirm=<confirm-key> x.txt");
1306 }
1307
1308 #[test]
1309 fn plan_program_returns_the_parse_errors_for_a_broken_source() {
1310 let errors = plan_program("echo 'unclosed").expect_err("must not parse");
1311 assert!(!errors.is_empty());
1312 }
1313
1314 #[test]
1315 fn redaction_covers_both_spellings_across_a_multi_statement_source() {
1316 let source = "echo one\ndd if=a confirm=deadbeef\nrm --confirm=deadbeef x";
1317 let redacted = redact_keys(source, &["deadbeef".to_string()]);
1318 assert!(!redacted.contains("deadbeef"), "got: {redacted}");
1319 assert_eq!(redacted, "echo one\ndd if=a\nrm x");
1320 }
1321}