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