parsing.go

 1package bashkit
 2
 3import (
 4	"fmt"
 5	"strings"
 6
 7	"mvdan.cc/sh/v3/interp"
 8	"mvdan.cc/sh/v3/syntax"
 9)
10
11// ExtractCommands parses a bash command and extracts individual command names that are
12// candidates for auto-installation.
13//
14// Returns only simple command names (no paths, no builtins, no variable assignments)
15// that could potentially be missing tools that need installation.
16//
17// Filtering logic:
18// - Excludes commands with paths (./script.sh, /usr/bin/tool, ../build.sh)
19// - Excludes shell builtins (echo, cd, test, [, etc.)
20// - Excludes variable assignments (FOO=bar)
21// - Deduplicates repeated command names
22//
23// Examples:
24//
25//	"ls -la && echo done" → ["ls"] (echo filtered as builtin)
26//	"./deploy.sh && curl api.com" → ["curl"] (./deploy.sh filtered as path)
27//	"yamllint config.yaml" → ["yamllint"] (candidate for installation)
28func ExtractCommands(command string) ([]string, error) {
29	r := strings.NewReader(command)
30	parser := syntax.NewParser()
31	file, err := parser.Parse(r, "")
32	if err != nil {
33		return nil, fmt.Errorf("failed to parse bash command: %w", err)
34	}
35
36	var commands []string
37	seen := make(map[string]bool)
38
39	syntax.Walk(file, func(node syntax.Node) bool {
40		callExpr, ok := node.(*syntax.CallExpr)
41		if !ok || len(callExpr.Args) == 0 {
42			return true
43		}
44		cmdName := callExpr.Args[0].Lit()
45		if cmdName == "" {
46			return true
47		}
48		if strings.Contains(cmdName, "=") {
49			// variable assignment
50			return true
51		}
52		if strings.Contains(cmdName, "/") {
53			// commands with slashes are user-specified executables/scripts
54			return true
55		}
56		if interp.IsBuiltin(cmdName) {
57			return true
58		}
59		if !seen[cmdName] {
60			seen[cmdName] = true
61			commands = append(commands, cmdName)
62		}
63		return true
64	})
65
66	return commands, nil
67}