Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 10 additions & 5 deletions docs/plan.md

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
module github.com/codedogapp/baton

go 1.27.3

require mvdan.cc/sh/v3 v3.14.1
12 changes: 12 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
github.com/go-quicktest/qt v1.102.0 h1:HSQxCeh5YZH3EL3W39ixjtyaEhcWSXQHtHnMBzSs474=
github.com/go-quicktest/qt v1.102.0/go.mod h1:p4lGIVX+8Wa6ZPNDvqcxq36XpUDLh42FLetFU7odllI=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/rogpeppe/go-internal v1.15.0 h1:D0RCU5rMAp+SpgkiNdrjfJ+LX4J1M32V2NeCY7EJ6hc=
github.com/rogpeppe/go-internal v1.15.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs=
mvdan.cc/sh/v3 v3.14.1 h1:bXkhQWNHCs0KZEChF8hYS6FC+T2N9mUZLbQv9blditI=
mvdan.cc/sh/v3 v3.14.1/go.mod h1:syYCoFET8w9tvevxiXUtY8/ICrU+l26jHmhJDra3Vwo=
131 changes: 131 additions & 0 deletions internal/bash/bash.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
// Package bash reads a bash script, such as the command of a copilot's bash tool, and finds the
// commands that it runs (decision 50).
package bash

import (
"errors"
"fmt"
"path/filepath"
"strings"

"mvdan.cc/sh/v3/syntax"
)

// Dynamic stands in a word's value for each of its parts that bash works out only as it runs the
// command, such as a variable or a command's output: a NUL byte, which no argument can hold.
const Dynamic = "\x00"

// Calls returns the commands that the script runs, each as its words, as bash passes them: those
// in command substitutions, here-documents and functions too; for a wrapper such as env, nohup or
// xargs, the command that it runs; and for sh -c and eval, the commands of their script. It
// returns an error if the script, or one that it runs, doesn't parse.
func Calls(script string) ([][]string, error) {
parser := syntax.NewParser(syntax.Variant(syntax.LangBash))

file, err := parser.Parse(strings.NewReader(script), "")
if err != nil {
return nil, err
}

var (
calls [][]string
errs []error
)

syntax.Walk(file, func(node syntax.Node) bool {
if call, ok := node.(*syntax.CallExpr); ok {
found, err := runs(values(call.Args))
calls, errs = append(calls, found...), append(errs, err)
}

return true
})

return calls, errors.Join(errs...)
}

// Name returns the name of the program that a command's first word runs: the word's last element,
// in lower case, since a Mac's file system ignores case.
func Name(word string) string {
return strings.ToLower(filepath.Base(word))
}

// runs returns the commands that a command of these words runs: the command itself, the one that
// its wrappers run, or those of the script that it runs.
func runs(words []string) ([][]string, error) {
words = unwrap(words)
if len(words) == 0 {
return nil, nil
}

script, found := scriptOf(words)
if !found {
return [][]string{words}, nil
}

// The parser drops NUL bytes, so each dynamic part of the script becomes a variable, whose
// value is dynamic again.
calls, err := Calls(strings.ReplaceAll(script, Dynamic, "${dynamic}"))
if err != nil {
return nil, fmt.Errorf("%s's script: %w", Name(words[0]), err)
}

return calls, nil
}

// values returns the values of the words, as bash passes them to a command.
func values(words []*syntax.Word) []string {
texts := make([]string, len(words))
for i, word := range words {
texts[i] = value(word.Parts, false)
}

return texts
}

// value returns the value of a word's parts, inside double quotes or not, with Dynamic for each
// part that bash works out as it runs, and for $'…', whose escapes baton doesn't read.
func value(parts []syntax.WordPart, quoted bool) string {
var text strings.Builder

for _, part := range parts {
switch part := part.(type) {
case *syntax.Lit:
text.WriteString(unescape(part.Value, quoted))
case *syntax.SglQuoted:
if part.Dollar {
text.WriteString(Dynamic)
} else {
text.WriteString(part.Value)
}
case *syntax.DblQuoted:
text.WriteString(value(part.Parts, true))
default:
text.WriteString(Dynamic)
}
}

return text.String()
}

