Skip to content

Commit

Permalink
Minor release 0.8.0
Browse files Browse the repository at this point in the history
Signed-off-by: Torsten Long <[email protected]>
  • Loading branch information
razziel89 committed Mar 27, 2024
1 parent f0b3f98 commit bfe32e4
Show file tree
Hide file tree
Showing 10 changed files with 399 additions and 2 deletions.
5 changes: 5 additions & 0 deletions .github/workflows/test-lint-deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,11 @@ jobs:
with:
fetch-depth: 0

- uses: actions/setup-go@v5
with:
go-version-file: "go/go.mod"
cache: true

- name: Build Package
run: make build

Expand Down
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,12 +50,17 @@ The following tools are needed to use `shellmock`:
- `base64`
- `bash` (at least version 4.4)
- `cat`
- `chmod`
- `env`
- `find`
- `gawk`
- `grep`
- `mkdir`
- `mktemp`
- `rm`
- `sed`
- `sort`
- `touch`
- `tr`
- `xargs`

Expand All @@ -71,7 +76,11 @@ We recommend an installation via `npm` instead of an installation via `apt`.
The reason is that many system packages provide comparatively old versions while
the version installable via `npm` is up to date.

To run the [`commands` command](./docs/usage.md#commands), you also need a
[Golang][golang] toolchain.

[bats-npm-install]: https://bats-core.readthedocs.io/en/stable/installation.html#any-os-npm
[golang]: https://go.dev/doc/install

## Documentation Overview

Expand Down
90 changes: 89 additions & 1 deletion docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@ It is implemented as a shell function with the following sub-commands:
Configure a previously-created mock by defining expectations.
- `assert`:
Assert based on previously-configured expectations.
- `commands`:
Retrieve a list of all executables and shell functions called by shell code.
- `global-config`:
Configure global behaviour of Shellmock itself.
- `calls`:
Expand All @@ -74,8 +76,12 @@ You can jump to the respective section via the following links.
- [Flexible Position-Independent argspec](#flexible-position-independent-argspec)
- [Regex-Based argspec](#regex-based-argspec)
- [Multi-Line Mock Output](#multi-line-mock-output)
- [Forwarding Calls](#forwarding-calls)
- [assert](#assert)
- [Assertion Types](#assertion-types)
- [commands](#commands)
- [Dependencies](#dependencies)
- [Examples](#examples)
- [global-config](#global-config)
- [checkpath](#checkpath)
- [killparent](#killparent)
Expand Down Expand Up @@ -430,7 +436,7 @@ EOF
shellmock config git 0 1:tag 2:--list <<< $'first\nsecond\n'
```

### Forwarding Calls
#### Forwarding Calls

It can be desirable to mock only some calls to an executable.
For example, you may want to mock only `POST` request sent via `curl` but `GET`
Expand Down Expand Up @@ -510,6 +516,88 @@ There are currently the following types of assertions.
`only-expected-calls`, and `call-correspondence`.
It is a convenience assertion type combining all other assertions.

### commands

<!-- shellmock-helptext-start -->

Syntax:
`shellmock commands [-f] [-c]`

The `commands` command builds a list of executables used by some shell code.
The shell code that shall be checked is read from stdin.

<!-- shellmock-helptext-end -->

The `commands` command can be used to create a test to make sure that you know
exactly which executables your shell script uses.
Shell-builtins will not be reported.
Furthermore, by default, known shell functions will not be reported unless the
`-f` flag is provided.
The `-c` flag will modify the output by also providing the number of times the
executable or function is used.
Executable/function and count will be separated by a colon.

#### Dependencies

The `commands` command is not fully implemented in `bash` and with default shell
utilities.
Instead, it uses some bundled [Golang code](../go) to perform the extraction of
used commands.
Thus, in order to use the `commands` command, you have to have a
[Golang][golang] toolchain installed on your system.

[golang]: https://go.dev/doc/install

#### Examples

**Example**:
Finding executables

```bash
# Some shell code that uses "git" to pull a repository and mercurial otherwise.
shellmock commands <<< "if command -v git; then git pull; else hg pull; fi"
# Output will be:
# git
# hg
```

**Example**:
Some cases that cannot be found

```bash
# Define two functions first.
func1() {
echo "Running func1."
}
func2() {
local ls=ls
func1
# Output all files in a directory using `cat`.
find . -type f | xargs cat
# Calling "ls" but with its name stored in a variable.
"${ls}"
func1
}

shellmock commands -f -c <<< "$(type func2 | tail -n+2)"
# Output will be:
# find:1
# func1:2
# xargs:1
```

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`.
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.

### global-config

<!-- shellmock-helptext-start -->
Expand Down
19 changes: 19 additions & 0 deletions generate_deployable.sh
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,25 @@ EOF
ENDOFFILE
}
# Internal Go code used to check used commands in shell code.
__shellmock_internal_init_command_search() {
local path=$1
EOF

echo "cat > \"\${path}/go.mod\" << 'ENDOFFILE'"
cat ./go/go.mod

cat << 'EOF'
ENDOFFILE
EOF

echo "cat > \"\${path}/main.go\" << 'ENDOFFILE'"
cat ./go/main.go

cat << 'EOF'
ENDOFFILE
}
# Run initialisation steps.
__shellmock_internal_init
EOF
Expand Down
2 changes: 2 additions & 0 deletions go/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# Always ignore the sumfile because users won't have one either.
go.sum
24 changes: 24 additions & 0 deletions go/go.mod
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
// Copyright (c) 2022 - for information on the respective copyright owner
// see the NOTICE file or the repository
// https://github.com/boschresearch/shellmock
//
// Licensed under the Apache License, Version 2.0 (the "License"); you may not
// use this file except in compliance with the License. You may obtain a copy of
// the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
// License for the specific language governing permissions and limitations under
// the License.

module main

go 1.22.1

require (
golang.org/x/exp v0.0.0-20240325151524-a685a6edb6d8
mvdan.cc/sh/v3 v3.8.0
)
75 changes: 75 additions & 0 deletions go/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
// Copyright (c) 2022 - for information on the respective copyright owner
// see the NOTICE file or the repository
// https://github.com/boschresearch/shellmock
//
// Licensed under the Apache License, Version 2.0 (the "License"); you may not
// use this file except in compliance with the License. You may obtain a copy of
// the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
// License for the specific language governing permissions and limitations under
// the License.

package main

import (
"bufio"
"bytes"
"fmt"
"io"
"log"
"os"
"slices"

"golang.org/x/exp/maps"
shell "mvdan.cc/sh/v3/syntax"
)

// Determine all commands executed by a script.
func findCommands(shellCode shell.Node) map[string]int {
result := map[string]int{}

shell.Walk(
shellCode, func(node shell.Node) bool {
// Simple commands.
if expr, ok := node.(*shell.CallExpr); ok {
if len(expr.Args) == 0 || len(expr.Args[0].Parts) == 0 {
// Ignore empty commands and continue searching.
return true
}
// We do not detect cases where a command is an argument. We also do not detect
// cases where the command we seek is hidden in a command substitution or shell
// expansion.
if cmd, ok := expr.Args[0].Parts[0].(*shell.Lit); ok {
result[cmd.Value]++
}
}
// Continue searching.
return true
},
)

return result
}

func main() {
content, err := io.ReadAll(bufio.NewReader(os.Stdin))
if err != nil {
log.Fatalf("failed to read from stdin: %s", err.Error())
}
parsed, err := shell.NewParser().Parse(bytes.NewReader(content), "")
if err != nil {
log.Fatalf("failed to parse shell code: %s", err.Error())
}
commands := findCommands(parsed)
keys := maps.Keys(commands)
slices.Sort(keys)
for _, cmd := range keys {
count := commands[cmd]
fmt.Printf("%s:%d\n", cmd, count)
}
}
87 changes: 87 additions & 0 deletions lib/command_commands.bash
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
#!/bin/bash

# Copyright (c) 2022 - for information on the respective copyright owner
# see the NOTICE file or the repository
# https://github.com/boschresearch/shellmock
#
# Licensed under the Apache License, Version 2.0 (the "License"); you may not
# use this file except in compliance with the License. You may obtain a copy of
# the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations under
# the License.

# This file contains functionality needed for the command used to check which
# executables a script is executing. That makes it easier to determine which
# ones to mock.
__shellmock__commands() {
__shellmock_internal_pathcheck
__shellmock_internal_trapcheck

local check_functions=0
local usage_counts=0
while [[ $# -gt 0 ]]; do
case $1 in
--check-functions | -f)
local check_functions=1
;;
--usage-counts | -c)
local usage_counts=1
;;
*)
echo >&2 "Unknown argument '$1'."
return 1
;;
esac
shift
done

if ! command -v go &> /dev/null; then
echo >&2 "The 'commands' command requires a Go toolchain." \
"Get it from here: https://go.dev/doc/install"
return 1
fi

if [[ -t 0 ]]; then
echo >&2 "Shell code is read from stdin but stdin is a terminal, aborting."
return 1
fi
local code
code="$(cat -)"

# Build the binary used to analyse the shell code.
local bin="${__SHELLMOCK_GO_MOD}/main"
if ! [[ -x ${bin} ]]; then
__shellmock_internal_init_command_search "${__SHELLMOCK_GO_MOD}"
(cd "${__SHELLMOCK_GO_MOD}" && go get && go build) 1>&2
fi

declare -A builtins
local tmp
while read -r tmp; do
builtins["${tmp}"]=1
done < <(compgen -b) && wait $! || return 1

local cmd
while read -r tmp; do
cmd="${tmp%:*}"
# Only output if it is neither a currently defined function or a built-in.
if
[[ -z ${builtins["${cmd}"]-} ]] \
&& [[ ${check_functions} -eq 1 ||
$(type -t "${cmd}" || :) != function ]]
then
# Adjust output format as requested.
if [[ ${usage_counts} -eq 1 ]]; then
echo "${tmp}"
else
echo "${cmd}"
fi
fi
done < <("${bin}" <<< "${code}") && wait $! || return 1
}
Loading

0 comments on commit bfe32e4

Please sign in to comment.