Skip to content

Commit 23810b3

Browse files
authored
feat(cli): bash and zsh completion scripts with an anti-drift test (#791)
1 parent 716f886 commit 23810b3

6 files changed

Lines changed: 352 additions & 0 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
- `--test-timeout` no longer intermittently reports a fast test as timed out. The watchdog's process-group kill could miss when `set -m` had not made the backgrounded subshell a group leader, leaving it to sleep the full timeout and fire against an already-finished test; it is now signalled by pid directly and skips marking a test that already completed
77

88
### Added
9+
- Shell tab-completion scripts for bash and zsh under `completions/` — subcommands, all `test` flags (with value hints like `--jobs auto`, `--output tap`, file completion for `--env`), and assertion names after `bashunit assert`. An anti-drift CI test fails when a flag is added to the CLI but not to the scripts (#778)
910
- `--rerun-failed` (env: `BASHUNIT_RERUN_FAILED`) replays only the tests that failed on the previous run. Every run records its failing tests as `<test_file>:<function_name>` in `.bashunit/last-failed` under the working directory (a green run clears it); with the flag, discovery is restricted to those files and functions. Falls back to the full suite with a notice when the cache is empty, composes with `--filter`/`--tag` (intersection) and `--parallel`. Add `.bashunit/` to your `.gitignore` (#776)
1011
- Coverage badge: an optional, nightly `coverage.yml` CI workflow dogfoods `--coverage` over the unit suite, uploads `coverage/lcov.info` as an artifact, and publishes a shields.io endpoint badge (now shown in the README) to an orphan `badges` branch — no third-party coverage service. It runs on a schedule / manual dispatch only (never on push or pull requests, so it never gates merges). The engine's own meta-tests are excluded from the measured run to avoid double-instrumenting `src/coverage.sh` (#754)
1112
- `--jobs auto` (and `-j auto`) caps parallel concurrency at the detected CPU core count, portable across Linux/macOS/BSD (`nproc`, then `sysctl`, then `getconf`, falling back to 4). The default stays unlimited (`--jobs 0`) (#766)

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,8 @@ bashunit ships ~60 assertions across many families. One representative example p
8383

8484
Full documentation, covering installation options, every feature and examples, lives at [bashunit.com](https://bashunit.com).
8585

86+
Shell tab-completion for bash and zsh is available under [`completions/`](completions/) — see the [installation docs](https://bashunit.com/installation#shell-completion).
87+
8688
## Contribute
8789

8890
Issues, ideas and pull requests are welcome.

completions/_bashunit

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
#compdef bashunit
2+
# zsh completion for bashunit
3+
#
4+
# Install: copy this file as _bashunit into a directory in your $fpath, e.g.
5+
# cp completions/_bashunit /usr/local/share/zsh/site-functions/_bashunit
6+
# then restart zsh (or reinitialize completions with compinit).
7+
#
8+
# Kept in sync with src/main.sh by tests/unit/completions_test.sh (anti-drift).
9+
10+
_bashunit() {
11+
local curcontext="$curcontext" ret=1
12+
13+
# `bashunit assert <fn>` completes the public assertion names.
14+
if (( CURRENT == 3 )) && [ "${words[2]}" = "assert" ]; then
15+
local -a assert_fns
16+
assert_fns=(
17+
assert_array_contains assert_array_length assert_array_not_contains
18+
assert_arrays_equal assert_command_not_found assert_contains
19+
assert_contains_ignore_case assert_date_after assert_date_before
20+
assert_date_equals assert_date_within_delta assert_date_within_range
21+
assert_directory_exists assert_directory_not_exists assert_duration
22+
assert_duration_greater_than assert_duration_less_than assert_empty
23+
assert_equals assert_exec assert_exit_code assert_false
24+
assert_file_contains assert_file_exists assert_file_not_contains
25+
assert_file_not_exists assert_file_permissions assert_files_equals
26+
assert_files_not_equals assert_general_error assert_greater_or_equal_than
27+
assert_greater_than assert_is_directory assert_is_directory_empty
28+
assert_is_directory_not_empty assert_is_directory_not_readable
29+
assert_is_directory_not_writable assert_is_directory_readable
30+
assert_is_directory_writable assert_is_file assert_is_file_empty
31+
assert_json_contains assert_json_equals assert_json_key_exists
32+
assert_less_or_equal_than assert_less_than assert_line_count
33+
assert_match_snapshot assert_match_snapshot_ignore_colors assert_matches
34+
assert_not_contains assert_not_empty assert_not_equals assert_not_matches
35+
assert_not_same assert_same assert_string_ends_with
36+
assert_string_matches_format assert_string_not_ends_with
37+
assert_string_not_matches_format assert_string_not_starts_with
38+
assert_string_starts_with assert_successful_code assert_true
39+
assert_unsuccessful_code assert_within_delta
40+
)
41+
compadd -a assert_fns
42+
return 0
43+
fi
44+
45+
if (( CURRENT == 2 )); then
46+
local -a subcommands
47+
subcommands=(
48+
'test:Run tests (default command)'
49+
'bench:Run benchmarks'
50+
'doc:Display assertion documentation'
51+
'init:Initialize a new test directory'
52+
'learn:Start interactive tutorial'
53+
'upgrade:Upgrade bashunit to latest version'
54+
'assert:Run standalone assertion'
55+
'watch:Watch files and rerun tests on change'
56+
)
57+
_describe 'bashunit command' subcommands && ret=0
58+
fi
59+
60+
_arguments \
61+
'(-a --assert)'{-a,--assert}'[Run a standalone assert function]:function:' \
62+
'(-e --env --boot)'{-e,--env,--boot}'[Load a custom env/bootstrap file]:file:_files' \
63+
'(-f --filter)'{-f,--filter}'[Only run tests matching the name]:name:' \
64+
'--tag[Only run tests with matching @tag]:tag:' \
65+
'--exclude-tag[Skip tests with matching @tag]:tag:' \
66+
'--log-junit[Write JUnit XML report]:file:_files' \
67+
'--report-junit[Write JUnit XML report]:file:_files' \
68+
'--log-gha[Write GitHub Actions workflow-commands log]:file:_files' \
69+
'(-j --jobs)'{-j,--jobs}'[Max N parallel jobs, or auto]:jobs:(auto)' \
70+
'(-p --parallel)'{-p,--parallel}'[Run tests in parallel]' \
71+
'--no-parallel[Run tests sequentially]' \
72+
'(-r --report-html)'{-r,--report-html}'[Write HTML report]:file:_files' \
73+
'--report-tap[Write TAP version 13 report]:file:_files' \
74+
'--report-json[Write machine-readable JSON report]:file:_files' \
75+
'(-s --simple)'{-s,--simple}'[Simple output with dots]' \
76+
'--detailed[Detailed output, the default]' \
77+
'--output[Output format]:format:(tap)' \
78+
'(-R --run-all)'{-R,--run-all}'[Run all assertions without stopping on first failure]' \
79+
'(-S --stop-on-failure)'{-S,--stop-on-failure}'[Stop on first failure]' \
80+
'--test-timeout[Fail a test running longer than N seconds]:seconds:' \
81+
'--retry[Rerun a failed test up to N extra times]:count:' \
82+
'--random-order[Randomize test execution order]' \
83+
'--seed[Seed for random order]:seed:' \
84+
'--shard[Run shard i of n]:shard:' \
85+
'--rerun-failed[Replay only the tests that failed on the last run]' \
86+
'(-vvv --verbose)'{-vvv,--verbose}'[Show execution details]' \
87+
'--debug[Enable shell debug mode]::file:_files' \
88+
'--no-output[Suppress all output]' \
89+
'--failures-only[Only show failures]' \
90+
'--fail-on-risky[Treat tests without assertions as failures]' \
91+
'--profile[Report the slowest tests]' \
92+
'--no-progress[Suppress real-time progress]' \
93+
'--show-output[Show test output on failure]' \
94+
'--no-output-on-failure[Hide test output on failure]' \
95+
'--show-skipped[Show skipped tests summary]' \
96+
'--show-incomplete[Show incomplete tests summary]' \
97+
'--strict[Enable strict shell mode]' \
98+
'--skip-env-file[Skip .env loading]' \
99+
'(-l --login)'{-l,--login}'[Run tests in login shell context]' \
100+
'(-w --watch)'{-w,--watch}'[Watch for changes and rerun tests]' \
101+
'--no-color[Disable colored output]' \
102+
'--coverage[Enable code coverage tracking]' \
103+
'--coverage-paths[Paths to track]:paths:_files' \
104+
'--coverage-exclude[Coverage exclusion patterns]:pattern:' \
105+
'--coverage-report[LCOV output path]:file:_files' \
106+
'--coverage-report-html[Generate HTML coverage report]::dir:_files' \
107+
'--coverage-min[Minimum coverage threshold]:percent:' \
108+
'--no-coverage-report[Console coverage output only]' \
109+
'(-h --help)'{-h,--help}'[Show help message]' \
110+
'*:path:_files' && ret=0
111+
112+
return ret
113+
}
114+
115+
_bashunit "$@"

completions/bashunit.bash

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
# bash completion for bashunit -*- shell-script -*-
2+
#
3+
# Install (bash-completion):
4+
# cp completions/bashunit.bash /usr/local/etc/bash_completion.d/bashunit
5+
# or source it from your ~/.bashrc:
6+
# source /path/to/bashunit/completions/bashunit.bash
7+
#
8+
# Kept in sync with src/main.sh by tests/unit/completions_test.sh (anti-drift).
9+
# Works on bash 3.2+ (plain compgen -W, no bash-completion helpers required).
10+
11+
_BASHUNIT_COMPLETIONS_SUBCOMMANDS="test bench doc init learn upgrade assert watch"
12+
13+
_BASHUNIT_COMPLETIONS_TEST_OPTS="--assert --boot --coverage --coverage-exclude \
14+
--coverage-min --coverage-paths --coverage-report --coverage-report-html \
15+
--debug --detailed --env --exclude-tag --fail-on-risky --failures-only \
16+
--filter --help --jobs --log-gha --log-junit --login --no-color \
17+
--no-coverage-report --no-output --no-output-on-failure --no-parallel \
18+
--no-progress --output --parallel --profile --random-order --report-html \
19+
--report-json --report-junit --report-tap --rerun-failed --retry --run-all \
20+
--seed --shard --show-incomplete --show-output --show-skipped --simple \
21+
--skip-env-file --stop-on-failure --strict --tag --test-timeout --verbose \
22+
--watch -R -S -a -e -f -h -j -l -p -r -s -vvv -w"
23+
24+
_BASHUNIT_COMPLETIONS_ASSERT_FNS="assert_array_contains assert_array_length \
25+
assert_array_not_contains assert_arrays_equal assert_command_not_found \
26+
assert_contains assert_contains_ignore_case assert_date_after \
27+
assert_date_before assert_date_equals assert_date_within_delta \
28+
assert_date_within_range assert_directory_exists assert_directory_not_exists \
29+
assert_duration assert_duration_greater_than assert_duration_less_than \
30+
assert_empty assert_equals assert_exec assert_exit_code assert_false \
31+
assert_file_contains assert_file_exists assert_file_not_contains \
32+
assert_file_not_exists assert_file_permissions assert_files_equals \
33+
assert_files_not_equals assert_general_error assert_greater_or_equal_than \
34+
assert_greater_than assert_is_directory assert_is_directory_empty \
35+
assert_is_directory_not_empty assert_is_directory_not_readable \
36+
assert_is_directory_not_writable assert_is_directory_readable \
37+
assert_is_directory_writable assert_is_file assert_is_file_empty \
38+
assert_json_contains assert_json_equals assert_json_key_exists \
39+
assert_less_or_equal_than assert_less_than assert_line_count \
40+
assert_match_snapshot assert_match_snapshot_ignore_colors assert_matches \
41+
assert_not_contains assert_not_empty assert_not_equals assert_not_matches \
42+
assert_not_same assert_same assert_string_ends_with \
43+
assert_string_matches_format assert_string_not_ends_with \
44+
assert_string_not_matches_format assert_string_not_starts_with \
45+
assert_string_starts_with assert_successful_code assert_true \
46+
assert_unsuccessful_code assert_within_delta"
47+
48+
# compgen output is split into COMPREPLY words on purpose; mapfile/read -a would need bash 4+.
49+
# shellcheck disable=SC2207
50+
_bashunit_completions() {
51+
local cur prev
52+
COMPREPLY=()
53+
cur="${COMP_WORDS[COMP_CWORD]}"
54+
prev="${COMP_WORDS[COMP_CWORD - 1]}"
55+
56+
# Value hints for options that take an argument.
57+
case "$prev" in
58+
--output)
59+
COMPREPLY=($(compgen -W "tap" -- "$cur"))
60+
return 0
61+
;;
62+
-j | --jobs)
63+
COMPREPLY=($(compgen -W "auto" -- "$cur"))
64+
return 0
65+
;;
66+
-e | --env | --boot)
67+
COMPREPLY=($(compgen -f -- "$cur"))
68+
return 0
69+
;;
70+
-f | --filter | --tag | --exclude-tag | --retry | --seed | --shard | --test-timeout)
71+
return 0
72+
;;
73+
esac
74+
75+
# `bashunit assert <fn>` completes the public assertion names.
76+
if [ "$COMP_CWORD" -ge 2 ] && [ "${COMP_WORDS[1]}" = "assert" ]; then
77+
COMPREPLY=($(compgen -W "$_BASHUNIT_COMPLETIONS_ASSERT_FNS" -- "$cur"))
78+
return 0
79+
fi
80+
81+
# First word: subcommands (plus flags, since `test` is the default command).
82+
if [ "$COMP_CWORD" -eq 1 ] && [ "${cur#-}" = "$cur" ]; then
83+
COMPREPLY=($(compgen -W "$_BASHUNIT_COMPLETIONS_SUBCOMMANDS" -- "$cur"))
84+
# Also offer test files/dirs, as `bashunit tests/` is the common shorthand.
85+
COMPREPLY+=($(compgen -f -- "$cur"))
86+
return 0
87+
fi
88+
89+
case "$cur" in
90+
-*)
91+
COMPREPLY=($(compgen -W "$_BASHUNIT_COMPLETIONS_TEST_OPTS" -- "$cur"))
92+
;;
93+
*)
94+
COMPREPLY=($(compgen -f -- "$cur"))
95+
;;
96+
esac
97+
return 0
98+
}
99+
100+
complete -o filenames -F _bashunit_completions bashunit 2>/dev/null || true