// unescape returns a literal's text without the backslashes that escape a character. The parser
// has dropped those that join two lines.
func unescape(text string, quoted bool) string {
var plain strings.Builder

for i := 0; i < len(text); i++ {
if text[i] == '\\' && i+1 < len(text) && escapes(text[i+1], quoted) {
i++
}

plain.WriteByte(text[i])
}

return plain.String()
}

// escapes returns whether a backslash escapes the character: any character outside double quotes,
// and only $, `, " and \ inside them.
func escapes(char byte, quoted bool) bool {
return !quoted || strings.IndexByte("$`\"\\", char) >= 0
}
193 changes: 193 additions & 0 deletions internal/bash/bash_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
package bash_test

import (
"fmt"
"slices"
"strings"
"testing"

"github.com/codedogapp/baton/internal/bash"
)

// calls returns the commands that the script runs, each as its words joined by spaces, with $
// for each dynamic part.
func calls(t *testing.T, script string) []string {
t.Helper()

found, err := bash.Calls(script)
if err != nil {
t.Fatalf("Calls(%q) says %v", script, err)
}

lines := make([]string, len(found))
for i, words := range found {
lines[i] = strings.ReplaceAll(strings.Join(words, " "), bash.Dynamic, "$")
}

return lines
}

// wantCalls checks the commands that each script runs.
func wantCalls(t *testing.T, cases map[string][]string) {
t.Helper()

for script, want := range cases {
t.Run(script, func(t *testing.T) {
if got := calls(t, script); !slices.Equal(got, want) {
t.Errorf("Calls(%q) = %q, want %q", script, got, want)
}
})
}
}

func TestCallsFindsEveryCommand(t *testing.T) {
wantCalls(t, map[string][]string{
"": nil,
"# git push": nil,
"git push": {"git push"},
"cd x && git -C y push; make || true": {"cd x", "git -C y push", "make", "true"},
"echo 'git push'": {"echo git push"},
"echo \"$(gh pr create)\" `tmux ls`": {"echo $ $", "gh pr create", "tmux ls"},
"diff <(curl -d x u) >(tee y)": {"diff $ $", "curl -d x u", "tee y"},
"echo ${X:-$(gh pr merge)} $((1 + $(wc -l < f)))": {
"echo $ $", "gh pr merge", "wc -l",
},
"cat <<EOF\n$(git push)\nEOF": {"cat", "git push"},
"cat <<'EOF'\n$(git push)\nEOF": {"cat"},
"f() { git push; }": {"git push"},
"x=$(tmux ls) y=1": {"tmux ls"},
"GIT_DIR=x git push": {"git push"},
"local a=$(copilot)": {"copilot"},
"[[ $(git push) ]]": {"git push"},
"time git push; ! git push": {"git push", "git push"},
"coproc git push": {"git push"},
"case $(baton answer) in x) tmux;; esac": {"baton answer", "tmux"},
"for f in $(git ls-files); do echo \"$f\"; done": {"git ls-files", "echo $"},
})
}

