From 5b0be3981776d2ebbfcdcff6f42b3940169743cb Mon Sep 17 00:00:00 2001 From: Chemaclass Date: Tue, 14 Jul 2026 07:19:20 +0200 Subject: [PATCH 1/2] feat(cli): bash and zsh completion scripts with an anti-drift test Ship static completion scripts under completions/ for bash (completions/bashunit.bash, plain compgen -W, works on bash 3.2) and zsh (completions/_bashunit, _arguments spec): the 8 subcommands, every flag of the test subcommand with value hints (--jobs auto, --output tap, file completion for -e/--env and report paths), and the 66 public assertion names after 'bashunit assert'. An anti-drift unit test extracts the flag list from the option-parsing case arms in src/main.sh and compares it against both scripts (plus the assertion list against src/assert*.sh), so a new CLI flag fails CI until the completions are updated. Includes bash -n / zsh -n syntax checks. Docs installation section and README pointer added. Closes #778 --- CHANGELOG.md | 1 + README.md | 2 + completions/_bashunit | 115 +++++++++++++++++++++++++++++++++ completions/bashunit.bash | 98 ++++++++++++++++++++++++++++ docs/installation.md | 27 ++++++++ tests/unit/completions_test.sh | 105 ++++++++++++++++++++++++++++++ 6 files changed, 348 insertions(+) create mode 100644 completions/_bashunit create mode 100644 completions/bashunit.bash create mode 100644 tests/unit/completions_test.sh diff --git a/CHANGELOG.md b/CHANGELOG.md index 91b9d6c9..3842a5b9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ - `--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 ### Added +- 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) - `--rerun-failed` (env: `BASHUNIT_RERUN_FAILED`) replays only the tests that failed on the previous run. Every run records its failing tests as `:` 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) - 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) - `--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) diff --git a/README.md b/README.md index f2fa328a..1abebadd 100644 --- a/README.md +++ b/README.md @@ -83,6 +83,8 @@ bashunit ships ~60 assertions across many families. One representative example p Full documentation, covering installation options, every feature and examples, lives at [bashunit.com](https://bashunit.com). +Shell tab-completion for bash and zsh is available under [`completions/`](completions/) — see the [installation docs](https://bashunit.com/installation#shell-completion). + ## Contribute Issues, ideas and pull requests are welcome. diff --git a/completions/_bashunit b/completions/_bashunit new file mode 100644 index 00000000..5c3557f8 --- /dev/null +++ b/completions/_bashunit @@ -0,0 +1,115 @@ +#compdef bashunit +# zsh completion for bashunit +# +# Install: copy this file as _bashunit into a directory in your $fpath, e.g. +# cp completions/_bashunit /usr/local/share/zsh/site-functions/_bashunit +# then restart zsh (or reinitialize completions with compinit). +# +# Kept in sync with src/main.sh by tests/unit/completions_test.sh (anti-drift). + +_bashunit() { + local curcontext="$curcontext" ret=1 + + # `bashunit assert ` completes the public assertion names. + if (( CURRENT == 3 )) && [ "${words[2]}" = "assert" ]; then + local -a assert_fns + assert_fns=( + assert_array_contains assert_array_length assert_array_not_contains + assert_arrays_equal assert_command_not_found assert_contains + assert_contains_ignore_case assert_date_after assert_date_before + assert_date_equals assert_date_within_delta assert_date_within_range + assert_directory_exists assert_directory_not_exists assert_duration + assert_duration_greater_than assert_duration_less_than assert_empty + assert_equals assert_exec assert_exit_code assert_false + assert_file_contains assert_file_exists assert_file_not_contains + assert_file_not_exists assert_file_permissions assert_files_equals + assert_files_not_equals assert_general_error assert_greater_or_equal_than + assert_greater_than assert_is_directory assert_is_directory_empty + assert_is_directory_not_empty assert_is_directory_not_readable + assert_is_directory_not_writable assert_is_directory_readable + assert_is_directory_writable assert_is_file assert_is_file_empty + assert_json_contains assert_json_equals assert_json_key_exists + assert_less_or_equal_than assert_less_than assert_line_count + assert_match_snapshot assert_match_snapshot_ignore_colors assert_matches + assert_not_contains assert_not_empty assert_not_equals assert_not_matches + assert_not_same assert_same assert_string_ends_with + assert_string_matches_format assert_string_not_ends_with + assert_string_not_matches_format assert_string_not_starts_with + assert_string_starts_with assert_successful_code assert_true + assert_unsuccessful_code assert_within_delta + ) + compadd -a assert_fns + return 0 + fi + + if (( CURRENT == 2 )); then + local -a subcommands + subcommands=( + 'test:Run tests (default command)' + 'bench:Run benchmarks' + 'doc:Display assertion documentation' + 'init:Initialize a new test directory' + 'learn:Start interactive tutorial' + 'upgrade:Upgrade bashunit to latest version' + 'assert:Run standalone assertion' + 'watch:Watch files and rerun tests on change' + ) + _describe 'bashunit command' subcommands && ret=0 + fi + + _arguments \ + '(-a --assert)'{-a,--assert}'[Run a standalone assert function]:function:' \ + '(-e --env --boot)'{-e,--env,--boot}'[Load a custom env/bootstrap file]:file:_files' \ + '(-f --filter)'{-f,--filter}'[Only run tests matching the name]:name:' \ + '--tag[Only run tests with matching @tag]:tag:' \ + '--exclude-tag[Skip tests with matching @tag]:tag:' \ + '--log-junit[Write JUnit XML report]:file:_files' \ + '--report-junit[Write JUnit XML report]:file:_files' \ + '--log-gha[Write GitHub Actions workflow-commands log]:file:_files' \ + '(-j --jobs)'{-j,--jobs}'[Max N parallel jobs, or auto]:jobs:(auto)' \ + '(-p --parallel)'{-p,--parallel}'[Run tests in parallel]' \ + '--no-parallel[Run tests sequentially]' \ + '(-r --report-html)'{-r,--report-html}'[Write HTML report]:file:_files' \ + '--report-tap[Write TAP version 13 report]:file:_files' \ + '--report-json[Write machine-readable JSON report]:file:_files' \ + '(-s --simple)'{-s,--simple}'[Simple output with dots]' \ + '--detailed[Detailed output, the default]' \ + '--output[Output format]:format:(tap)' \ + '(-R --run-all)'{-R,--run-all}'[Run all assertions without stopping on first failure]' \ + '(-S --stop-on-failure)'{-S,--stop-on-failure}'[Stop on first failure]' \ + '--test-timeout[Fail a test running longer than N seconds]:seconds:' \ + '--retry[Rerun a failed test up to N extra times]:count:' \ + '--random-order[Randomize test execution order]' \ + '--seed[Seed for random order]:seed:' \ + '--shard[Run shard i of n]:shard:' \ + '--rerun-failed[Replay only the tests that failed on the last run]' \ + '(-vvv --verbose)'{-vvv,--verbose}'[Show execution details]' \ + '--debug[Enable shell debug mode]::file:_files' \ + '--no-output[Suppress all output]' \ + '--failures-only[Only show failures]' \ + '--fail-on-risky[Treat tests without assertions as failures]' \ + '--profile[Report the slowest tests]' \ + '--no-progress[Suppress real-time progress]' \ + '--show-output[Show test output on failure]' \ + '--no-output-on-failure[Hide test output on failure]' \ + '--show-skipped[Show skipped tests summary]' \ + '--show-incomplete[Show incomplete tests summary]' \ + '--strict[Enable strict shell mode]' \ + '--skip-env-file[Skip .env loading]' \ + '(-l --login)'{-l,--login}'[Run tests in login shell context]' \ + '(-w --watch)'{-w,--watch}'[Watch for changes and rerun tests]' \ + '--no-color[Disable colored output]' \ + '--coverage[Enable code coverage tracking]' \ + '--coverage-paths[Paths to track]:paths:_files' \ + '--coverage-exclude[Coverage exclusion patterns]:pattern:' \ + '--coverage-report[LCOV output path]:file:_files' \ + '--coverage-report-html[Generate HTML coverage report]::dir:_files' \ + '--coverage-min[Minimum coverage threshold]:percent:' \ + '--no-coverage-report[Console coverage output only]' \ + '(-h --help)'{-h,--help}'[Show help message]' \ + '*:path:_files' && ret=0 + + return ret +} + +_bashunit "$@" diff --git a/completions/bashunit.bash b/completions/bashunit.bash new file mode 100644 index 00000000..d7b59a31 --- /dev/null +++ b/completions/bashunit.bash @@ -0,0 +1,98 @@ +# bash completion for bashunit -*- shell-script -*- +# +# Install (bash-completion): +# cp completions/bashunit.bash /usr/local/etc/bash_completion.d/bashunit +# or source it from your ~/.bashrc: +# source /path/to/bashunit/completions/bashunit.bash +# +# Kept in sync with src/main.sh by tests/unit/completions_test.sh (anti-drift). +# Works on bash 3.2+ (plain compgen -W, no bash-completion helpers required). + +_BASHUNIT_COMPLETIONS_SUBCOMMANDS="test bench doc init learn upgrade assert watch" + +_BASHUNIT_COMPLETIONS_TEST_OPTS="--assert --boot --coverage --coverage-exclude \ +--coverage-min --coverage-paths --coverage-report --coverage-report-html \ +--debug --detailed --env --exclude-tag --fail-on-risky --failures-only \ +--filter --help --jobs --log-gha --log-junit --login --no-color \ +--no-coverage-report --no-output --no-output-on-failure --no-parallel \ +--no-progress --output --parallel --profile --random-order --report-html \ +--report-json --report-junit --report-tap --rerun-failed --retry --run-all \ +--seed --shard --show-incomplete --show-output --show-skipped --simple \ +--skip-env-file --stop-on-failure --strict --tag --test-timeout --verbose \ +--watch -R -S -a -e -f -h -j -l -p -r -s -vvv -w" + +_BASHUNIT_COMPLETIONS_ASSERT_FNS="assert_array_contains assert_array_length \ +assert_array_not_contains assert_arrays_equal assert_command_not_found \ +assert_contains assert_contains_ignore_case assert_date_after \ +assert_date_before assert_date_equals assert_date_within_delta \ +assert_date_within_range assert_directory_exists assert_directory_not_exists \ +assert_duration assert_duration_greater_than assert_duration_less_than \ +assert_empty assert_equals assert_exec assert_exit_code assert_false \ +assert_file_contains assert_file_exists assert_file_not_contains \ +assert_file_not_exists assert_file_permissions assert_files_equals \ +assert_files_not_equals assert_general_error assert_greater_or_equal_than \ +assert_greater_than assert_is_directory assert_is_directory_empty \ +assert_is_directory_not_empty assert_is_directory_not_readable \ +assert_is_directory_not_writable assert_is_directory_readable \ +assert_is_directory_writable assert_is_file assert_is_file_empty \ +assert_json_contains assert_json_equals assert_json_key_exists \ +assert_less_or_equal_than assert_less_than assert_line_count \ +assert_match_snapshot assert_match_snapshot_ignore_colors assert_matches \ +assert_not_contains assert_not_empty assert_not_equals assert_not_matches \ +assert_not_same assert_same assert_string_ends_with \ +assert_string_matches_format assert_string_not_ends_with \ +assert_string_not_matches_format assert_string_not_starts_with \ +assert_string_starts_with assert_successful_code assert_true \ +assert_unsuccessful_code assert_within_delta" + +_bashunit_completions() { + local cur prev + COMPREPLY=() + cur="${COMP_WORDS[COMP_CWORD]}" + prev="${COMP_WORDS[COMP_CWORD - 1]}" + + # Value hints for options that take an argument. + case "$prev" in + --output) + COMPREPLY=($(compgen -W "tap" -- "$cur")) + return 0 + ;; + -j | --jobs) + COMPREPLY=($(compgen -W "auto" -- "$cur")) + return 0 + ;; + -e | --env | --boot) + COMPREPLY=($(compgen -f -- "$cur")) + return 0 + ;; + -f | --filter | --tag | --exclude-tag | --retry | --seed | --shard | --test-timeout) + return 0 + ;; + esac + + # `bashunit assert ` completes the public assertion names. + if [ "$COMP_CWORD" -ge 2 ] && [ "${COMP_WORDS[1]}" = "assert" ]; then + COMPREPLY=($(compgen -W "$_BASHUNIT_COMPLETIONS_ASSERT_FNS" -- "$cur")) + return 0 + fi + + # First word: subcommands (plus flags, since `test` is the default command). + if [ "$COMP_CWORD" -eq 1 ] && [ "${cur#-}" = "$cur" ]; then + COMPREPLY=($(compgen -W "$_BASHUNIT_COMPLETIONS_SUBCOMMANDS" -- "$cur")) + # Also offer test files/dirs, as `bashunit tests/` is the common shorthand. + COMPREPLY+=($(compgen -f -- "$cur")) + return 0 + fi + + case "$cur" in + -*) + COMPREPLY=($(compgen -W "$_BASHUNIT_COMPLETIONS_TEST_OPTS" -- "$cur")) + ;; + *) + COMPREPLY=($(compgen -f -- "$cur")) + ;; + esac + return 0 +} + +complete -o filenames -F _bashunit_completions bashunit 2>/dev/null || true diff --git a/docs/installation.md b/docs/installation.md index bb0e6762..6e9cf478 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -352,6 +352,33 @@ Either way you get bashunit updates as routine pull requests — no manual re-pi See bashunit's own pipeline for a real example: https://github.com/TypedDevs/bashunit/blob/main/.github/workflows/tests.yml ::: +## Shell completion + +bashunit ships tab-completion scripts for bash and zsh under +[`completions/`](https://github.com/TypedDevs/bashunit/tree/main/completions) +— subcommands, all `test` flags (with value hints like `--jobs auto` and +`--output tap`), and the assertion names after `bashunit assert`. + +::: code-group +```bash [bash] +# With bash-completion installed (path may vary by OS): +cp completions/bashunit.bash /usr/local/etc/bash_completion.d/bashunit + +# Or source it directly from your ~/.bashrc: +source /path/to/bashunit/completions/bashunit.bash +``` +```zsh [zsh] +# Copy into any directory in your $fpath, e.g.: +cp completions/_bashunit /usr/local/share/zsh/site-functions/_bashunit + +# then restart zsh (or reinitialize completions): +autoload -Uz compinit && compinit +``` +::: + +The scripts are kept honest by an anti-drift test in CI: adding a flag to +bashunit without updating the completions fails the build. + ## Related - [Quickstart](/quickstart) - write and run your first test diff --git a/tests/unit/completions_test.sh b/tests/unit/completions_test.sh new file mode 100644 index 00000000..1052a27a --- /dev/null +++ b/tests/unit/completions_test.sh @@ -0,0 +1,105 @@ +#!/usr/bin/env bash +# shellcheck disable=SC2317 + +# Anti-drift contract: the static completion scripts under completions/ must +# stay in sync with the flags parsed by the test subcommand in src/main.sh, +# the subcommand list in the bashunit entrypoint, and the public assertions. + +BASH_COMPLETION_FILE="completions/bashunit.bash" +ZSH_COMPLETION_FILE="completions/_bashunit" + +# Flags accepted by cmd_test, straight from the option-parsing case arms. +function completions_expected_test_flags() { + awk '/# Parse test-specific options/,/^ done$/' src/main.sh | + grep -E '^[[:space:]]+--?[a-zA-Z][a-zA-Z0-9-]*( \| --?[a-zA-Z][a-zA-Z0-9-]*)*\)' | + sed 's/)$//' | tr -d ' ' | tr '|' '\n' | + LC_ALL=C sort -u +} + +function completions_expected_assert_functions() { + grep -hoE '^function assert_[a-z_0-9]+' src/assert*.sh | + sed 's/^function //' | LC_ALL=C sort -u +} + +# Flags advertised by the bash completion script (single source variable). +function completions_bash_flags() { + ( + # shellcheck source=/dev/null + source "$BASH_COMPLETION_FILE" 2>/dev/null + echo "$_BASHUNIT_COMPLETIONS_TEST_OPTS" | tr ' ' '\n' | grep -v '^$' | LC_ALL=C sort -u + ) +} + +# Flags advertised by the zsh completion script: strip [descriptions], then +# collect every -x/--long token. +function completions_zsh_flags() { + sed 's/\[[^]]*\]//g' "$ZSH_COMPLETION_FILE" | + tr '{}(),"'"'"':' ' ' | tr ' \t' '\n\n' | + grep -E '^--?[a-zA-Z][a-zA-Z0-9-]*$' | + LC_ALL=C sort -u +} + +function test_bash_completion_script_exists_and_passes_syntax_check() { + assert_file_exists "$BASH_COMPLETION_FILE" + assert_successful_code "$(bash -n "$BASH_COMPLETION_FILE" 2>&1)" +} + +function test_zsh_completion_script_exists_and_passes_syntax_check() { + assert_file_exists "$ZSH_COMPLETION_FILE" + if ! command -v zsh >/dev/null 2>&1; then + bashunit::skip "zsh not available" && return + fi + assert_successful_code "$(zsh -n "$ZSH_COMPLETION_FILE" 2>&1)" +} + +function test_bash_completion_flags_match_main_sh() { + local expected actual + expected=$(completions_expected_test_flags) + actual=$(completions_bash_flags) + + assert_same "$expected" "$actual" +} + +function test_zsh_completion_flags_match_main_sh() { + local expected actual + expected=$(completions_expected_test_flags) + actual=$(completions_zsh_flags) + + assert_same "$expected" "$actual" +} + +function test_bash_completion_lists_all_subcommands() { + local subcommands + subcommands=$( + # shellcheck source=/dev/null + source "$BASH_COMPLETION_FILE" 2>/dev/null + echo "$_BASHUNIT_COMPLETIONS_SUBCOMMANDS" + ) + + local sub + for sub in test bench doc init learn upgrade assert watch; do + assert_contains "$sub" "$subcommands" + done +} + +function test_zsh_completion_lists_all_subcommands() { + local content + content=$(cat "$ZSH_COMPLETION_FILE") + + local sub + for sub in test bench doc init learn upgrade assert watch; do + assert_contains "$sub" "$content" + done +} + +function test_bash_completion_assert_functions_match_src() { + local expected actual + expected=$(completions_expected_assert_functions) + actual=$( + # shellcheck source=/dev/null + source "$BASH_COMPLETION_FILE" 2>/dev/null + echo "$_BASHUNIT_COMPLETIONS_ASSERT_FNS" | tr ' ' '\n' | grep -v '^$' | LC_ALL=C sort -u + ) + + assert_same "$expected" "$actual" +} From 2218ffbcb0b147f9b5c701676327b2fd8d28440a Mon Sep 17 00:00:00 2001 From: Chemaclass Date: Tue, 14 Jul 2026 11:25:59 +0200 Subject: [PATCH 2/2] fix(cli): silence shellcheck SC2207/SC2020 in completions compgen word-splitting into COMPREPLY is intentional (mapfile needs bash 4+, scripts target 3.2); the tr char-map in the anti-drift test maps punctuation to spaces on purpose. Both flagged by CI shellcheck. --- completions/bashunit.bash | 2 ++ tests/unit/completions_test.sh | 2 ++ 2 files changed, 4 insertions(+) diff --git a/completions/bashunit.bash b/completions/bashunit.bash index d7b59a31..923b7d5e 100644 --- a/completions/bashunit.bash +++ b/completions/bashunit.bash @@ -45,6 +45,8 @@ assert_string_not_matches_format assert_string_not_starts_with \ assert_string_starts_with assert_successful_code assert_true \ assert_unsuccessful_code assert_within_delta" +# compgen output is split into COMPREPLY words on purpose; mapfile/read -a would need bash 4+. +# shellcheck disable=SC2207 _bashunit_completions() { local cur prev COMPREPLY=() diff --git a/tests/unit/completions_test.sh b/tests/unit/completions_test.sh index 1052a27a..b29c262f 100644 --- a/tests/unit/completions_test.sh +++ b/tests/unit/completions_test.sh @@ -33,6 +33,8 @@ function completions_bash_flags() { # Flags advertised by the zsh completion script: strip [descriptions], then # collect every -x/--long token. function completions_zsh_flags() { + # Each punctuation char maps to a space (char-by-char); the repeated spaces are intentional. + # shellcheck disable=SC2020 sed 's/\[[^]]*\]//g' "$ZSH_COMPLETION_FILE" | tr '{}(),"'"'"':' ' ' | tr ' \t' '\n\n' | grep -E '^--?[a-zA-Z][a-zA-Z0-9-]*$' |