blob: 2b17ae3209e4f18e7b76a5666508e347e365edfb [file] [log] [blame]
Josh Bleecher Snyder495c1fa2025-05-29 00:37:22 +00001package bashkit
2
3import (
4 "fmt"
5 "strings"
6
7 "mvdan.cc/sh/v3/syntax"
8)
9
10// ExtractCommands parses a bash command and extracts individual command names that are
11// candidates for auto-installation.
12//
13// Returns only simple command names (no paths, no builtins, no variable assignments)
14// that could potentially be missing tools that need installation.
15//
16// Filtering logic:
17// - Excludes commands with paths (./script.sh, /usr/bin/tool, ../build.sh)
18// - Excludes shell builtins (echo, cd, test, [, etc.)
19// - Excludes variable assignments (FOO=bar)
20// - Deduplicates repeated command names
21//
22// Examples:
23//
24// "ls -la && echo done" → ["ls"] (echo filtered as builtin)
25// "./deploy.sh && curl api.com" → ["curl"] (./deploy.sh filtered as path)
26// "yamllint config.yaml" → ["yamllint"] (candidate for installation)
27func ExtractCommands(command string) ([]string, error) {
28 r := strings.NewReader(command)
29 parser := syntax.NewParser()
30 file, err := parser.Parse(r, "")
31 if err != nil {
32 return nil, fmt.Errorf("failed to parse bash command: %w", err)
33 }
34
35 var commands []string
36 seen := make(map[string]bool)
37
38 syntax.Walk(file, func(node syntax.Node) bool {
39 callExpr, ok := node.(*syntax.CallExpr)
40 if !ok || len(callExpr.Args) == 0 {
41 return true
42 }
43 cmdName := callExpr.Args[0].Lit()
44 if cmdName == "" {
45 return true
46 }
47 if strings.Contains(cmdName, "=") {
48 // variable assignment
49 return true
50 }
51 if strings.Contains(cmdName, "/") {
52 // commands with slashes are user-specified executables/scripts
53 return true
54 }
55 if isBuiltin(cmdName) {
56 return true
57 }
58 if !seen[cmdName] {
59 seen[cmdName] = true
60 commands = append(commands, cmdName)
61 }
62 return true
63 })
64
65 return commands, nil
66}
67
68// isBuiltin checks if a command is a shell built-in using the same logic as mvdan.cc/sh/v3/interp
69// This is copied from mvdan.cc/sh/v3/interp.isBuiltin since it's not exported
70// See https://github.com/mvdan/sh/issues/1164
71func isBuiltin(name string) bool {
72 switch name {
73 case "true", ":", "false", "exit", "set", "shift", "unset",
74 "echo", "printf", "break", "continue", "pwd", "cd",
75 "wait", "builtin", "trap", "type", "source", ".", "command",
76 "dirs", "pushd", "popd", "umask", "alias", "unalias",
77 "fg", "bg", "getopts", "eval", "test", "[", "exec",
78 "return", "read", "mapfile", "readarray", "shopt":
79 return true
80 }
81 return false
82}