| Josh Bleecher Snyder | 495c1fa | 2025-05-29 00:37:22 +0000 | [diff] [blame] | 1 | package bashkit |
| 2 | |
| 3 | import ( |
| 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) |
| 27 | func 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 |
| 71 | func 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 | } |