diff --git a/docs/usage.md b/docs/usage.md index 862b5d2..cf3b93f 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -562,42 +562,81 @@ shellmock commands <<< "if command -v git; then git pull; else hg pull; fi" ``` **Example**: -Some cases that cannot be found +Some cases that cannot be found by default ```bash -# Define two functions first. +# Put the following function definitions in a file `script.sh`. func1() { echo "Running func1." } func2() { local ls=ls func1 - # Output all files in a directory using `cat`. - find . -type f | xargs cat + # Output all files in a directory using `cat` via their full paths. + find . -type f | xargs readlink -f | xargs cat # Calling "ls" but with its name stored in a variable. "${ls}" func1 } -shellmock commands -f -c <<< "$(type func2 | tail -n+2)" +# Run this in the directory containing `script.sh`. +shellmock commands -f -c < script.sh # Output will be: # find:1 # func1:2 -# xargs:1 +# xargs:2 ``` Note how the `-c` flag causes the number of occurrences to be reported. Furthermore, the `-f` flag causes `func1` to be reported, too, even though it is a known shell function. -Note how `cat` is not detected because it is not called directly by the script. -Instead, it is called indirectly via `xargs`. +Note how neither `cat` nor `readlink` are detected because they are not called +directly by the script. +Instead, they are being called indirectly via `xargs`. Also note how `ls` is not detected even though it is called directly by the script. However, its name is stored in a shell variable. To be able to detect such cases, the values of all shell variables would have to be known, which is not possible without executing the script. +To support examples like the one above, `shellmock` allows for specifying +commands that are used indirectly by adding specific directives as comments. +Lines containing directives generally look like `# shellmock: +uses-command=cmd1,cmd2` and may be followed by a comment. +The above example can thus be updated to report all used executables. + +**Example**: +Using directives to specify used executables + +```bash +# Put the following function definitions in a file `script.sh`. +func1() { + echo "Running func1." +} +func2() { + local ls=ls + func1 + # Output all files in a directory using `cat` via their full paths. This calls + # `cat` and `basename` via `xargs`. + # shellmock: uses-command=readlink,cat + find . -type f | xargs readlink -f | xargs cat + # shellmock: uses-command=ls # Calling "ls" with its name in a variable. + "${ls}" + func1 +} + +# Run this in the directory containing `script.sh`. +shellmock commands -f -c < script.sh +# Output will be: +# cat:1 +# find:1 +# func1:2 +# ls:1 +# readlink:1 +# xargs:2 +``` + ### global-config diff --git a/go/main.go b/go/main.go index 6e8ccb2..f7f0233 100644 --- a/go/main.go +++ b/go/main.go @@ -24,10 +24,19 @@ import ( "log" "os" "slices" + "strings" + "unicode" shell "mvdan.cc/sh/v3/syntax" ) +const ( + commentChar = "#" + directiveSep = ":" + directiveStart = "shellmock" + usesCmdDirective = "uses-command=" +) + func sortedKeys(data map[string]int) []string { result := make([]string, 0, len(data)) for key := range data { @@ -64,6 +73,50 @@ func findCommands(shellCode shell.Node) map[string]int { return result } +func findCommandsFromDirectives(shellCode string) map[string]int { + result := map[string]int{} + for lineIdx, orgLine := range strings.Split(shellCode, "\n") { + line := orgLine + isDirectiveLine := true + // First, detect comment lines. Then, detect lines with shellmock directives. Then, detect + // lines with the expected directive. Skip if any of the preconditions are not fulfilled. + for idx, prefix := range []string{commentChar, directiveStart, usesCmdDirective} { + line = strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(line), directiveSep)) + if !strings.HasPrefix(line, prefix) { + if idx >= 2 { + log.Printf( + "WARNING: found unknown shellmock directive in line %d: %s", + lineIdx+1, orgLine, + ) + } + isDirectiveLine = false + break + } + line = strings.TrimPrefix(line, prefix) + } + if !isDirectiveLine { + continue + } + for _, cmd := range strings.Split(line, ",") { + // Stop if after some whitespace there is something starting with a comment character. + // That way, users can still add comments following the directive and executables + // containing whitespace are supported. The only thing we do not support is adding + // executables this way whose names contain a comment character following some space. We + // also do not support adding executables this way whose names contain commas. + idx := strings.Index(cmd, commentChar) + if idx > 0 && unicode.IsSpace(rune(cmd[idx-1])) { + // Make sure to add the last command before the trailing comment. + result[strings.TrimSpace(cmd[:idx])]++ + break + } + if len(cmd) != 0 { + result[cmd]++ + } + } + } + return result +} + func main() { content, err := io.ReadAll(bufio.NewReader(os.Stdin)) if err != nil { @@ -74,6 +127,11 @@ func main() { log.Fatalf("failed to parse shell code: %s", err.Error()) } commands := findCommands(parsed) + // Also find commands that are noted by shellmock directives. + moreCommands := findCommandsFromDirectives(string(content)) + for cmd, count := range moreCommands { + commands[cmd] += count + } for _, cmd := range sortedKeys(commands) { count := commands[cmd] fmt.Printf("%s:%d\n", cmd, count) diff --git a/lib/mock_management.bash b/lib/mock_management.bash index b8e5962..f8ba971 100644 --- a/lib/mock_management.bash +++ b/lib/mock_management.bash @@ -268,6 +268,7 @@ __shellmock__assert() { declare -a actual_argspecs mapfile -t actual_argspecs < <( if [[ -d "${__SHELLMOCK_OUTPUT}/${cmd_b32}" ]]; then + # shellmock: uses-command=cat find "${__SHELLMOCK_OUTPUT}/${cmd_b32}" -mindepth 2 -type f \ -name argspec -print0 | xargs -r -0 cat | sort -u fi diff --git a/lib/shellmock.bash b/lib/shellmock.bash index bdeb906..1f908b9 100644 --- a/lib/shellmock.bash +++ b/lib/shellmock.bash @@ -165,6 +165,7 @@ __shellmock_internal_trap() { then local defined_cmds readarray -d $'\n' -t defined_cmds < <( + # shellmock: uses-command=basename find "${__SHELLMOCK_MOCKBIN}" -type f -print0 | xargs -r -0 -I{} basename {} ) && wait $! diff --git a/tests/extended.bats b/tests/extended.bats index 513ea9a..819a5c0 100644 --- a/tests/extended.bats +++ b/tests/extended.bats @@ -180,3 +180,27 @@ EOF ) [[ ${output} == $(_join $'\n' "${exes[@]}") ]] } + +@test "hinting at which executables are being used" { + # We support multiple ways to specify directives. Test that they all work. + directives=( + '# shellmock uses-command=cmd1' + '#shellmock:uses-command=cmd1,cmd with spaces,cmd2 # followed by a comment' + ' # shellmock: uses-command=cmd2,cmd2' + ) + run -0 shellmock commands -c <<< "$(_join $'\n' "${directives[@]}")" + + exes=( + "cmd with spaces:1" + "cmd1:2" + "cmd2:3" + ) + [[ ${output} == $(_join $'\n' "${exes[@]}") ]] +} + +@test "warning about unknown directives" { + line='# shellmock: unknown-directive=value' + script=$'\n\n\n'"${line}"$'\n\n' + run -0 shellmock commands -c <<< "${script}" + [[ ${output} == *"WARNING: found unknown shellmock directive in line 4: ${line}"* ]] +}