| package bashkit |
| |
| import ( |
| "fmt" |
| "strings" |
| |
| "mvdan.cc/sh/v3/syntax" |
| ) |
| |
| // ExtractCommands parses a bash command and extracts individual command names that are |
| // candidates for auto-installation. |
| // |
| // Returns only simple command names (no paths, no builtins, no variable assignments) |
| // that could potentially be missing tools that need installation. |
| // |
| // Filtering logic: |
| // - Excludes commands with paths (./script.sh, /usr/bin/tool, ../build.sh) |
| // - Excludes shell builtins (echo, cd, test, [, etc.) |
| // - Excludes variable assignments (FOO=bar) |
| // - Deduplicates repeated command names |
| // |
| // Examples: |
| // |
| // "ls -la && echo done" → ["ls"] (echo filtered as builtin) |
| // "./deploy.sh && curl api.com" → ["curl"] (./deploy.sh filtered as path) |
| // "yamllint config.yaml" → ["yamllint"] (candidate for installation) |
| func ExtractCommands(command string) ([]string, error) { |
| r := strings.NewReader(command) |
| parser := syntax.NewParser() |
| file, err := parser.Parse(r, "") |
| if err != nil { |
| return nil, fmt.Errorf("failed to parse bash command: %w", err) |
| } |
| |
| var commands []string |
| seen := make(map[string]bool) |
| |
| syntax.Walk(file, func(node syntax.Node) bool { |
| callExpr, ok := node.(*syntax.CallExpr) |
| if !ok || len(callExpr.Args) == 0 { |
| return true |
| } |
| cmdName := callExpr.Args[0].Lit() |
| if cmdName == "" { |
| return true |
| } |
| if strings.Contains(cmdName, "=") { |
| // variable assignment |
| return true |
| } |
| if strings.Contains(cmdName, "/") { |
| // commands with slashes are user-specified executables/scripts |
| return true |
| } |
| if isBuiltin(cmdName) { |
| return true |
| } |
| if !seen[cmdName] { |
| seen[cmdName] = true |
| commands = append(commands, cmdName) |
| } |
| return true |
| }) |
| |
| return commands, nil |
| } |
| |
| // isBuiltin checks if a command is a shell built-in using the same logic as mvdan.cc/sh/v3/interp |
| // This is copied from mvdan.cc/sh/v3/interp.isBuiltin since it's not exported |
| // See https://github.com/mvdan/sh/issues/1164 |
| func isBuiltin(name string) bool { |
| switch name { |
| case "true", ":", "false", "exit", "set", "shift", "unset", |
| "echo", "printf", "break", "continue", "pwd", "cd", |
| "wait", "builtin", "trap", "type", "source", ".", "command", |
| "dirs", "pushd", "popd", "umask", "alias", "unalias", |
| "fg", "bg", "getopts", "eval", "test", "[", "exec", |
| "return", "read", "mapfile", "readarray", "shopt": |
| return true |
| } |
| return false |
| } |