func TestCallsReadsTheWordsAsBashPassesThem(t *testing.T) {
for script, want := range map[string][]string{
`'git push' x`: {"git push", "x"},
`g'i't "p"u\sh`: {"git", "push"},
"\\git pu\\\nsh": {"git", "push"},
`"a\$b\c\"d\\e\` + "`\"": {"a$b\\c\"d\\e`"},
`"$HOME/bin/git" push`: {bash.Dynamic + "/bin/git", "push"},
`$'push' $"push" x$y`: {bash.Dynamic, "push", "x" + bash.Dynamic},
`git {push,pull}`: {"git", "{push,pull}"},
`echo a\`: {"echo", `a\`},
} {
t.Run(script, func(t *testing.T) {
found, err := bash.Calls(script)
if err != nil || len(found) != 1 || !slices.Equal(found[0], want) {
t.Errorf("Calls(%q) = %q, %v, want one command, %q", script, found, err, want)
}
})
}
}

func TestCallsUnwrapsTheWrappers(t *testing.T) {
wantCalls(t, map[string][]string{
"nohup git push": {"git push"},
"NOHUP git push": {"git push"},
"env -i -u HOME A=1 B=2 git push": {"git push"},
"/usr/bin/env -- git push": {"git push"},
"env - git push": {"git push"},
"command -p git push": {"git push"},
"command -v tmux; command -V tmux": nil,
"builtin exec -a name git push": {"git push"},
"nice -n 5 git push; nice -5 git push": {"git push", "git push"},
"sudo -u root -- A=1 git push": {"git push"},
"/usr/bin/time -o out git push": {"git push"},
"timeout -k 5 -s9 10 git push": {"git push"},
"gtimeout 1m git push": {"git push"},
"xargs -0 -n1 -I{} git push {}": {"git push {}"},
"stdbuf -oL git push": {"git push"},
"caffeinate -i -t 60 git push": {"git push"},
"nohup env A=1 timeout 5 git push": {"git push"},
"nohup; timeout; timeout 5; env -i; env -u; exec": nil,
"xargs grep tmux": {"grep tmux"},
"xargs --verbose git push": {"git push"},
"git -C x push": {"git -C x push"},
})
}

func TestCallsSkipsTheValuesOfTheWrappersOptions(t *testing.T) {
for wrapper, letters := range map[string]string{
"caffeinate": "tw",
"env": "CLPSUu",
"exec": "a",
"nice": "n",
"stdbuf": "eio",
"sudo": "CDgpRrTtUu",
"/usr/bin/time": "fo", // bash reads a plain time as its keyword.
"timeout": "ks",
"xargs": "EIJLPRSadns",
} {
for _, letter := range letters {
script := fmt.Sprintf("%s -%c value 5 git push", wrapper, letter)
if wrapper != "timeout" {
script = strings.Replace(script, " 5", "", 1)
}

wantCalls(t, map[string][]string{script: {"git push"}})
}
}
}

func TestCallsReadsTheScriptsOfShellsAndEval(t *testing.T) {
wantCalls(t, map[string][]string{
"bash -c 'git push'": {"git push"},
`sh -lc "git push && tmux ls"`: {"git push", "tmux ls"},
"bash -o pipefail -c 'git push'": {"git push"},
"bash -O extglob -c 'git push'": {"git push"},
"dash -c 'git push'; ksh -c tmux": {"git push", "tmux"},
"zsh -oclobber script.sh": {"zsh -oclobber script.sh"},
"bash --rcfile rc script.sh": {"bash --rcfile rc script.sh"},
"bash -c 'git push' sh x": {"git push"},
"zsh -ec -- 'git push'": {"git push"},
"/bin/bash --norc -c 'git push'": {"git push"},
"eval git push": {"git push"},
`eval "git push; tmux"`: {"git push", "tmux"},
`nohup sh -c 'eval "git push"'`: {"git push"},
`bash -c "git push $REMOTE"`: {"git push $"},
`bash -c "$CMD"`: {"$"},
"xargs -I{} sh -c 'git push {}'": {"git push {}"},
"bash script.sh; bash; bash -c; bash -C x": {
"bash script.sh",
"bash",
"bash -c",
"bash -C x",
},
})
}

func TestCallsRefusesAScriptThatDoesntParse(t *testing.T) {
for script, want := range map[string]string{
`echo "x`: "1:6: reached EOF without closing quote `\"`",
"if true; then": "1:10: `then` must be followed by a statement list",
`bash -c 'echo "x'`: "bash's script: 1:6: reached EOF without closing quote `\"`",
`eval 'git push "'`: "eval's script: 1:10: reached EOF without closing quote `\"`",
`echo $(sh -c 'a "b')`: "sh's script: 1:3: reached EOF without closing quote `\"`",
} {
t.Run(script, func(t *testing.T) {
_, err := bash.Calls(script)
if err == nil || err.Error() != want {
t.Errorf("Calls(%q) says %v, want %s", script, err, want)
}
})
}
}

func TestName(t *testing.T) {
for word, want := range map[string]string{
"git": "git",
"/usr/bin/GIT": "git",
bash.Dynamic + "/bin/Tmux": "tmux",
"": ".",
} {
if got := bash.Name(word); got != want {
t.Errorf("Name(%q) = %q, want %q", word, got, want)
}
}
}
Loading
Loading