diff --git a/CHANGELOG.md b/CHANGELOG.md index 020d0a7b..bcfd6a48 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ ## Unreleased +### Added +- Add inline filter syntax to run specific tests from a file + - `path::function_name` - filter tests by function name + - `path:line_number` - run the test containing the specified line + ### Changed - **BREAKING:** Introduce subcommand-based CLI architecture - `bashunit test [path]` - run tests (default, backwards compatible with `bashunit [path]`) diff --git a/docs/command-line.md b/docs/command-line.md index ce2f5961..f7b3e6d0 100644 --- a/docs/command-line.md +++ b/docs/command-line.md @@ -88,6 +88,38 @@ bashunit test tests/ --filter "user_login" ``` ::: +### Inline Filter Syntax + +You can also specify a filter directly in the file path using `::` or `:line` syntax: + +**Run a specific test by function name:** +> `bashunit test path::function_name` + +::: code-group +```bash [Exact match] +bashunit test tests/unit/example_test.sh::test_user_login +``` +```bash [Partial match] +# Runs all tests containing "user" in their name +bashunit test tests/unit/example_test.sh::user +``` +::: + +**Run the test at a specific line number:** +> `bashunit test path:line_number` + +This is useful when jumping to a test from your editor or IDE. + +::: code-group +```bash [Example] +bashunit test tests/unit/example_test.sh:42 +``` +::: + +::: tip +The line number syntax finds the test function that contains the specified line. If the line is before any test function, an error is shown. +::: + ### Parallel > `bashunit test -p|--parallel` diff --git a/src/helpers.sh b/src/helpers.sh index 27174abe..df6c9585 100755 --- a/src/helpers.sh +++ b/src/helpers.sh @@ -372,3 +372,72 @@ function helper::generate_id() { echo "${sanitized_basename}_$$" fi } + +# +# Parses a file path that may contain a filter suffix. +# Supports two syntaxes: +# - path::function_name (filter by function name) +# - path:line_number (filter by line number) +# +# @param $1 string Eg: "tests/test.sh::test_foo" or "tests/test.sh:123" +# +# @return string Two lines: first is file path, second is filter (or empty) +# +function helper::parse_file_path_filter() { + local input="$1" + local file_path="" + local filter="" + + # Check for :: syntax (function name filter) + if [[ "$input" == *"::"* ]]; then + file_path="${input%%::*}" + filter="${input#*::}" + # Check for :number syntax (line number filter) + elif [[ "$input" =~ ^(.+):([0-9]+)$ ]]; then + file_path="${BASH_REMATCH[1]}" + local line_number="${BASH_REMATCH[2]}" + # Line number will be resolved to function name later + filter="__line__:${line_number}" + else + file_path="$input" + fi + + echo "$file_path" + echo "$filter" +} + +# +# Finds the test function that contains a given line number in a file. +# +# @param $1 string File path +# @param $2 number Line number +# +# @return string The function name, or empty if not found +# +function helper::find_function_at_line() { + local file="$1" + local target_line="$2" + + if [[ ! -f "$file" ]]; then + return 1 + fi + + # Find all test function definitions and their line numbers + local best_match="" + local best_line=0 + + while IFS=: read -r line_num content; do + # Extract function name from the line + local fn_name="" + if [[ "$content" =~ ^[[:space:]]*(function[[:space:]]+)?(test[a-zA-Z_][a-zA-Z0-9_]*)[[:space:]]*\(\) ]]; then + fn_name="${BASH_REMATCH[2]}" + fi + + if [[ -n "$fn_name" && "$line_num" -le "$target_line" && "$line_num" -gt "$best_line" ]]; then + best_match="$fn_name" + best_line="$line_num" + fi + done < <(grep -n -E '^[[:space:]]*(function[[:space:]]+)?test[a-zA-Z_][a-zA-Z0-9_]*[[:space:]]*\(\)' "$file") + + echo "$best_match" +} diff --git a/src/main.sh b/src/main.sh index ef3e201a..e1f730d4 100644 --- a/src/main.sh +++ b/src/main.sh @@ -73,13 +73,57 @@ function main::cmd_test() { shift done - # Expand positional arguments + # Expand positional arguments and extract inline filters + # Skip filter parsing for assert mode - args are not file paths + local inline_filter="" + local inline_filter_file="" if [[ ${#raw_args[@]} -gt 0 ]]; then - for arg in "${raw_args[@]}"; do - while IFS= read -r file; do - args+=("$file") - done < <(helper::find_files_recursive "$arg" '*[tT]est.sh') - done + if [[ -n "$assert_fn" ]]; then + # Assert mode: pass args as-is without file path processing + args=("${raw_args[@]}") + else + # Test mode: process file paths and extract inline filters + for arg in "${raw_args[@]}"; do + local parsed_path parsed_filter + { + read -r parsed_path + read -r parsed_filter + } < <(helper::parse_file_path_filter "$arg") + + # If an inline filter was found, store it + if [[ -n "$parsed_filter" ]]; then + inline_filter="$parsed_filter" + inline_filter_file="$parsed_path" + fi + + while IFS= read -r file; do + args+=("$file") + done < <(helper::find_files_recursive "$parsed_path" '*[tT]est.sh') + done + + # Resolve line number filter to function name + if [[ "$inline_filter" == "__line__:"* ]]; then + local line_number="${inline_filter#__line__:}" + local resolved_file="${inline_filter_file}" + + # If the file path was a pattern, use the first resolved file + if [[ ${#args[@]} -gt 0 ]]; then + resolved_file="${args[0]}" + fi + + inline_filter=$(helper::find_function_at_line "$resolved_file" "$line_number") + if [[ -z "$inline_filter" ]]; then + printf "%sError: No test function found at line %s in %s%s\n" \ + "${_COLOR_FAILED}" "$line_number" "$resolved_file" "${_COLOR_DEFAULT}" + exit 1 + fi + fi + + # Use inline filter if no -f filter was provided + if [[ -z "$filter" && -n "$inline_filter" ]]; then + filter="$inline_filter" + fi + fi fi # Optional bootstrap diff --git a/tests/acceptance/bashunit_inline_filter_test.sh b/tests/acceptance/bashunit_inline_filter_test.sh new file mode 100644 index 00000000..172b772e --- /dev/null +++ b/tests/acceptance/bashunit_inline_filter_test.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env bash +set -euo pipefail + +function set_up_before_script() { + TEST_ENV_FILE="tests/acceptance/fixtures/.env.default" +} + +function test_double_colon_syntax_runs_specific_test() { + local output + output=$(./bashunit --no-parallel --env "$TEST_ENV_FILE" \ + "tests/acceptance/fixtures/tests_path/a_test.sh::test_assert_empty") + + assert_contains "1 passed" "$output" + assert_contains "Assert empty" "$output" +} + +function test_double_colon_syntax_with_partial_match() { + local output + output=$(./bashunit --no-parallel --env "$TEST_ENV_FILE" \ + "tests/acceptance/fixtures/tests_path/a_test.sh::test_assert") + + assert_contains "2 passed" "$output" +} + +function test_line_number_syntax_runs_specific_test() { + local output + # Line 4 should be inside test_assert_greater_and_less_than (starts at line 3) + output=$(./bashunit --no-parallel --env "$TEST_ENV_FILE" \ + "tests/acceptance/fixtures/tests_path/a_test.sh:4") + + assert_contains "passed" "$output" + assert_contains "Assert greater and less than" "$output" +} + +function test_line_number_at_second_function() { + local output + # Line 9 should be inside test_assert_empty (starts at line 8) + output=$(./bashunit --no-parallel --env "$TEST_ENV_FILE" \ + "tests/acceptance/fixtures/tests_path/a_test.sh:9") + + assert_contains "1 passed" "$output" + assert_contains "Assert empty" "$output" +} + +function test_line_number_before_any_test_shows_error() { + local output + output=$(./bashunit --no-parallel --env "$TEST_ENV_FILE" \ + "tests/acceptance/fixtures/tests_path/a_test.sh:1" 2>&1) || true + + assert_contains "No test function found" "$output" +} + +function test_double_colon_syntax_no_match_runs_nothing() { + local output + output=$(./bashunit --no-parallel --env "$TEST_ENV_FILE" \ + "tests/acceptance/fixtures/tests_path/a_test.sh::nonexistent_test") + + assert_contains "0 total" "$output" +} + +function test_regular_filter_option_still_works() { + local output + output=$(./bashunit --no-parallel --env "$TEST_ENV_FILE" \ + --filter "test_assert_empty" "tests/acceptance/fixtures/tests_path/a_test.sh") + + assert_contains "1 passed" "$output" +} diff --git a/tests/unit/helpers_test.sh b/tests/unit/helpers_test.sh index 8ebcdf3d..bb5b2f59 100644 --- a/tests/unit/helpers_test.sh +++ b/tests/unit/helpers_test.sh @@ -316,3 +316,91 @@ function test_find_total_tests_with_filter() { assert_same "3" "$(helpers_test::find_total_in_subshell "with_provider" "$file1" "$file2")" } + +function test_parse_file_path_filter_plain_path() { + local result + result=$(helper::parse_file_path_filter "tests/unit/example_test.sh") + + local file_path filter + { + read -r file_path + read -r filter + } <<< "$result" + + assert_same "tests/unit/example_test.sh" "$file_path" + assert_same "" "$filter" +} + +function test_parse_file_path_filter_with_double_colon() { + local result + result=$(helper::parse_file_path_filter "tests/unit/example_test.sh::test_my_function") + + local file_path filter + { + read -r file_path + read -r filter + } <<< "$result" + + assert_same "tests/unit/example_test.sh" "$file_path" + assert_same "test_my_function" "$filter" +} + +function test_parse_file_path_filter_with_line_number() { + local result + result=$(helper::parse_file_path_filter "tests/unit/example_test.sh:42") + + local file_path filter + { + read -r file_path + read -r filter + } <<< "$result" + + assert_same "tests/unit/example_test.sh" "$file_path" + assert_same "__line__:42" "$filter" +} + +function test_parse_file_path_filter_with_colon_in_path() { + local result + result=$(helper::parse_file_path_filter "/path/to:weird/example_test.sh::test_func") + + local file_path filter + { + read -r file_path + read -r filter + } <<< "$result" + + assert_same "/path/to:weird/example_test.sh" "$file_path" + assert_same "test_func" "$filter" +} + +function test_find_function_at_line_first_function() { + local file + file="$(current_dir)/fixtures/find_total_tests/simple_test.sh" + + assert_same "test_first" "$(helper::find_function_at_line "$file" 4)" +} + +function test_find_function_at_line_second_function() { + local file + file="$(current_dir)/fixtures/find_total_tests/simple_test.sh" + + assert_same "test_second" "$(helper::find_function_at_line "$file" 8)" +} + +function test_find_function_at_line_exact_function_line() { + local file + file="$(current_dir)/fixtures/find_total_tests/simple_test.sh" + + assert_same "test_first" "$(helper::find_function_at_line "$file" 3)" +} + +function test_find_function_at_line_before_any_function() { + local file + file="$(current_dir)/fixtures/find_total_tests/simple_test.sh" + + assert_same "" "$(helper::find_function_at_line "$file" 1)" +} + +function test_find_function_at_line_nonexistent_file() { + assert_general_error "$(helper::find_function_at_line "/nonexistent/file.sh" 10)" +}