1use std::collections::BTreeSet;
19
20use kaish_types::plan::{
21 Plan, PlannedCommand, PlannedHeredoc, PlannedRedirect, PlannedValue, PLAN_RENDER_LIMIT,
22};
23use kaish_types::Value;
24
25use super::types::{
26 Arg, Assignment, BinaryOp, CaseStmt, Command, Expr, ForLoop, IfStmt, ListElem, Pipeline,
27 PipelineStage, RecordKey, Redirect, RedirectKind, Stmt, StringPart, TestExpr, ToolDef, VarPath,
28 VarSegment,
29 WhileLoop,
30};
31
32pub const KAISH_VERSION: &str = env!("CARGO_PKG_VERSION");
47
48pub const KAISH_GIT_HASH: &str = match option_env!("KAISH_GIT_HASH") {
54 Some(hash) => hash,
55 None => "unknown",
56};
57
58pub const KAISH_BUILD_DATE: &str = match option_env!("KAISH_BUILD_DATE") {
64 Some(date) => date,
65 None => "unknown",
66};
67
68pub struct StatementPlan {
70 pub plan: Plan,
72}
73
74#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
80pub struct PlannedStatement {
81 pub index: usize,
85 pub plan: Plan,
87}
88
89pub fn plan_program(
110 source: &str,
111) -> Result<Vec<PlannedStatement>, Vec<crate::parser::ParseError>> {
112 let program = crate::parser::parse(source)?;
113 Ok(program
114 .statements
115 .iter()
116 .filter(|stmt| !matches!(stmt, Stmt::Empty))
121 .enumerate()
122 .map(|(index, stmt)| PlannedStatement {
123 index,
124 plan: plan_statement(stmt).plan,
125 })
126 .collect())
127}
128
129pub(crate) fn plan_statement(stmt: &Stmt) -> StatementPlan {
131 let collected = collect(stmt);
132 let free: Vec<String> = collected
137 .reads
138 .difference(&collected.binds)
139 .cloned()
140 .collect();
141 let bound: Vec<String> = collected.binds.into_iter().collect();
142 StatementPlan {
143 plan: Plan::new(
144 truncate_rendering(render_stmt(stmt)),
145 stmt.kind_name(),
146 collected.commands,
147 )
148 .with_variables(free, bound),
149 }
150}
151
152fn truncate_rendering(rendered: String) -> String {
159 if rendered.len() <= PLAN_RENDER_LIMIT {
160 return rendered;
161 }
162 let mut cut = PLAN_RENDER_LIMIT;
164 while cut > 0 && !rendered.is_char_boundary(cut) {
165 cut -= 1;
166 }
167 let mut out = rendered[..cut].to_string();
168 out.push_str(&format!(
169 "… [rendering truncated at {PLAN_RENDER_LIMIT} bytes]"
170 ));
171 out
172}
173
174#[derive(Default)]
187struct Collected {
188 commands: Vec<PlannedCommand>,
189 reads: BTreeSet<String>,
194 binds: BTreeSet<String>,
197 heredoc_targets: Vec<Expr>,
213}
214
215impl Collected {
216 fn take_heredocs(&mut self, cmd: &Command) -> Vec<PlannedHeredoc> {
219 cmd.redirects
220 .iter()
221 .filter_map(|r| match &r.kind {
222 RedirectKind::HereDoc(meta) => Some((meta, &r.target)),
223 _ => None,
224 })
225 .map(|(meta, target)| {
226 let index = self.heredoc_targets.len();
227 self.heredoc_targets.push(target.clone());
228 let free = if meta.literal {
233 Vec::new()
234 } else {
235 let mut body_reads = Collected::default();
236 collect_expr(target, false, &mut body_reads);
237 body_reads.reads.into_iter().collect()
238 };
239 PlannedHeredoc::new(
240 index,
241 meta.delimiter.clone(),
242 meta.literal,
243 meta.strip_tabs,
244 PlannedValue::Plain(meta.body.clone()),
245 meta.body_offset,
246 )
247 .with_free_variables(free)
248 })
249 .collect()
250 }
251
252 fn read_path(&mut self, path: &VarPath) {
255 for (i, segment) in path.segments.iter().enumerate() {
256 match segment {
257 VarSegment::Field(name) if i == 0 => {
258 self.reads.insert(name.clone());
259 }
260 VarSegment::Dynamic(v) => {
261 self.reads.insert(v.clone());
262 }
263 _ => {}
264 }
265 }
266 }
267
268 fn bind_path(&mut self, path: &VarPath) {
271 if let Some(VarSegment::Field(name)) = path.segments.first() {
272 self.binds.insert(name.clone());
273 }
274 for segment in path.segments.iter().skip(1) {
275 if let VarSegment::Dynamic(v) = segment {
276 self.reads.insert(v.clone());
277 }
278 }
279 }
280
281 fn read_arithmetic(&mut self, expr: &str, background: bool) {
301 if let Ok(parsed) = crate::arithmetic::parse(expr) {
302 self.read_arith_expr(&parsed, background);
303 }
304 }
305
306 fn read_arith_expr(&mut self, expr: &crate::arithmetic::ArithExpr, background: bool) {
307 use crate::arithmetic::ArithExpr;
308 match expr {
309 ArithExpr::Int(_) => {}
310 ArithExpr::Expansion(e) => self.read_arith_expansion(e, background),
311 ArithExpr::Subscript { root, indices } => {
312 self.reads.insert(root.clone());
313 for index in indices {
314 self.read_arith_expr(index, background);
315 }
316 }
317 ArithExpr::BasedExpansion { expansion, .. } => {
318 self.read_arith_expansion(expansion, background)
319 }
320 ArithExpr::Unary { operand, .. } => self.read_arith_expr(operand, background),
321 ArithExpr::Binary { left, right, .. } => {
322 self.read_arith_expr(left, background);
323 self.read_arith_expr(right, background);
324 }
325 ArithExpr::Ternary { cond, then_branch, else_branch } => {
326 self.read_arith_expr(cond, background);
327 self.read_arith_expr(then_branch, background);
328 self.read_arith_expr(else_branch, background);
329 }
330 }
331 }
332
333 fn read_arith_expansion(&mut self, e: &crate::arithmetic::Expansion, background: bool) {
334 use crate::arithmetic::Expansion;
335 match e {
336 Expansion::Var(name) => {
340 if name.parse::<usize>().is_err() {
341 self.reads.insert(name.clone());
342 }
343 }
344 Expansion::BracedPath { root, brackets } => {
345 let raw = format!("${{{root}{brackets}}}");
346 self.read_path(&crate::parser::parse_varpath(&raw));
347 }
348 Expansion::BracedDefault { root, brackets, default } => {
349 let raw = format!("${{{root}{brackets}}}");
350 self.read_path(&crate::parser::parse_varpath(&raw));
351 if let Ok(parsed) = crate::arithmetic::parse(default) {
352 self.read_arith_expr(&parsed, background);
353 }
354 }
355 Expansion::LastExitCode | Expansion::CurrentPid => {}
356 Expansion::CommandSubst(stmts) => collect_block(stmts, background, self),
363 Expansion::Nested(inner) => self.read_arith_expr(inner, background),
364 }
365 }
366}
367
368fn collect(stmt: &Stmt) -> Collected {
375 let mut out = Collected::default();
376 collect_stmt(stmt, false, &mut out);
377 out
378}
379
380pub(crate) fn heredoc_targets(stmt: &Stmt) -> Vec<Expr> {
387 collect(stmt).heredoc_targets
388}
389
390pub fn planned_commands(stmt: &Stmt) -> Vec<PlannedCommand> {
392 collect(stmt).commands
393}
394
395fn collect_stmt(stmt: &Stmt, background: bool, out: &mut Collected) {
396 match stmt {
397 Stmt::Assignment(a) => {
398 out.bind_path(&a.path);
399 collect_expr(&a.value, background, out)
400 }
401 Stmt::Command(cmd) => collect_command(cmd, background, out),
402 Stmt::Pipeline(p) => {
403 for stage in &p.stages {
404 match stage {
405 PipelineStage::Command(cmd) => {
406 collect_command(cmd, background || p.background, out)
407 }
408 PipelineStage::Compound(stmt) => {
411 collect_stmt(stmt, background || p.background, out)
412 }
413 }
414 }
415 }
416 Stmt::If(s) => {
417 collect_expr(&s.condition, background, out);
418 collect_block(&s.then_branch, background, out);
419 if let Some(else_branch) = &s.else_branch {
420 collect_block(else_branch, background, out);
421 }
422 }
423 Stmt::For(s) => {
424 out.binds.insert(s.variable.clone());
425 for item in &s.items {
426 collect_expr(item, background, out);
427 }
428 collect_block(&s.body, background, out);
429 }
430 Stmt::While(s) => {
431 collect_expr(&s.condition, background, out);
432 collect_block(&s.body, background, out);
433 }
434 Stmt::Case(s) => {
435 collect_expr(&s.expr, background, out);
436 for branch in &s.branches {
437 collect_block(&branch.body, background, out);
438 }
439 }
440 Stmt::Return(e) | Stmt::Exit(e) => {
441 if let Some(e) = e {
442 collect_expr(e, background, out);
443 }
444 }
445 Stmt::ToolDef(def) => {
446 for param in &def.params {
447 out.binds.insert(param.name.clone());
448 if let Some(default) = ¶m.default {
449 collect_expr(default, background, out);
450 }
451 }
452 collect_block(&def.body, background, out)
453 }
454 Stmt::Test(t) => collect_test(t, background, out),
455 Stmt::Arith(expr) => out.read_arithmetic(expr, background),
459 Stmt::AndChain { left, right } | Stmt::OrChain { left, right } => {
460 collect_stmt(left, background, out);
461 collect_stmt(right, background, out);
462 }
463 Stmt::EnvScoped { assignments, body } => {
464 for a in assignments {
465 out.bind_path(&a.path);
466 collect_expr(&a.value, background, out);
467 }
468 collect_stmt(body, background, out);
469 }
470 Stmt::Break(_) | Stmt::Continue(_) | Stmt::Empty => {}
471 }
472}
473
474fn collect_block(stmts: &[Stmt], background: bool, out: &mut Collected) {
475 for stmt in stmts {
476 collect_stmt(stmt, background, out);
477 }
478}
479
480fn collect_command(cmd: &Command, background: bool, out: &mut Collected) {
481 let args: Vec<PlannedValue> = cmd.args.iter().map(|arg| plan_arg(arg).1).collect();
482 let redirects = cmd
483 .redirects
484 .iter()
485 .map(|r| PlannedRedirect::new(r.kind.to_string(), plan_redirect_target(r)))
486 .collect();
487 let heredocs = out.take_heredocs(cmd);
488 out.commands.push(
489 PlannedCommand::new(cmd.name.clone(), args, redirects, background)
490 .with_heredocs(heredocs),
491 );
492 for arg in &cmd.args {
495 match arg {
496 Arg::Positional(e) => collect_expr(e, background, out),
497 Arg::Named { value, .. } | Arg::WordAssign { value, .. } => {
498 collect_expr(value, background, out)
499 }
500 Arg::ShortFlag(_) | Arg::LongFlag(_) | Arg::DoubleDash => {}
501 }
502 }
503 for redirect in &cmd.redirects {
504 collect_expr(&redirect.target, background, out);
505 }
506}
507
508fn collect_expr(expr: &Expr, background: bool, out: &mut Collected) {
509 match expr {
510 Expr::Command(cmd) => collect_command(cmd, background, out),
511 Expr::CommandSubst(stmts) => collect_block(stmts, background, out),
512 Expr::Not(inner) => collect_expr(inner, background, out),
514 Expr::BinaryOp { left, right, .. } => {
515 collect_expr(left, background, out);
516 collect_expr(right, background, out);
517 }
518 Expr::Interpolated(parts) => collect_parts(parts, background, out),
519 Expr::HereDocBody { parts, .. } => {
520 for part in parts {
521 collect_part(&part.part, background, out);
522 }
523 }
524 Expr::Test(t) => collect_test(t, background, out),
525 Expr::VarWithDefault { path, default } => {
526 out.read_path(path);
527 collect_parts(default, background, out)
528 }
529 Expr::ListLiteral(elems) => {
530 for elem in elems {
531 match elem {
532 ListElem::Item(e) | ListElem::Spread(e) => collect_expr(e, background, out),
533 }
534 }
535 }
536 Expr::RecordLiteral(entries) => {
537 for entry in entries {
538 if let RecordKey::Interpolated(parts) = &entry.key {
539 collect_parts(parts, background, out);
540 }
541 collect_expr(&entry.value, background, out);
542 }
543 }
544 Expr::VarRef(path) | Expr::VarLength(path) => out.read_path(path),
545 Expr::Arithmetic(e) => out.read_arithmetic(e, background),
546 Expr::Arith(e) => out.read_arithmetic(e, background),
547 Expr::Literal(_)
550 | Expr::NumericLiteral { .. }
551 | Expr::Positional(_)
552 | Expr::AllArgs
553 | Expr::ArgCount
554 | Expr::LastExitCode
555 | Expr::CurrentPid
556 | Expr::GlobPattern(_) => {}
557 }
558}
559
560fn collect_parts(parts: &[StringPart], background: bool, out: &mut Collected) {
561 for part in parts {
562 collect_part(part, background, out);
563 }
564}
565
566fn collect_part(part: &StringPart, background: bool, out: &mut Collected) {
567 match part {
568 StringPart::CommandSubst(stmts) => collect_block(stmts, background, out),
569 StringPart::VarWithDefault { path, default } => {
570 out.read_path(path);
571 collect_parts(default, background, out)
572 }
573 StringPart::Var(path) | StringPart::VarLength(path) => out.read_path(path),
574 StringPart::Arithmetic(e) => out.read_arithmetic(e, background),
575 StringPart::Literal(_)
577 | StringPart::Positional(_)
578 | StringPart::AllArgs
579 | StringPart::ArgCount
580 | StringPart::LastExitCode
581 | StringPart::CurrentPid => {}
582 }
583}
584
585fn collect_test(test: &TestExpr, background: bool, out: &mut Collected) {
586 match test {
587 TestExpr::FileTest { path, .. } => collect_expr(path, background, out),
588 TestExpr::StringTest { value, .. } => collect_expr(value, background, out),
589 TestExpr::Comparison { left, right, .. }
590 | TestExpr::In { left, right }
591 | TestExpr::NotIn { left, right } => {
592 collect_expr(left, background, out);
593 collect_expr(right, background, out);
594 }
595 TestExpr::And { left, right } | TestExpr::Or { left, right } => {
596 collect_test(left, background, out);
597 collect_test(right, background, out);
598 }
599 TestExpr::Not { expr } => collect_test(expr, background, out),
600 }
601}
602
603pub(crate) fn render_stmt(stmt: &Stmt) -> String {
607 match stmt {
608 Stmt::Assignment(a) => render_assignment(a),
609 Stmt::Command(cmd) => render_command(cmd),
610 Stmt::Pipeline(p) => render_pipeline(p),
611 Stmt::If(s) => render_if(s),
612 Stmt::For(s) => render_for(s),
613 Stmt::While(s) => render_while(s),
614 Stmt::Case(s) => render_case(s),
615 Stmt::Break(n) => render_keyword("break", n.map(|n| n.to_string())),
616 Stmt::Continue(n) => render_keyword("continue", n.map(|n| n.to_string())),
617 Stmt::Return(e) => render_keyword("return", e.as_ref().map(|e| render_expr(e))),
618 Stmt::Exit(e) => render_keyword("exit", e.as_ref().map(|e| render_expr(e))),
619 Stmt::ToolDef(def) => render_tooldef(def),
620 Stmt::Test(t) => format!("[[ {} ]]", render_test(t)),
621 Stmt::Arith(e) => format!("(({e}))"),
622 Stmt::AndChain { left, right } => {
623 format!("{} && {}", render_stmt(left), render_stmt(right))
624 }
625 Stmt::OrChain { left, right } => {
626 format!("{} || {}", render_stmt(left), render_stmt(right))
627 }
628 Stmt::EnvScoped { assignments, body } => {
629 let prefix: Vec<String> = assignments.iter().map(render_assignment).collect();
630 format!("{} {}", prefix.join(" "), render_stmt(body))
631 }
632 Stmt::Empty => String::new(),
633 }
634}
635
636fn render_keyword(word: &str, operand: Option<String>) -> String {
637 match operand {
638 Some(operand) => format!("{word} {operand}"),
639 None => word.to_string(),
640 }
641}
642
643fn render_block(stmts: &[Stmt]) -> String {
644 stmts
645 .iter()
646 .filter(|s| !matches!(s, Stmt::Empty))
647 .map(render_stmt)
648 .collect::<Vec<_>>()
649 .join("; ")
650}
651
652fn render_assignment(a: &Assignment) -> String {
653 let path = render_varpath(&a.path);
654 if a.local {
655 format!("local {} = {}", path, render_expr(&a.value))
656 } else {
657 format!("{}={}", path, render_expr(&a.value))
658 }
659}
660
661pub(crate) fn render_command(cmd: &Command) -> String {
663 let mut parts = vec![cmd.name.clone()];
664 for arg in &cmd.args {
665 parts.push(plan_arg(arg).0);
666 }
667 for redirect in &cmd.redirects {
668 parts.push(render_redirect(redirect));
669 }
670 parts.join(" ")
671}
672
673fn plan_arg(arg: &Arg) -> (String, PlannedValue) {
677 let text = match arg {
678 Arg::Positional(e) => render_expr(e),
679 Arg::Named { key, value } => format!("--{key}={}", render_expr(value)),
680 Arg::WordAssign { key, value } => format!("{key}={}", render_expr(value)),
681 Arg::ShortFlag(f) => format!("-{f}"),
682 Arg::LongFlag(f) => format!("--{f}"),
683 Arg::DoubleDash => "--".to_string(),
684 };
685 (text.clone(), PlannedValue::Plain(text))
686}
687
688fn plan_redirect_target(redirect: &Redirect) -> PlannedValue {
698 match &redirect.kind {
699 RedirectKind::HereDoc(meta) => {
700 let quote = if meta.literal { "'" } else { "" };
701 PlannedValue::Plain(format!("{quote}{}{quote}", meta.delimiter))
702 }
703 _ => PlannedValue::Plain(render_expr(&redirect.target)),
704 }
705}
706
707fn render_redirect(redirect: &Redirect) -> String {
708 match &redirect.kind {
711 RedirectKind::MergeStderr | RedirectKind::MergeStdout => redirect.kind.to_string(),
712 RedirectKind::HereDoc(meta) => {
717 let dash = if meta.strip_tabs { "-" } else { "" };
718 let quote = if meta.literal { "'" } else { "" };
719 format!(
720 "<<{dash}{quote}{delim}{quote}\n{body}{delim}",
721 delim = meta.delimiter,
722 body = meta.body,
723 )
724 }
725 _ => format!(
726 "{} {}",
727 redirect.kind,
728 plan_redirect_target(redirect).display()
729 ),
730 }
731}
732
733fn render_pipeline(p: &Pipeline) -> String {
734 let body = p
735 .stages
736 .iter()
737 .map(|stage| match stage {
738 PipelineStage::Command(cmd) => render_command(cmd),
739 PipelineStage::Compound(stmt) => render_stmt(stmt),
740 })
741 .collect::<Vec<_>>()
742 .join(" | ");
743 if p.background {
744 format!("{body} &")
745 } else {
746 body
747 }
748}
749
750fn render_if(s: &IfStmt) -> String {
751 let mut out = format!(
752 "if {}; then {}",
753 render_expr(&s.condition),
754 render_block(&s.then_branch)
755 );
756 if let Some(else_branch) = &s.else_branch {
757 let rendered = render_block(else_branch);
758 if !rendered.is_empty() {
759 out.push_str(&format!("; else {rendered}"));
760 }
761 }
762 out.push_str("; fi");
763 out
764}
765
766fn render_for(s: &ForLoop) -> String {
767 let items: Vec<String> = s.items.iter().map(render_expr).collect();
768 format!(
769 "for {} in {}; do {}; done",
770 s.variable,
771 items.join(" "),
772 render_block(&s.body)
773 )
774}
775
776fn render_while(s: &WhileLoop) -> String {
777 format!(
778 "while {}; do {}; done",
779 render_expr(&s.condition),
780 render_block(&s.body)
781 )
782}
783
784fn render_case(s: &CaseStmt) -> String {
785 let branches: Vec<String> = s
786 .branches
787 .iter()
788 .map(|b| format!("{}) {} ;;", b.patterns.join("|"), render_block(&b.body)))
789 .collect();
790 format!("case {} in {} esac", render_expr(&s.expr), branches.join(" "))
791}
792
793fn render_tooldef(def: &ToolDef) -> String {
794 let params: Vec<String> = def
795 .params
796 .iter()
797 .map(|p| match &p.default {
798 Some(default) => format!("{}={}", p.name, render_expr(default)),
799 None => p.name.clone(),
800 })
801 .collect();
802 format!(
803 "tool {}({}) {{ {} }}",
804 def.name,
805 params.join(", "),
806 render_block(&def.body)
807 )
808}
809
810pub(crate) fn render_expr(expr: &Expr) -> String {
813 match expr {
814 Expr::Not(inner) => format!("! {}", render_expr(inner)),
815 Expr::Literal(v) => render_literal(v),
816 Expr::VarRef(path) => format!("${{{}}}", render_varpath(path)),
817 Expr::Interpolated(parts) => format!("\"{}\"", render_parts(parts)),
818 Expr::HereDocBody { parts, strip_tabs } => {
819 let dash = if *strip_tabs { "-" } else { "" };
820 let body: Vec<String> = parts.iter().map(|sp| render_part(&sp.part)).collect();
821 format!("<<{dash}EOF\n{}\nEOF", body.join(""))
822 }
823 Expr::BinaryOp { left, op, right } => {
824 let op = match op {
825 BinaryOp::And => "&&",
826 BinaryOp::Or => "||",
827 };
828 format!("{} {} {}", render_expr(left), op, render_expr(right))
829 }
830 Expr::CommandSubst(stmts) => format!("$({})", render_block(stmts)),
831 Expr::Test(t) => format!("[[ {} ]]", render_test(t)),
832 Expr::Positional(n) => format!("${n}"),
833 Expr::AllArgs => "$@".to_string(),
834 Expr::ArgCount => "$#".to_string(),
835 Expr::VarLength(path) => format!("${{#{}}}", render_varpath(path)),
836 Expr::VarWithDefault { path, default } => {
837 format!("${{{}:-{}}}", render_varpath(path), render_parts(default))
838 }
839 Expr::Arithmetic(e) => format!("$(({e}))"),
840 Expr::Arith(e) => format!("(({e}))"),
841 Expr::NumericLiteral { raw, .. } => raw.clone(),
844 Expr::Command(cmd) => render_command(cmd),
845 Expr::LastExitCode => "$?".to_string(),
846 Expr::CurrentPid => "$$".to_string(),
847 Expr::GlobPattern(p) => p.clone(),
848 Expr::ListLiteral(elems) => {
849 let parts: Vec<String> = elems
850 .iter()
851 .map(|e| match e {
852 ListElem::Item(e) => render_expr(e),
853 ListElem::Spread(e) => format!("...{}", render_expr(e)),
854 })
855 .collect();
856 format!("[{}]", parts.join(" "))
857 }
858 Expr::RecordLiteral(entries) => {
859 let parts: Vec<String> = entries
860 .iter()
861 .map(|entry| {
862 let key = match &entry.key {
863 RecordKey::Bare(k) => k.clone(),
864 RecordKey::Quoted(k) => format!("\"{k}\""),
865 RecordKey::Interpolated(parts) => format!("\"{}\"", render_parts(parts)),
866 };
867 format!("{key}: {}", render_expr(&entry.value))
868 })
869 .collect();
870 format!("{{{}}}", parts.join(", "))
871 }
872 }
873}
874
875fn render_literal(value: &Value) -> String {
877 match value {
878 Value::String(s) => quote_word(s),
879 Value::Int(i) => i.to_string(),
880 Value::Float(f) => f.to_string(),
881 Value::Bool(b) => b.to_string(),
882 Value::Null => "null".to_string(),
883 Value::Json(j) => j.to_string(),
884 Value::Bytes(b) => format!("<bytes len={}>", b.len()),
888 }
889}
890
891fn quote_word(s: &str) -> String {
893 let needs_quotes = s.is_empty()
894 || s.chars()
895 .any(|c| c.is_whitespace() || "\"'$`&|;<>(){}[]*?#!~\\".contains(c));
896 if !needs_quotes {
897 return s.to_string();
898 }
899 format!("'{}'", s.replace('\'', "'\\''"))
902}
903
904fn render_parts(parts: &[StringPart]) -> String {
905 parts.iter().map(render_part).collect::<Vec<_>>().join("")
906}
907
908fn render_part(part: &StringPart) -> String {
909 match part {
910 StringPart::Literal(s) => s.replace('\\', "\\\\").replace('"', "\\\""),
911 StringPart::Var(path) => format!("${{{}}}", render_varpath(path)),
912 StringPart::VarWithDefault { path, default } => {
913 format!("${{{}:-{}}}", render_varpath(path), render_parts(default))
914 }
915 StringPart::VarLength(path) => format!("${{#{}}}", render_varpath(path)),
916 StringPart::Positional(n) => format!("${n}"),
917 StringPart::AllArgs => "$@".to_string(),
918 StringPart::ArgCount => "$#".to_string(),
919 StringPart::Arithmetic(e) => format!("$(({e}))"),
920 StringPart::CommandSubst(stmts) => format!("$({})", render_block(stmts)),
921 StringPart::LastExitCode => "$?".to_string(),
922 StringPart::CurrentPid => "$$".to_string(),
923 }
924}
925
926fn render_test(test: &TestExpr) -> String {
927 match test {
928 TestExpr::FileTest { op, path } => format!("{} {}", op, render_expr(path)),
929 TestExpr::StringTest { op, value } => format!("{} {}", op, render_expr(value)),
930 TestExpr::Comparison { left, op, right } => {
931 format!("{} {} {}", render_expr(left), op, render_expr(right))
932 }
933 TestExpr::And { left, right } => {
934 format!("{} && {}", render_test(left), render_test(right))
935 }
936 TestExpr::Or { left, right } => {
937 format!("{} || {}", render_test(left), render_test(right))
938 }
939 TestExpr::Not { expr } => format!("! {}", render_test(expr)),
940 TestExpr::In { left, right } => {
941 format!("{} in {}", render_expr(left), render_expr(right))
942 }
943 TestExpr::NotIn { left, right } => {
944 format!("{} not in {}", render_expr(left), render_expr(right))
945 }
946 }
947}
948
949fn render_varpath(path: &VarPath) -> String {
952 let mut out = String::new();
953 for (i, segment) in path.segments.iter().enumerate() {
954 match segment {
955 VarSegment::Field(name) => {
956 if i > 0 {
957 out.push('.');
958 }
959 out.push_str(name);
960 }
961 VarSegment::Index(idx) => out.push_str(&format!("[{idx}]")),
962 VarSegment::Key(k) => out.push_str(&format!("[{k}]")),
963 VarSegment::Dynamic(v) => out.push_str(&format!("[${v}]")),
964 VarSegment::Slice(a, b) => out.push_str(&format!(
965 "[{}:{}]",
966 a.map(|n| n.to_string()).unwrap_or_default(),
967 b.map(|n| n.to_string()).unwrap_or_default()
968 )),
969 }
970 }
971 out
972}
973
974#[cfg(test)]
975#[allow(clippy::unwrap_used, clippy::expect_used)]
976mod tests {
977 use super::*;
978 use crate::parser::parse;
979
980 fn planned_of(source: &str) -> StatementPlan {
981 let program = parse(source).expect("the fixture parses");
982 let stmt = program
983 .statements
984 .into_iter()
985 .find(|s| !matches!(s, Stmt::Empty))
986 .expect("one statement");
987 plan_statement(&stmt)
988 }
989
990 fn plan_of(source: &str) -> Plan {
991 planned_of(source).plan
992 }
993
994 #[test]
995 fn a_variable_renders_unexpanded() {
996 let plan = plan_of("rm -r \"${HOME}/build\"");
997 assert!(
998 plan.rendered.contains("${HOME}"),
999 "the plan must keep the variable as written: {}",
1000 plan.rendered
1001 );
1002 }
1003
1004 #[test]
1005 fn a_substitution_renders_unexpanded_and_plans_its_own_command() {
1006 let plan = plan_of("rm $(cat list.txt)");
1007 assert!(
1008 plan.rendered.contains("$(cat list.txt)"),
1009 "got: {}",
1010 plan.rendered
1011 );
1012 let names: Vec<&str> = plan.commands.iter().map(|c| c.name.as_str()).collect();
1013 assert_eq!(names, vec!["rm", "cat"], "the substitution runs too");
1014 }
1015
1016 #[test]
1017 fn a_loop_body_belongs_to_the_enclosing_statement() {
1018 let plan = plan_of("for f in a b; do rm $f; done");
1019 assert_eq!(plan.statement_kind, "for");
1020 let names: Vec<&str> = plan.commands.iter().map(|c| c.name.as_str()).collect();
1021 assert_eq!(names, vec!["rm"]);
1022 assert!(plan.rendered.starts_with("for f in a b; do rm"));
1023 }
1024
1025 #[test]
1026 fn every_redirect_form_renders() {
1027 let plan = plan_of("cmd > out.txt 2> err.txt < in.txt");
1028 let kinds: Vec<&str> = plan.commands[0]
1029 .redirects
1030 .iter()
1031 .map(|r| r.kind.as_str())
1032 .collect();
1033 assert_eq!(kinds, vec![">", "2>", "<"]);
1034 assert_eq!(
1035 plan.commands[0].redirects[0].target,
1036 PlannedValue::Plain("out.txt".to_string())
1037 );
1038 assert!(plan.rendered.contains("> out.txt"), "got: {}", plan.rendered);
1039 }
1040
1041 #[test]
1042 fn a_redirect_target_stays_unexpanded() {
1043 let plan = plan_of("echo hi > ${LOG}");
1044 assert_eq!(
1045 plan.commands[0].redirects[0].target,
1046 PlannedValue::Plain("${LOG}".to_string())
1047 );
1048 }
1049
1050 #[test]
1051 fn a_merge_redirect_renders_as_its_operator_alone() {
1052 let plan = plan_of("cmd 2>&1");
1053 assert!(
1054 plan.rendered.ends_with("2>&1"),
1055 "a merge redirect has no filename: {}",
1056 plan.rendered
1057 );
1058 }
1059
1060 #[test]
1061 fn every_argument_form_renders() {
1062 let plan = plan_of("tool -v --force --key=value word -- --after");
1063 let args = &plan.commands[0].args;
1064 assert_eq!(
1065 args,
1066 &vec![
1067 PlannedValue::Plain("-v".to_string()),
1068 PlannedValue::Plain("--force".to_string()),
1069 PlannedValue::Plain("--key=value".to_string()),
1070 PlannedValue::Plain("word".to_string()),
1071 PlannedValue::Plain("--".to_string()),
1072 PlannedValue::Plain("--after".to_string()),
1073 ]
1074 );
1075 }
1076
1077 #[test]
1078 fn a_backgrounded_pipeline_marks_every_command() {
1079 let plan = plan_of("a | b &");
1080 assert!(plan.commands.iter().all(|c| c.background));
1081 assert!(plan.rendered.ends_with('&'), "got: {}", plan.rendered);
1082 }
1083
1084 #[test]
1085 fn a_pipeline_renders_every_stage() {
1086 let plan = plan_of("cat f | grep x | wc -l");
1087 let names: Vec<&str> = plan.commands.iter().map(|c| c.name.as_str()).collect();
1088 assert_eq!(names, vec!["cat", "grep", "wc"]);
1089 assert_eq!(plan.rendered, "cat f | grep x | wc -l");
1090 }
1091
1092 #[test]
1093 fn an_and_chain_plans_both_sides() {
1094 let plan = plan_of("mkdir d && rm -r d");
1095 assert_eq!(plan.statement_kind, "and_chain");
1096 let names: Vec<&str> = plan.commands.iter().map(|c| c.name.as_str()).collect();
1097 assert_eq!(names, vec!["mkdir", "rm"]);
1098 }
1099
1100 #[test]
1101 fn an_if_plans_its_condition_and_both_branches() {
1102 let plan = plan_of("if grep -q x f; then echo hit; else echo miss; fi");
1103 let names: Vec<&str> = plan.commands.iter().map(|c| c.name.as_str()).collect();
1104 assert_eq!(names, vec!["grep", "echo", "echo"]);
1105 }
1106
1107 #[test]
1108 fn a_quoted_word_keeps_its_spaces_inside_quotes() {
1109 let plan = plan_of("echo 'two words'");
1110 assert_eq!(plan.rendered, "echo 'two words'");
1111 }
1112
1113 #[test]
1114 fn an_interpolated_string_keeps_its_variables() {
1115 let plan = plan_of("echo \"hello ${NAME}\"");
1116 assert_eq!(plan.rendered, "echo \"hello ${NAME}\"");
1117 }
1118
1119 #[test]
1120 fn a_bracket_path_renders_with_brackets_not_dots() {
1121 let plan = plan_of("echo ${servers[web]}");
1122 assert_eq!(plan.rendered, "echo ${servers[web]}");
1123 }
1124
1125 #[test]
1126 fn rendering_truncates_at_the_limit_with_a_loud_marker() {
1127 let long = "x".repeat(PLAN_RENDER_LIMIT * 2);
1128 let plan = plan_of(&format!("echo {long}"));
1129 assert!(
1130 plan.rendered.contains("[rendering truncated at 8192 bytes]"),
1131 "expected the marker, got {} bytes ending in {:?}",
1132 plan.rendered.len(),
1133 &plan.rendered[plan.rendered.len().saturating_sub(48)..]
1134 );
1135 assert_eq!(plan.commands.len(), 1);
1137 assert_eq!(plan.commands[0].name, "echo");
1138 }
1139
1140 #[test]
1141 fn a_short_rendering_carries_no_marker() {
1142 let plan = plan_of("echo hi");
1143 assert_eq!(plan.rendered, "echo hi");
1144 }
1145
1146 #[test]
1155 fn confirm_renders_as_an_ordinary_argument() {
1156 let plan = plan_of("rm --confirm=deadbeef target.txt");
1157 assert_eq!(plan.rendered, "rm --confirm=deadbeef target.txt");
1158 assert_eq!(
1159 plan.commands[0].args,
1160 vec![
1161 PlannedValue::Plain("--confirm=deadbeef".to_string()),
1162 PlannedValue::Plain("target.txt".to_string()),
1163 ]
1164 );
1165 }
1166
1167 #[test]
1168 fn a_confirm_token_renders_the_same_direct_and_through_arithmetic() {
1169 let direct = plan_of("echo $(rm --confirm=secret x)");
1175 let via_arith = plan_of("echo $((1 + $(rm --confirm=secret x)))");
1176 assert_eq!(direct.rendered, "echo $(rm --confirm=secret x)");
1177 assert_eq!(
1178 via_arith.rendered,
1179 "echo $((1 + $(rm --confirm=secret x)))"
1180 );
1181 assert!(direct.rendered.contains("--confirm=secret"));
1182 assert!(via_arith.rendered.contains("--confirm=secret"));
1183 }
1184
1185 #[test]
1188 fn reads_cover_interpolation_length_subscript_and_arithmetic() {
1189 let plan = plan_of(
1190 "echo \"${greeting} ${#items} ${servers[$env]}\" $((base + offset))",
1191 );
1192 assert_eq!(
1193 plan.free_variables,
1194 vec!["base", "env", "greeting", "items", "offset", "servers"],
1195 "every lexical read is listed, sorted"
1196 );
1197 assert!(plan.bound_variables.is_empty());
1198 }
1199
1200 #[test]
1209 fn a_based_literal_reads_nothing() {
1210 assert!(plan_of("echo $((16#ff + 1))").free_variables.is_empty());
1211 }
1212
1213 #[test]
1214 fn a_hex_literal_reads_nothing_but_a_real_operand_still_does() {
1215 assert_eq!(plan_of("echo $((0xff + x))").free_variables, vec!["x"]);
1216 }
1217
1218 #[test]
1219 fn based_expansion_reads_the_variable_not_the_base() {
1220 assert_eq!(plan_of("echo $((10#$m % 12))").free_variables, vec!["m"]);
1221 }
1222
1223 #[test]
1224 fn ternary_reads_both_branches_deduped_and_sorted() {
1225 assert_eq!(plan_of("echo $((a > b ? a : b))").free_variables, vec!["a", "b"]);
1226 }
1227
1228 #[test]
1229 fn a_bare_subscript_reads_the_root_and_the_index_variable() {
1230 assert_eq!(plan_of("echo $((xs[i] + 1))").free_variables, vec!["i", "xs"]);
1234 assert_eq!(plan_of("echo ${xs[i]}").free_variables, vec!["xs"]);
1235 }
1236
1237 #[test]
1238 fn last_exit_code_and_pid_are_not_session_variables() {
1239 assert!(plan_of("echo $(($? + $$))").free_variables.is_empty());
1240 }
1241
1242 #[test]
1243 fn random_and_seconds_are_free_variables_like_any_other_name() {
1244 assert_eq!(plan_of("echo $((RANDOM % 10))").free_variables, vec!["RANDOM"]);
1248 }
1249
1250 #[test]
1251 fn command_substitution_inside_arithmetic_contributes_its_own_reads() {
1252 assert_eq!(plan_of("echo $((1 + $(echo $y)))").free_variables, vec!["y"]);
1253 }
1254
1255 #[test]
1256 fn a_bare_arith_condition_reads_like_any_other_arithmetic() {
1257 let plan = plan_of("while (( i <= n )); do :; done");
1258 assert_eq!(plan.free_variables, vec!["i", "n"]);
1259 }
1260
1261 #[test]
1262 fn a_subscripted_assignment_binds_the_root_and_reads_the_subscript() {
1263 let plan = plan_of("counts[$key]=1");
1264 assert_eq!(plan.free_variables, vec!["key"]);
1265 assert_eq!(plan.bound_variables, vec!["counts"]);
1266 }
1267
1268 #[test]
1269 fn an_env_prefix_binds_its_name_for_the_one_command() {
1270 let plan = plan_of("MODE=fast deploy ${TARGET}");
1271 assert_eq!(plan.free_variables, vec!["TARGET"]);
1272 assert_eq!(plan.bound_variables, vec!["MODE"]);
1273 }
1274
1275 #[test]
1287 fn plan_program_indexes_are_dense_and_ordered() {
1288 let source = "echo one\n\n# a comment\necho two && echo three\nX=5";
1289 let program = parse(source).expect("the fixture parses");
1290 let expected: Vec<String> = program
1291 .statements
1292 .iter()
1293 .filter(|s| !matches!(s, Stmt::Empty))
1294 .map(|s| s.kind_name().to_string())
1295 .collect();
1296 let plans = plan_program(source).expect("the fixture parses");
1297 assert_eq!(
1298 plans.iter().map(|p| p.plan.statement_kind.clone()).collect::<Vec<_>>(),
1299 expected,
1300 "every non-empty statement is planned, in source order"
1301 );
1302 for (position, planned) in plans.iter().enumerate() {
1303 assert_eq!(
1304 planned.index, position,
1305 "index must be the position in the returned list"
1306 );
1307 }
1308 }
1309
1310 #[test]
1314 fn a_leading_comment_does_not_shift_the_indexes() {
1315 let plans = plan_program("# lead\necho a\necho b").expect("parses");
1316 assert_eq!(plans.len(), 2);
1317 assert_eq!(plans[0].index, 0, "a leading comment must not shift index");
1318 assert_eq!(plans[1].index, 1);
1319 }
1320
1321 #[test]
1322 fn plan_program_returns_the_parse_errors_for_a_broken_source() {
1323 let errors = plan_program("echo 'unclosed").expect_err("must not parse");
1324 assert!(!errors.is_empty());
1325 }
1326}