1use serde::{Deserialize, Serialize};
28
29use crate::ast::{
30 Command, CommandArgument, CommandNameInner, ListOperator,
31 PrimaryExpression, Program, Redirect, RedirectedStatement, Span,
32 Statement, StringPart,
33};
34
35#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
44#[serde(rename_all = "camelCase")]
45pub struct BashShape {
46 pub primary_program: Option<String>,
50 pub all_programs: Vec<String>,
52 pub list_ops: Vec<String>,
54 pub pipeline_stages: usize,
56 pub redirects: Vec<String>,
58 pub has_heredoc: bool,
60 pub cwd_hint: Option<String>,
63 pub side_effect: Option<SideEffect>,
66 pub kind: Option<ProgramKind>,
71 #[serde(default)]
78 pub top_level_statements: Vec<StatementSlice>,
79}
80
81#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
85#[serde(rename_all = "camelCase")]
86pub struct StatementSlice {
87 pub byte_start: usize,
88 pub byte_end: usize,
89 pub kind: StatementKind,
90}
91
92#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
96pub enum StatementKind {
97 Command,
98 Pipeline,
99 List,
100 Compound,
101 Subshell,
102 If,
103 While,
104 For,
105 CStyleFor,
106 Case,
107 FunctionDefinition,
108 Redirected,
109 Declaration,
110 Unset,
111 Test,
112 Negated,
113 VariableAssignment,
114 VariableAssignments,
115}
116
117#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
120pub enum SideEffect {
121 GitWrite,
124 GitHub,
126 Publish,
128 Destructive,
130 Network,
132 SudoOrInstall,
134}
135
136#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
143pub enum ProgramKind {
144 Search,
147 Vcs,
149 Build,
152 Network,
154 Container,
156 Remote,
158 Elevation,
160 Destructive,
163 ScriptingRuntime,
166 Yah,
168 Mcp,
172}
173
174const PROGRAM_KIND_TABLE: &[(&str, ProgramKind)] = &[
178 ("ls", ProgramKind::Search),
180 ("grep", ProgramKind::Search),
181 ("rg", ProgramKind::Search),
182 ("ag", ProgramKind::Search),
183 ("ack", ProgramKind::Search),
184 ("find", ProgramKind::Search),
185 ("fd", ProgramKind::Search),
186 ("cat", ProgramKind::Search),
187 ("bat", ProgramKind::Search),
188 ("head", ProgramKind::Search),
189 ("tail", ProgramKind::Search),
190 ("less", ProgramKind::Search),
191 ("more", ProgramKind::Search),
192 ("wc", ProgramKind::Search),
193 ("file", ProgramKind::Search),
194 ("stat", ProgramKind::Search),
195 ("du", ProgramKind::Search),
196 ("df", ProgramKind::Search),
197 ("tree", ProgramKind::Search),
198 ("awk", ProgramKind::Search),
199 ("sed", ProgramKind::Search),
200 ("jq", ProgramKind::Search),
201 ("yq", ProgramKind::Search),
202 ("which", ProgramKind::Search),
203 ("whereis", ProgramKind::Search),
204 ("pwd", ProgramKind::Search),
205 ("echo", ProgramKind::Search),
206 ("printf", ProgramKind::Search),
207 ("fzf", ProgramKind::Search),
208 ("ps", ProgramKind::Search),
209 ("top", ProgramKind::Search),
210 ("htop", ProgramKind::Search),
211 ("lsof", ProgramKind::Search),
212 ("diff", ProgramKind::Search),
213 ("git", ProgramKind::Vcs),
215 ("hg", ProgramKind::Vcs),
216 ("svn", ProgramKind::Vcs),
217 ("jj", ProgramKind::Vcs),
218 ("cargo", ProgramKind::Build),
220 ("rustc", ProgramKind::Build),
221 ("rustup", ProgramKind::Build),
222 ("npm", ProgramKind::Build),
223 ("bun", ProgramKind::Build),
224 ("pnpm", ProgramKind::Build),
225 ("yarn", ProgramKind::Build),
226 ("make", ProgramKind::Build),
227 ("cmake", ProgramKind::Build),
228 ("ninja", ProgramKind::Build),
229 ("bazel", ProgramKind::Build),
230 ("tsc", ProgramKind::Build),
231 ("cc", ProgramKind::Build),
232 ("gcc", ProgramKind::Build),
233 ("clang", ProgramKind::Build),
234 ("go", ProgramKind::Build),
235 ("dotnet", ProgramKind::Build),
236 ("swift", ProgramKind::Build),
237 ("maven", ProgramKind::Build),
238 ("gradle", ProgramKind::Build),
239 ("curl", ProgramKind::Network),
241 ("wget", ProgramKind::Network),
242 ("gh", ProgramKind::Network),
243 ("http", ProgramKind::Network),
244 ("httpie", ProgramKind::Network),
245 ("ping", ProgramKind::Network),
246 ("dig", ProgramKind::Network),
247 ("nslookup", ProgramKind::Network),
248 ("host", ProgramKind::Network),
249 ("traceroute", ProgramKind::Network),
250 ("nc", ProgramKind::Network),
251 ("ncat", ProgramKind::Network),
252 ("netcat", ProgramKind::Network),
253 ("openssl", ProgramKind::Network),
254 ("docker", ProgramKind::Container),
256 ("podman", ProgramKind::Container),
257 ("nerdctl", ProgramKind::Container),
258 ("buildah", ProgramKind::Container),
259 ("kubectl", ProgramKind::Container),
260 ("helm", ProgramKind::Container),
261 ("terraform", ProgramKind::Container),
262 ("ansible", ProgramKind::Container),
263 ("ssh", ProgramKind::Remote),
265 ("scp", ProgramKind::Remote),
266 ("rsync", ProgramKind::Remote),
267 ("mosh", ProgramKind::Remote),
268 ("sftp", ProgramKind::Remote),
269 ("sudo", ProgramKind::Elevation),
271 ("su", ProgramKind::Elevation),
272 ("doas", ProgramKind::Elevation),
273 ("rm", ProgramKind::Destructive),
275 ("rmdir", ProgramKind::Destructive),
276 ("mv", ProgramKind::Destructive),
277 ("kill", ProgramKind::Destructive),
278 ("pkill", ProgramKind::Destructive),
279 ("shutdown", ProgramKind::Destructive),
280 ("reboot", ProgramKind::Destructive),
281 ("halt", ProgramKind::Destructive),
282 ("dd", ProgramKind::Destructive),
283 ("shred", ProgramKind::Destructive),
284 ("truncate", ProgramKind::Destructive),
285 ("python", ProgramKind::ScriptingRuntime),
287 ("python3", ProgramKind::ScriptingRuntime),
288 ("node", ProgramKind::ScriptingRuntime),
289 ("deno", ProgramKind::ScriptingRuntime),
290 ("ruby", ProgramKind::ScriptingRuntime),
291 ("perl", ProgramKind::ScriptingRuntime),
292 ("lua", ProgramKind::ScriptingRuntime),
293 ("php", ProgramKind::ScriptingRuntime),
294 ("yah", ProgramKind::Yah),
296];
297
298pub fn kind_for(program: &str) -> Option<ProgramKind> {
313 if let Some(kind) = PROGRAM_KIND_TABLE
314 .iter()
315 .find_map(|(name, kind)| (*name == program).then_some(*kind))
316 {
317 return Some(kind);
318 }
319 if contains_ignore_ascii_case(program, "yah") {
322 return Some(ProgramKind::Yah);
323 }
324 if contains_ignore_ascii_case(program, "mcp") {
325 return Some(ProgramKind::Mcp);
326 }
327 None
328}
329
330fn contains_ignore_ascii_case(haystack: &str, needle: &str) -> bool {
331 if needle.is_empty() || needle.len() > haystack.len() {
332 return haystack.is_empty() == needle.is_empty();
333 }
334 haystack
335 .as_bytes()
336 .windows(needle.len())
337 .any(|w| w.eq_ignore_ascii_case(needle.as_bytes()))
338}
339
340pub fn summarise_bash_shape(program: &Program) -> BashShape {
346 let mut all_programs: Vec<String> = Vec::new();
347 let mut list_ops_set: Vec<String> = Vec::new();
348 let mut pipeline_stages: usize = 1;
349 let mut redirects: Vec<String> = Vec::new();
350 let mut has_heredoc = false;
351
352 for stmt in &program.statements {
353 collect_info(
354 stmt,
355 &mut all_programs,
356 &mut list_ops_set,
357 &mut pipeline_stages,
358 &mut redirects,
359 &mut has_heredoc,
360 );
361 }
362
363 dedup_vec(&mut all_programs);
365 dedup_vec(&mut list_ops_set);
366
367 let (primary_program, cwd_hint, side_effect) = extract_primary(program);
370 let kind = crate::wrappers::peel_command(program)
378 .map(|p| p.primary)
379 .or_else(|| primary_program.clone())
380 .as_deref()
381 .and_then(kind_for);
382
383 let top_level_statements: Vec<StatementSlice> = program
384 .statements
385 .iter()
386 .map(statement_slice)
387 .collect();
388
389 BashShape {
390 primary_program,
391 all_programs,
392 list_ops: list_ops_set,
393 pipeline_stages,
394 redirects,
395 has_heredoc,
396 cwd_hint,
397 side_effect,
398 kind,
399 top_level_statements,
400 }
401}
402
403fn statement_slice(stmt: &Statement) -> StatementSlice {
407 let (span, kind) = match stmt {
408 Statement::Command(s) => (s.span, StatementKind::Command),
409 Statement::Pipeline(s) => (s.span, StatementKind::Pipeline),
410 Statement::List(s) => (s.span, StatementKind::List),
411 Statement::CompoundStatement(s) => (s.span, StatementKind::Compound),
412 Statement::Subshell(s) => (s.span, StatementKind::Subshell),
413 Statement::If(s) => (s.span, StatementKind::If),
414 Statement::While(s) => (s.span, StatementKind::While),
415 Statement::For(s) => (s.span, StatementKind::For),
416 Statement::CStyleFor(s) => (s.span, StatementKind::CStyleFor),
417 Statement::Case(s) => (s.span, StatementKind::Case),
418 Statement::FunctionDefinition(s) => (s.span, StatementKind::FunctionDefinition),
419 Statement::Redirected(s) => (s.span, StatementKind::Redirected),
420 Statement::Declaration(s) => (s.span, StatementKind::Declaration),
421 Statement::Unset(s) => (s.span, StatementKind::Unset),
422 Statement::Test(s) => (s.span, StatementKind::Test),
423 Statement::Negated(s) => (s.span, StatementKind::Negated),
424 Statement::VariableAssignment(s) => (s.span, StatementKind::VariableAssignment),
425 Statement::VariableAssignments(s) => (s.span, StatementKind::VariableAssignments),
426 };
427 let Span { byte_start, byte_end, .. } = span;
428 StatementSlice { byte_start, byte_end, kind }
429}
430
431fn collect_info(
436 stmt: &Statement,
437 all_programs: &mut Vec<String>,
438 list_ops: &mut Vec<String>,
439 pipeline_stages: &mut usize,
440 redirects: &mut Vec<String>,
441 has_heredoc: &mut bool,
442) {
443 match stmt {
444 Statement::Command(cmd) => {
445 if let Some(name) = cmd_name_text(cmd) {
446 all_programs.push(name);
447 }
448 }
449 Statement::Pipeline(p) => {
450 *pipeline_stages = (*pipeline_stages).max(p.stages.len());
451 for stage in &p.stages {
452 collect_info(stage, all_programs, list_ops, pipeline_stages, redirects, has_heredoc);
453 }
454 }
455 Statement::List(list) => {
456 let op: &str = match list.operator {
457 ListOperator::And => "&&",
458 ListOperator::Or => "||",
459 };
460 list_ops.push(op.to_owned());
461 collect_info(&list.left, all_programs, list_ops, pipeline_stages, redirects, has_heredoc);
462 collect_info(&list.right, all_programs, list_ops, pipeline_stages, redirects, has_heredoc);
463 }
464 Statement::Redirected(r) => {
465 collect_redirects(r, redirects, has_heredoc);
466 if let Some(body) = &r.body {
467 collect_info(body, all_programs, list_ops, pipeline_stages, redirects, has_heredoc);
468 }
469 }
470 Statement::CompoundStatement(cs) => {
471 for s in &cs.statements {
472 collect_info(s, all_programs, list_ops, pipeline_stages, redirects, has_heredoc);
473 }
474 }
475 Statement::Negated(n) => {
476 collect_info(&n.inner, all_programs, list_ops, pipeline_stages, redirects, has_heredoc);
477 }
478 Statement::If(i) => {
479 for s in &i.body {
480 collect_info(s, all_programs, list_ops, pipeline_stages, redirects, has_heredoc);
481 }
482 for elif in &i.elif_clauses {
483 for s in &elif.body {
484 collect_info(s, all_programs, list_ops, pipeline_stages, redirects, has_heredoc);
485 }
486 }
487 if let Some(else_cl) = &i.else_clause {
488 for s in &else_cl.body {
489 collect_info(s, all_programs, list_ops, pipeline_stages, redirects, has_heredoc);
490 }
491 }
492 }
493 Statement::While(w) => {
494 use crate::ast::LoopBody;
495 match &w.body {
496 LoopBody::Compound(cs) => {
497 for s in &cs.statements {
498 collect_info(s, all_programs, list_ops, pipeline_stages, redirects, has_heredoc);
499 }
500 }
501 LoopBody::DoGroup(dg) => {
502 for s in &dg.statements {
503 collect_info(s, all_programs, list_ops, pipeline_stages, redirects, has_heredoc);
504 }
505 }
506 }
507 }
508 _ => {}
510 }
511}
512
513fn collect_redirects(r: &RedirectedStatement, redirects: &mut Vec<String>, has_heredoc: &mut bool) {
514 for redirect in &r.redirects {
515 match redirect {
516 Redirect::File(f) => redirects.push(f.operator.clone()),
517 Redirect::Heredoc(_) => *has_heredoc = true,
518 Redirect::Herestring(_) => {}
519 }
520 }
521}
522
523fn extract_primary(program: &Program) -> (Option<String>, Option<String>, Option<SideEffect>) {
530 extract_from_stmt_list(&program.statements, None)
531}
532
533fn extract_from_stmt_list(
546 stmts: &[Statement],
547 inherited_cwd: Option<String>,
548) -> (Option<String>, Option<String>, Option<SideEffect>) {
549 for stmt in stmts {
550 match stmt {
551 Statement::VariableAssignment(a) => {
553 if let crate::ast::AssignmentValue::Primary(
554 crate::ast::PrimaryExpression::CommandSubstitution(cs),
555 ) = &a.value
556 {
557 let result = extract_from_stmt_list(&cs.body, inherited_cwd.clone());
558 if result.0.is_some() {
559 return result;
560 }
561 }
562 }
565 Statement::VariableAssignments(_) => {}
567 _ => {
568 let result = extract_primary_stmt(stmt, inherited_cwd.clone());
569 if result.0.is_some() {
570 return result;
571 }
572 }
576 }
577 }
578 (None, inherited_cwd, None)
579}
580
581fn extract_primary_stmt(
590 stmt: &Statement,
591 inherited_cwd: Option<String>,
592) -> (Option<String>, Option<String>, Option<SideEffect>) {
593 match stmt {
594 Statement::Command(cmd) => {
595 let prog = cmd_name_text(cmd);
596 if prog.as_deref() == Some("cd") {
597 let cwd = cmd.arguments.first().and_then(arg_text).or(inherited_cwd);
599 return (None, cwd, None);
600 }
601 let first_arg = cmd.arguments.first().and_then(arg_text);
602 let side = classify(prog.as_deref(), first_arg.as_deref(), &cmd.arguments);
603 (prog, inherited_cwd, side)
604 }
605 Statement::List(list) => {
606 let (lprog, lcwd, lside) = extract_primary_stmt(&list.left, inherited_cwd.clone());
607 if lprog.is_none() {
608 extract_primary_stmt(&list.right, lcwd)
610 } else {
611 (lprog, lcwd, lside)
612 }
613 }
614 Statement::Pipeline(p) => match p.stages.first() {
615 Some(first) => extract_primary_stmt(first, inherited_cwd),
616 None => (None, inherited_cwd, None),
617 },
618 Statement::Redirected(r) => match &r.body {
619 Some(body) => extract_primary_stmt(body, inherited_cwd),
620 None => (None, inherited_cwd, None),
621 },
622 Statement::Negated(n) => extract_primary_stmt(&n.inner, inherited_cwd),
623 Statement::For(f) => {
625 let stmts = loop_body_stmts(&f.body);
626 extract_from_stmt_list(stmts, inherited_cwd)
627 }
628 Statement::While(w) => {
629 let stmts = loop_body_stmts(&w.body);
630 extract_from_stmt_list(stmts, inherited_cwd)
631 }
632 Statement::CompoundStatement(cs) => {
633 extract_from_stmt_list(&cs.statements, inherited_cwd)
634 }
635 Statement::If(i) => extract_from_stmt_list(&i.body, inherited_cwd),
636 _ => (None, inherited_cwd, None),
637 }
638}
639
640fn loop_body_stmts(body: &crate::ast::LoopBody) -> &[Statement] {
642 match body {
643 crate::ast::LoopBody::Compound(cs) => &cs.statements,
644 crate::ast::LoopBody::DoGroup(dg) => &dg.statements,
645 }
646}
647
648fn classify(prog: Option<&str>, first_arg: Option<&str>, args: &[CommandArgument]) -> Option<SideEffect> {
653 let prog = prog?;
654 let fa = first_arg.unwrap_or("");
655
656 const GIT_WRITE: &[&str] = &[
657 "push", "commit", "tag", "merge", "rebase",
658 "reset", "branch", "checkout", "clean",
659 ];
660 const GH_OBJECTS: &[&str] = &["pr", "release", "issue"];
661 const GH_WRITE_ACTIONS: &[&str] = &["create", "merge", "close"];
662 const HTTP_WRITE_METHODS: &[&str] = &["POST", "PUT", "DELETE", "PATCH"];
663
664 match prog {
665 "git" => {
666 if GIT_WRITE.contains(&fa) {
667 return Some(SideEffect::GitWrite);
668 }
669 }
670 "gh" => {
671 if GH_OBJECTS.contains(&fa) {
672 let second = args.get(1).and_then(arg_text);
673 if second.as_deref().map(|s| GH_WRITE_ACTIONS.contains(&s)).unwrap_or(false) {
674 return Some(SideEffect::GitHub);
675 }
676 }
677 }
678 "cargo" | "npm" | "bun" => {
679 if fa == "publish" {
680 return Some(SideEffect::Publish);
681 }
682 }
683 "rm" | "rmdir" | "dd" => return Some(SideEffect::Destructive),
684 "curl" | "wget" => {
685 let texts: Vec<Option<String>> = args.iter().map(arg_text).collect();
686 let two_token = texts.windows(2).any(|w| {
688 w[0].as_deref() == Some("-X")
689 && w[1].as_deref().map(|s| HTTP_WRITE_METHODS.contains(&s)).unwrap_or(false)
690 });
691 let merged = texts.iter().any(|t| {
693 t.as_deref()
694 .map(|s| s.starts_with("-X") && HTTP_WRITE_METHODS.contains(&&s[2..]))
695 .unwrap_or(false)
696 });
697 if two_token || merged {
698 return Some(SideEffect::Network);
699 }
700 }
701 "sudo" => return Some(SideEffect::SudoOrInstall),
702 "brew" => {
703 if fa == "install" {
704 return Some(SideEffect::SudoOrInstall);
705 }
706 }
707 "apt" | "apt-get" => {
708 if fa == "install" {
709 return Some(SideEffect::SudoOrInstall);
710 }
711 }
712 _ => {
713 if prog.starts_with("mkfs") {
714 return Some(SideEffect::Destructive);
715 }
716 }
717 }
718 None
719}
720
721fn cmd_name_text(cmd: &Command) -> Option<String> {
726 match &cmd.name.inner {
727 CommandNameInner::Primary(p) => primary_text(p),
728 CommandNameInner::Concatenation(c) => {
729 let s: String = c.parts.iter().filter_map(primary_text).collect();
730 if s.is_empty() { None } else { Some(s) }
731 }
732 }
733}
734
735fn arg_text(arg: &CommandArgument) -> Option<String> {
736 match arg {
737 CommandArgument::Primary(p) => primary_text(p),
738 CommandArgument::Concatenation(c) => {
739 let s: String = c.parts.iter().filter_map(primary_text).collect();
740 if s.is_empty() { None } else { Some(s) }
741 }
742 CommandArgument::Regex(r) => Some(r.text.clone()),
743 CommandArgument::Operator { text, .. } => Some(text.clone()),
744 }
745}
746
747fn primary_text(p: &PrimaryExpression) -> Option<String> {
748 match p {
749 PrimaryExpression::Word(w) => Some(w.text.clone()),
750 PrimaryExpression::RawString(r) => Some(r.text.trim_matches('\'').to_owned()),
751 PrimaryExpression::StringNode(s) => {
752 let text: String = s.parts.iter().filter_map(|part| match part {
753 StringPart::Content(c) => Some(c.text.clone()),
754 _ => None,
755 }).collect();
756 if text.is_empty() { None } else { Some(text) }
757 }
758 PrimaryExpression::AnsiCString(a) => Some(a.text.clone()),
759 PrimaryExpression::SimpleExpansion(_)
760 | PrimaryExpression::Expansion(_)
761 | PrimaryExpression::CommandSubstitution(_)
762 | PrimaryExpression::ArithmeticExpansion(_)
763 | PrimaryExpression::BraceExpression(_)
764 | PrimaryExpression::Number(_)
765 | PrimaryExpression::ProcessSubstitution(_)
766 | PrimaryExpression::TranslatedString(_) => None,
767 }
768}
769
770fn dedup_vec(v: &mut Vec<String>) {
775 let mut seen = std::collections::HashSet::new();
776 v.retain(|s| seen.insert(s.clone()));
777}
778