docs/installation.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -352,6 +352,33 @@ Either way you get bashunit updates as routine pull requests — no manual re-pi
352352
See bashunit's own pipeline for a real example: https://github.com/TypedDevs/bashunit/blob/main/.github/workflows/tests.yml
353353
:::
354354

355+
## Shell completion
356+
357+
bashunit ships tab-completion scripts for bash and zsh under
358+
[`completions/`](https://github.com/TypedDevs/bashunit/tree/main/completions)
359+
— subcommands, all `test` flags (with value hints like `--jobs auto` and
360+
`--output tap`), and the assertion names after `bashunit assert`.
361+
362+
::: code-group
363+
```bash [bash]
364+
# With bash-completion installed (path may vary by OS):
365+
cp completions/bashunit.bash /usr/local/etc/bash_completion.d/bashunit
366+
367+
# Or source it directly from your ~/.bashrc:
368+
source /path/to/bashunit/completions/bashunit.bash
369+
```
370+
```zsh [zsh]
371+
# Copy into any directory in your $fpath, e.g.:
372+
cp completions/_bashunit /usr/local/share/zsh/site-functions/_bashunit
373+
374+
# then restart zsh (or reinitialize completions):
375+
autoload -Uz compinit && compinit
376+
```
377+
:::
378+
379+
The scripts are kept honest by an anti-drift test in CI: adding a flag to
380+
bashunit without updating the completions fails the build.
381+
355382
## Related
356383

357384
- [Quickstart](/quickstart) - write and run your first test

tests/unit/completions_test.sh

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
#!/usr/bin/env bash
2+
# shellcheck disable=SC2317
3+
4+
# Anti-drift contract: the static completion scripts under completions/ must
5+
# stay in sync with the flags parsed by the test subcommand in src/main.sh,
6+
# the subcommand list in the bashunit entrypoint, and the public assertions.
7+
8+
BASH_COMPLETION_FILE="completions/bashunit.bash"
9+
ZSH_COMPLETION_FILE="completions/_bashunit"
10+
11+
# Flags accepted by cmd_test, straight from the option-parsing case arms.
12+
function completions_expected_test_flags() {
13+
awk '/# Parse test-specific options/,/^ done$/' src/main.sh |
14+
grep -E '^[[:space:]]+--?[a-zA-Z][a-zA-Z0-9-]*( \| --?[a-zA-Z][a-zA-Z0-9-]*)*\)' |
15+
sed 's/)$//' | tr -d ' ' | tr '|' '\n' |
16+
LC_ALL=C sort -u
17+
}
18+
19+
function completions_expected_assert_functions() {
20+
grep -hoE '^function assert_[a-z_0-9]+' src/assert*.sh |
21+
sed 's/^function //' | LC_ALL=C sort -u
22+
}
23+
24+
# Flags advertised by the bash completion script (single source variable).
25+
function completions_bash_flags() {
26+
(
27+
# shellcheck source=/dev/null
28+
source "$BASH_COMPLETION_FILE" 2>/dev/null
29+
echo "$_BASHUNIT_COMPLETIONS_TEST_OPTS" | tr ' ' '\n' | grep -v '^$' | LC_ALL=C sort -u
30+
)
31+
}
32+
33+
# Flags advertised by the zsh completion script: strip [descriptions], then
34+
# collect every -x/--long token.
35+
function completions_zsh_flags() {
36+
# Each punctuation char maps to a space (char-by-char); the repeated spaces are intentional.
37+
# shellcheck disable=SC2020
38+
sed 's/\[[^]]*\]//g' "$ZSH_COMPLETION_FILE" |
39+
tr '{}(),"'"'"':' ' ' | tr ' \t' '\n\n' |
40+
grep -E '^--?[a-zA-Z][a-zA-Z0-9-]*$' |
41+
LC_ALL=C sort -u
42+
}
43+
44+
function test_bash_completion_script_exists_and_passes_syntax_check() {
45+
assert_file_exists "$BASH_COMPLETION_FILE"
46+
assert_successful_code "$(bash -n "$BASH_COMPLETION_FILE" 2>&1)"
47+
}
48+
49+
function test_zsh_completion_script_exists_and_passes_syntax_check() {
50+
assert_file_exists "$ZSH_COMPLETION_FILE"
51+
if ! command -v zsh >/dev/null 2>&1; then
52+
bashunit::skip "zsh not available" && return
53+
fi
54+
assert_successful_code "$(zsh -n "$ZSH_COMPLETION_FILE" 2>&1)"
55+
}
56+
57+
function test_bash_completion_flags_match_main_sh() {
58+
local expected actual
59+
expected=$(completions_expected_test_flags)
60+
actual=$(completions_bash_flags)
61+
62+
assert_same "$expected" "$actual"
63+
}
64+
65+
function test_zsh_completion_flags_match_main_sh() {
66+
local expected actual
67+
expected=$(completions_expected_test_flags)
68+
actual=$(completions_zsh_flags)
69+
70+
assert_same "$expected" "$actual"
71+
}
72+
73+
function test_bash_completion_lists_all_subcommands() {
74+
local subcommands
75+
subcommands=$(
76+
# shellcheck source=/dev/null
77+
source "$BASH_COMPLETION_FILE" 2>/dev/null
78+
echo "$_BASHUNIT_COMPLETIONS_SUBCOMMANDS"
79+
)
80+
81+
local sub
82+
for sub in test bench doc init learn upgrade assert watch; do
83+
assert_contains "$sub" "$subcommands"
84+
done
85+
}
86+
87+
function test_zsh_completion_lists_all_subcommands() {
88+
local content
89+
content=$(cat "$ZSH_COMPLETION_FILE")
90+
91+
local sub
92+
for sub in test bench doc init learn upgrade assert watch; do
93+
assert_contains "$sub" "$content"
94+
done
95+
}
96+
97+
function test_bash_completion_assert_functions_match_src() {
98+
local expected actual
99+
expected=$(completions_expected_assert_functions)
100+
actual=$(
101+
# shellcheck source=/dev/null
102+
source "$BASH_COMPLETION_FILE" 2>/dev/null
103+
echo "$_BASHUNIT_COMPLETIONS_ASSERT_FNS" | tr ' ' '\n' | grep -v '^$' | LC_ALL=C sort -u
104+
)
105+
106+
assert_same "$expected" "$actual"
107+
}

0 commit comments

Comments
 (0)