From 37b65bf3e16696ac52764e0d0d273861a6a872c3 Mon Sep 17 00:00:00 2001 From: Chemaclass Date: Thu, 29 Oct 2026 23:00:54 +0100 Subject: [PATCH 1/3] fix(runner): scan functions without parentheses Closes #1422 --- CHANGELOG.md | 2 ++ src/helper/discovery.sh | 29 +++++++---------- src/helper/provider.sh | 7 +++-- src/helper/tags.sh | 5 +-- .../bashunit_test_function_names_test.sh | 30 ++++++++++++++++++ tests/unit/helper/annotations_test.sh | 31 +++++++++++++++++++ tests/unit/helper/helper_test.sh | 14 +++++++++ tests/unit/helper/tags_map_test.sh | 14 +++++++++ 8 files changed, 110 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 067c59326..d306e4ae9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ ### Fixed +- Annotations, tags, header counts and duplicate checks support functions declared without parentheses (#1422) + - `init` writes the bootstrap path and a custom tests directory to `.env` quoted, so a directory with a space runs afterwards instead of breaking every next run (#1411) - Failure source context stays inside a one-line test instead of listing the next function's assertions (#1420) - Parallel runs count tests under relative hidden paths and keep their results separate from matching visible paths (#1393) diff --git a/src/helper/discovery.sh b/src/helper/discovery.sh index f12fca458..0c2737480 100644 --- a/src/helper/discovery.sh +++ b/src/helper/discovery.sh @@ -17,23 +17,18 @@ function bashunit::helper::check_duplicate_functions() { # list itself, so no `sort` fork is needed to keep the output deterministic. local duplicates duplicates=$(awk ' - /^[[:space:]]*(function[[:space:]]+)?test[a-zA-Z_][a-zA-Z0-9_]*[[:space:]]*\(\)[[:space:]]*(\{|$)/ { - for (i = 1; i <= NF; i++) { - if ($i ~ /^test[a-zA-Z_][a-zA-Z0-9_]*\(\)$/) { - name = $i - gsub(/\(\)/, "", name) - if (++seen[name] == 2) { - dup[name] = 1 - } - # Recorded for every occurrence, the first included: the report needs - # both definitions to be worth anything. - if (lines[name] == "") { - lines[name] = FNR - } else { - lines[name] = lines[name] ", " FNR - } - break - } + /^[[:space:]]*function[[:space:]]+test[a-zA-Z_][a-zA-Z0-9_]*[[:space:]]*(\(\)|\{|#|\(|$)/ || + /^[[:space:]]*test[a-zA-Z_][a-zA-Z0-9_]*[[:space:]]*\(\)/ { + name = $0 + sub(/^[[:space:]]*(function[[:space:]]+)?/, "", name) + sub(/[[:space:]({#].*/, "", name) + if (++seen[name] == 2) { + dup[name] = 1 + } + if (lines[name] == "") { + lines[name] = FNR + } else { + lines[name] = lines[name] ", " FNR } } END { diff --git a/src/helper/provider.sh b/src/helper/provider.sh index fae890912..34a4e6d0e 100644 --- a/src/helper/provider.sh +++ b/src/helper/provider.sh @@ -150,11 +150,12 @@ function bashunit::helper::build_provider_map() { # Any other comment keeps the block open, the same rule @tag follows. /^[[:space:]]*#/ { next } { - is_fn = match($0, /^[[:space:]]*(function[[:space:]]+)?[A-Za-z_][A-Za-z0-9_:]*[[:space:]]*\(\)/) + is_fn = match($0, /^[[:space:]]*function[[:space:]]+[A-Za-z_][A-Za-z0-9_:]*[[:space:]]*(\(\)|\{|#|\(|$)/) || + match($0, /^[[:space:]]*[A-Za-z_][A-Za-z0-9_:]*[[:space:]]*\(\)/) if (is_fn) { fn = $0 sub(/^[[:space:]]*(function[[:space:]]+)?/, "", fn) - sub(/[[:space:]]*\(\).*/, "", fn) + sub(/[[:space:]({#].*/, "", fn) } # A definition at column 0 is one this scan can be sure the file defines @@ -162,7 +163,7 @@ function bashunit::helper::build_provider_map() { # Anything that could define a function this scan cannot see -- eval, # a nested source, an indented (so conditional or nested) definition -- # marks the file dynamic and sends the count back to sourcing. - if (match($0, /^(function[[:space:]]+)?[A-Za-z_][A-Za-z0-9_:]*[[:space:]]*\(\)/)) { + if (is_fn && $0 !~ /^[[:space:]]/) { # Accumulated, not emitted per definition: the read loop below runs # once per row, and a row per function cost about as much as counting # statically saved. diff --git a/src/helper/tags.sh b/src/helper/tags.sh index 5fd590234..2746f8630 100644 --- a/src/helper/tags.sh +++ b/src/helper/tags.sh @@ -89,10 +89,11 @@ function bashunit::helper::build_tags_map() { next } /^[[:space:]]*#/ { next } - /^[[:space:]]*(function[[:space:]]+)?[A-Za-z_][A-Za-z0-9_:]*[[:space:]]*\(\)/ { + /^[[:space:]]*function[[:space:]]+[A-Za-z_][A-Za-z0-9_:]*[[:space:]]*(\(\)|\{|#|\(|$)/ || + /^[[:space:]]*[A-Za-z_][A-Za-z0-9_:]*[[:space:]]*\(\)/ { fn = $0 sub(/^[[:space:]]*(function[[:space:]]+)?/, "", fn) - sub(/[[:space:]]*\(\).*/, "", fn) + sub(/[[:space:]({#].*/, "", fn) # Buffered rather than printed here so a `# @tags` line placed below the # functions still applies to them (single pass, order preserved). order[n] = fn diff --git a/tests/acceptance/bashunit_test_function_names_test.sh b/tests/acceptance/bashunit_test_function_names_test.sh index 7ab03e9da..9debc804a 100644 --- a/tests/acceptance/bashunit_test_function_names_test.sh +++ b/tests/acceptance/bashunit_test_function_names_test.sh @@ -74,3 +74,33 @@ function test_real_3() { assert_same 1 1; }') assert_contains "test_real_3" "$output" assert_not_contains "testdata_path" "$output" } + +function provide_definition_annotation_modes() { + bashunit::data_set --no-parallel + bashunit::data_set --parallel +} + +# @data_provider provide_definition_annotation_modes +function test_mixed_definition_forms_keep_annotations_tags_and_header_counts() { + local fixture="$WORKDIR/mixed_test.sh" + printf '%s\n' 'function provide_keyword_rows() { bashunit::data_set one; bashunit::data_set two; } +function test_keyword_with_parentheses() { assert_true true; } +test_only_parentheses() { assert_true true; } +# @data_provider provide_keyword_rows +function test_keyword_provider { assert_same 1 "$#"; } +# @skip not ready +function test_keyword_skipped { assert_true false; } +# @tag slow +function test_keyword_tagged { assert_true true; }' >"$fixture" + local output + output=$("$BASHUNIT_BIN" "$1" --skip-env-file --no-color "$fixture" 2>&1) + assert_successful_code "$?" + assert_contains 'Tests: 6' "$output" + assert_contains '5 passed' "$output" + assert_contains '1 skipped' "$output" + assert_contains '6 total' "$output" + output=$("$BASHUNIT_BIN" "$1" --skip-env-file --no-color --tag slow "$fixture" 2>&1) + assert_successful_code "$?" + assert_contains '1 passed' "$output" + assert_contains '1 total' "$output" +} diff --git a/tests/unit/helper/annotations_test.sh b/tests/unit/helper/annotations_test.sh index 6ab549edb..b3bc96a60 100644 --- a/tests/unit/helper/annotations_test.sh +++ b/tests/unit/helper/annotations_test.sh @@ -300,3 +300,34 @@ ${FN}() { :; }" assert_empty "$(compgen -A variable _BASHUNIT_METADATA_ANNOT_)" assert_empty "$(compgen -A variable _BASHUNIT_METADATA_PROVIDER_)" } + +function test_keyword_only_definitions_keep_provider_and_runtime_annotations() { + build_map "# @timeout 7 +# @retry 2 +# @data_provider rows +function ${FN} { + : +}" + bashunit::helper::provider_for_function "$FN" + assert_same rows "$_BASHUNIT_PROVIDER_FN_OUT" + bashunit::helper::annotations_for_function "$FN" + assert_same 7 "$_BASHUNIT_ANNOT_TIMEOUT_OUT" + assert_same 2 "$_BASHUNIT_ANNOT_RETRY_OUT" + assert_same false "$_BASHUNIT_PROVIDER_MAP_DYNAMIC" +} + +function test_keyword_only_definition_keeps_skip_reason() { + build_map "# @skip not ready +function ${OTHER_FN} +{ + : +}" + bashunit::helper::annotations_for_function "$OTHER_FN" + assert_same true "$_BASHUNIT_ANNOT_SKIP_OUT" + assert_same 'not ready' "$_BASHUNIT_ANNOT_REASON_OUT" +} + +function test_indented_keyword_only_definition_keeps_dynamic_counting() { + build_map " function ${FN} { :; }" + assert_same true "$_BASHUNIT_PROVIDER_MAP_DYNAMIC" +} diff --git a/tests/unit/helper/helper_test.sh b/tests/unit/helper/helper_test.sh index 7d45adf9b..c5f3a690a 100644 --- a/tests/unit/helper/helper_test.sh +++ b/tests/unit/helper/helper_test.sh @@ -1052,3 +1052,17 @@ function test_no_exclude_filter_keeps_previous_behaviour() { assert_same "prefix_alpha prefix_beta" \ "$(_with_exclude_filter "" "prefix" "" "${functions[*]}")" } + +function test_duplicate_keyword_only_definitions_are_reported() { + local file fn=test_duplicate_without_parens + file="$(bashunit::temp_file)" + printf 'function %s { :; }\nfunction %s\n{ :; }\n' "$fn" "$fn" >"$file" + assert_general_error "$(bashunit::helper::check_duplicate_functions "$file")" +} + +function test_duplicate_definitions_with_comments_and_subshell_bodies_are_reported() { + local file fn=test_duplicate_body_forms + file="$(bashunit::temp_file)" + printf 'function %s () # a note\n{ :; }\n%s() ( : )\n' "$fn" "$fn" >"$file" + assert_general_error "$(bashunit::helper::check_duplicate_functions "$file")" +} diff --git a/tests/unit/helper/tags_map_test.sh b/tests/unit/helper/tags_map_test.sh index 4908bf3e4..d47c95ff0 100644 --- a/tests/unit/helper/tags_map_test.sh +++ b/tests/unit/helper/tags_map_test.sh @@ -93,3 +93,17 @@ function test_singular_tag_is_still_function_scoped() { assert_same "" \ "$(tags_for "$FIXTURE_TAGS_MAP" "test_no_tags")" } + +function test_tag_applies_to_keyword_only_definition() { + local file fn=test_keyword_only_tagged + file="$(bashunit::temp_file)" + printf '# @tag slow\nfunction %s\n{ :; }\n' "$fn" >"$file" + assert_same slow "$(tags_for "$file" "$fn")" +} + +function test_tag_does_not_turn_a_bare_word_into_a_definition() { + local file fn=test_plain_word + file="$(bashunit::temp_file)" + printf '# @tag slow\n%s\n' "$fn" >"$file" + assert_empty "$(tags_for "$file" "$fn")" +} From 42a597dcb8ab3bee9754bee9fa37a0199c444cc2 Mon Sep 17 00:00:00 2001 From: Chemaclass Date: Thu, 29 Oct 2026 23:10:16 +0100 Subject: [PATCH 2/3] fix(cli): quote spaced paths in init workflows Closes #1425 --- CHANGELOG.md | 2 ++ docs/command-line.md | 2 +- src/cli/init.sh | 18 ++++++++++++++ tests/acceptance/bashunit_init_test.sh | 34 ++++++++++++++++++++++++++ 4 files changed, 55 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 067c59326..fd9ba48e8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ ### Fixed +- Generated workflows run test directories containing whitespace as a single path without requiring a committed `.env` (#1425) + - `init` writes the bootstrap path and a custom tests directory to `.env` quoted, so a directory with a space runs afterwards instead of breaking every next run (#1411) - Failure source context stays inside a one-line test instead of listing the next function's assertions (#1420) - Parallel runs count tests under relative hidden paths and keep their results separate from matching visible paths (#1393) diff --git a/docs/command-line.md b/docs/command-line.md index bd116df3c..be94f74b0 100644 --- a/docs/command-line.md +++ b/docs/command-line.md @@ -1581,7 +1581,7 @@ Reports an error if the two variables are not equal... > `bashunit init [directory]` -Initialize a new test directory with sample files. +Initialize a new test directory with sample files. A directory containing whitespace gets a separate, quoted workflow command so CI can run it without a committed `.env` file. ::: code-group ```bash [Examples] diff --git a/src/cli/init.sh b/src/cli/init.sh index ba1080831..084ad72cd 100644 --- a/src/cli/init.sh +++ b/src/cli/init.sh @@ -69,9 +69,27 @@ jobs: steps: - uses: actions/checkout@v4 - uses: TypedDevs/bashunit@v0 +SH + case "$tests_dir" in + *[[:space:]]*) + local workflow_path + workflow_path=$(printf '%q' "$tests_dir") + local expression_start='${{' literal_expression_start='\044{{' + # Actions expands expressions before Bash parses its quotes. + workflow_path=${workflow_path//"$expression_start"/$literal_expression_start} + cat >>"$workflow_file" <>"$workflow_file" < Created $workflow_file" fi diff --git a/tests/acceptance/bashunit_init_test.sh b/tests/acceptance/bashunit_init_test.sh index 6a776e875..a09db530c 100644 --- a/tests/acceptance/bashunit_init_test.sh +++ b/tests/acceptance/bashunit_init_test.sh @@ -289,3 +289,37 @@ function test_a_healthy_env_flag_file_still_loads() { assert_same 0 "$ec" assert_contains "1 passed" "$output" } + +function provide_workflow_directories_with_whitespace() { + bashunit::data_set 'my tests' + bashunit::data_set $'my\ttests' + bashunit::data_set $'my\t${{ github.repository }}' + bashunit::data_set "my tests'\"; touch injected; #" +} + +# @data_provider provide_workflow_directories_with_whitespace +function test_generated_workflow_runs_the_whole_directory_without_an_env_file() { + local directory=$1 command output code=0 + (cd "$TMP_DIR" && "$BASHUNIT_PATH" init "$directory") >/dev/null + command=$(awk '/^ run: \|$/ { getline; sub(/^ /, ""); print; exit }' \ + "$TMP_DIR/.github/workflows/tests.yml") + assert_not_empty "$command" + assert_file_not_contains "$TMP_DIR/.github/workflows/tests.yml" '${{' + if [ -z "$command" ]; then + return + fi + mkdir -p "$TMP_DIR/lib" + printf '#!/usr/bin/env bash\nexec %q "$@"\n' "$BASHUNIT_PATH" >"$TMP_DIR/lib/bashunit" + chmod +x "$TMP_DIR/lib/bashunit" + rm "$TMP_DIR/.env" + output=$(cd "$TMP_DIR" && bash -c "$command" 2>&1) || code=$? + assert_same 0 "$code" + assert_contains '1 passed' "$output" + assert_file_not_exists "$TMP_DIR/injected" +} + +function test_generated_workflow_keeps_the_existing_args_for_a_simple_directory() { + (cd "$TMP_DIR" && "$BASHUNIT_PATH" init custom) >/dev/null + assert_file_contains "$TMP_DIR/.github/workflows/tests.yml" ' args: custom' + assert_file_not_contains "$TMP_DIR/.github/workflows/tests.yml" ' run: |' +} From 449a6c799533577427b10bc01f8f1c19f1c22d2d Mon Sep 17 00:00:00 2001 From: Chemaclass Date: Fri, 30 Oct 2026 07:34:36 +0100 Subject: [PATCH 3/3] test(runner): cover comments before function braces --- tests/unit/helper/helper_test.sh | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/tests/unit/helper/helper_test.sh b/tests/unit/helper/helper_test.sh index 382328cd5..81fb98c9d 100644 --- a/tests/unit/helper/helper_test.sh +++ b/tests/unit/helper/helper_test.sh @@ -145,6 +145,19 @@ function test_check_duplicate_functions_with_the_brace_on_the_next_line() { assert_contains "test_twice (lines 1, 5)" "$detail" } +function test_check_duplicate_functions_with_a_comment_before_the_brace() { + local file detail + file="$(bashunit::temp_file duplicate_comment_line).sh" + printf 'function %s() # first\n{\n :\n}\nfunction %s() # second\n{\n :\n}\n' \ + test_twice test_twice >"$file" + + detail=$( + bashunit::helper::check_duplicate_functions "$file" >/dev/null 2>&1 || true + bashunit::state::get_duplicated_function_details + ) + assert_contains "test_twice (lines 1, 5)" "$detail" +} + function test_check_duplicate_functions_without_duplicates() { local file file="$(bashunit::current_dir)/../fixtures/no_duplicate_functions.sh"