Skip to content

Commit 7beccdb

Browse files
committed
feat: enable running concrete test using :: and :line syntax
Add support for specifying test functions directly in the file path: - `path::function_name` - filter by test function name - `path:line_number` - run test containing the specified line Closes #496
1 parent 92cb792 commit 7beccdb

4 files changed

Lines changed: 274 additions & 6 deletions

File tree

src/helpers.sh

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -372,3 +372,72 @@ function helper::generate_id() {
372372
echo "${sanitized_basename}_$$"
373373
fi
374374
}
375+
376+
#
377+
# Parses a file path that may contain a filter suffix.
378+
# Supports two syntaxes:
379+
# - path::function_name (filter by function name)
380+
# - path:line_number (filter by line number)
381+
#
382+
# @param $1 string Eg: "tests/test.sh::test_foo" or "tests/test.sh:123"
383+
#
384+
# @return string Two lines: first is file path, second is filter (or empty)
385+
#
386+
function helper::parse_file_path_filter() {
387+
local input="$1"
388+
local file_path=""
389+
local filter=""
390+
391+
# Check for :: syntax (function name filter)
392+
if [[ "$input" == *"::"* ]]; then
393+
file_path="${input%%::*}"
394+
filter="${input#*::}"
395+
# Check for :number syntax (line number filter)
396+
elif [[ "$input" =~ ^(.+):([0-9]+)$ ]]; then
397+
file_path="${BASH_REMATCH[1]}"
398+
local line_number="${BASH_REMATCH[2]}"
399+
# Line number will be resolved to function name later
400+
filter="__line__:${line_number}"
401+
else
402+
file_path="$input"
403+
fi
404+
405+
echo "$file_path"
406+
echo "$filter"
407+
}
408+
409+
#
410+
# Finds the test function that contains a given line number in a file.
411+
#
412+
# @param $1 string File path
413+
# @param $2 number Line number
414+
#
415+
# @return string The function name, or empty if not found
416+
#
417+
function helper::find_function_at_line() {
418+
local file="$1"
419+
local target_line="$2"
420+
421+
if [[ ! -f "$file" ]]; then
422+
return 1
423+
fi
424+
425+
# Find all test function definitions and their line numbers
426+
local best_match=""
427+
local best_line=0
428+
429+
while IFS=: read -r line_num content; do
430+
# Extract function name from the line
431+
local fn_name=""
432+
if [[ "$content" =~ ^[[:space:]]*(function[[:space:]]+)?(test[a-zA-Z_][a-zA-Z0-9_]*)[[:space:]]*\(\) ]]; then
433+
fn_name="${BASH_REMATCH[2]}"
434+
fi
435+
436+
if [[ -n "$fn_name" && "$line_num" -le "$target_line" && "$line_num" -gt "$best_line" ]]; then
437+
best_match="$fn_name"
438+
best_line="$line_num"
439+
fi
440+
done < <(grep -n -E '^[[:space:]]*(function[[:space:]]+)?test[a-zA-Z_][a-zA-Z0-9_]*[[:space:]]*\(\)' "$file")
441+
442+
echo "$best_match"
443+
}

src/main.sh

Lines changed: 50 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -73,13 +73,57 @@ function main::cmd_test() {
7373
shift
7474
done
7575

76-
# Expand positional arguments
76+
# Expand positional arguments and extract inline filters
77+
# Skip filter parsing for assert mode - args are not file paths
78+
local inline_filter=""
79+
local inline_filter_file=""
7780
if [[ ${#raw_args[@]} -gt 0 ]]; then
78-
for arg in "${raw_args[@]}"; do
79-
while IFS= read -r file; do
80-
args+=("$file")
81-
done < <(helper::find_files_recursive "$arg" '*[tT]est.sh')
82-
done
81+
if [[ -n "$assert_fn" ]]; then
82+
# Assert mode: pass args as-is without file path processing
83+
args=("${raw_args[@]}")
84+
else
85+
# Test mode: process file paths and extract inline filters
86+
for arg in "${raw_args[@]}"; do
87+
local parsed_path parsed_filter
88+
{
89+
read -r parsed_path
90+
read -r parsed_filter
91+
} < <(helper::parse_file_path_filter "$arg")
92+
93+
# If an inline filter was found, store it
94+
if [[ -n "$parsed_filter" ]]; then
95+
inline_filter="$parsed_filter"
96+
inline_filter_file="$parsed_path"
97+
fi
98+
99+
while IFS= read -r file; do
100+
args+=("$file")
101+
done < <(helper::find_files_recursive "$parsed_path" '*[tT]est.sh')
102+
done
103+
104+
# Resolve line number filter to function name
105+
if [[ "$inline_filter" == "__line__:"* ]]; then
106+
local line_number="${inline_filter#__line__:}"
107+
local resolved_file="${inline_filter_file}"
108+
109+
# If the file path was a pattern, use the first resolved file
110+
if [[ ${#args[@]} -gt 0 ]]; then
111+
resolved_file="${args[0]}"
112+
fi
113+
114+
inline_filter=$(helper::find_function_at_line "$resolved_file" "$line_number")
115+
if [[ -z "$inline_filter" ]]; then
116+
printf "%sError: No test function found at line %s in %s%s\n" \
117+
"${_COLOR_FAILED}" "$line_number" "$resolved_file" "${_COLOR_DEFAULT}"
118+
exit 1
119+
fi
120+
fi
121+
122+
# Use inline filter if no -f filter was provided
123+
if [[ -z "$filter" && -n "$inline_filter" ]]; then
124+
filter="$inline_filter"
125+
fi
126+
fi
83127
fi
84128

85129
# Optional bootstrap
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
#!/usr/bin/env bash
2+
set -euo pipefail
3+
4+
function set_up_before_script() {
5+
TEST_ENV_FILE="tests/acceptance/fixtures/.env.default"
6+
}
7+
8+
function test_double_colon_syntax_runs_specific_test() {
9+
local output
10+
output=$(./bashunit --no-parallel --env "$TEST_ENV_FILE" \
11+
"tests/acceptance/fixtures/tests_path/a_test.sh::test_assert_empty")
12+
13+
assert_contains "1 passed" "$output"
14+
assert_contains "Assert empty" "$output"
15+
}
16+
17+
function test_double_colon_syntax_with_partial_match() {
18+
local output
19+
output=$(./bashunit --no-parallel --env "$TEST_ENV_FILE" \
20+
"tests/acceptance/fixtures/tests_path/a_test.sh::test_assert")
21+
22+
assert_contains "2 passed" "$output"
23+
}
24+
25+
function test_line_number_syntax_runs_specific_test() {
26+
local output
27+
# Line 4 should be inside test_assert_greater_and_less_than (starts at line 3)
28+
output=$(./bashunit --no-parallel --env "$TEST_ENV_FILE" \
29+
"tests/acceptance/fixtures/tests_path/a_test.sh:4")
30+
31+
assert_contains "passed" "$output"
32+
assert_contains "Assert greater and less than" "$output"
33+
}
34+
35+
function test_line_number_at_second_function() {
36+
local output
37+
# Line 9 should be inside test_assert_empty (starts at line 8)
38+
output=$(./bashunit --no-parallel --env "$TEST_ENV_FILE" \
39+
"tests/acceptance/fixtures/tests_path/a_test.sh:9")
40+
41+
assert_contains "1 passed" "$output"
42+
assert_contains "Assert empty" "$output"
43+
}
44+
45+
function test_line_number_before_any_test_shows_error() {
46+
local output
47+
output=$(./bashunit --no-parallel --env "$TEST_ENV_FILE" \
48+
"tests/acceptance/fixtures/tests_path/a_test.sh:1" 2>&1) || true
49+
50+
assert_contains "No test function found" "$output"
51+
}
52+
53+
function test_double_colon_syntax_no_match_runs_nothing() {
54+
local output
55+
output=$(./bashunit --no-parallel --env "$TEST_ENV_FILE" \
56+
"tests/acceptance/fixtures/tests_path/a_test.sh::nonexistent_test")
57+
58+
assert_contains "0 total" "$output"
59+
}
60+
61+
function test_regular_filter_option_still_works() {
62+
local output
63+
output=$(./bashunit --no-parallel --env "$TEST_ENV_FILE" \
64+
--filter "test_assert_empty" "tests/acceptance/fixtures/tests_path/a_test.sh")
65+
66+
assert_contains "1 passed" "$output"
67+
}

tests/unit/helpers_test.sh

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -316,3 +316,91 @@ function test_find_total_tests_with_filter() {
316316

317317
assert_same "3" "$(helpers_test::find_total_in_subshell "with_provider" "$file1" "$file2")"
318318
}
319+
320+
function test_parse_file_path_filter_plain_path() {
321+
local result
322+
result=$(helper::parse_file_path_filter "tests/unit/example_test.sh")
323+
324+
local file_path filter
325+
{
326+
read -r file_path
327+
read -r filter
328+
} <<< "$result"
329+
330+
assert_same "tests/unit/example_test.sh" "$file_path"
331+
assert_same "" "$filter"
332+
}
333+
334+
function test_parse_file_path_filter_with_double_colon() {
335+
local result
336+
result=$(helper::parse_file_path_filter "tests/unit/example_test.sh::test_my_function")
337+
338+
local file_path filter
339+
{
340+
read -r file_path
341+
read -r filter
342+
} <<< "$result"
343+
344+
assert_same "tests/unit/example_test.sh" "$file_path"
345+
assert_same "test_my_function" "$filter"
346+
}
347+
348+
function test_parse_file_path_filter_with_line_number() {
349+
local result
350+
result=$(helper::parse_file_path_filter "tests/unit/example_test.sh:42")
351+
352+
local file_path filter
353+
{
354+
read -r file_path
355+
read -r filter
356+
} <<< "$result"
357+
358+
assert_same "tests/unit/example_test.sh" "$file_path"
359+
assert_same "__line__:42" "$filter"
360+
}
361+
362+
function test_parse_file_path_filter_with_colon_in_path() {
363+
local result
364+
result=$(helper::parse_file_path_filter "/path/to:weird/example_test.sh::test_func")
365+
366+
local file_path filter
367+
{
368+
read -r file_path
369+
read -r filter
370+
} <<< "$result"
371+
372+
assert_same "/path/to:weird/example_test.sh" "$file_path"
373+
assert_same "test_func" "$filter"
374+
}
375+
376+
function test_find_function_at_line_first_function() {
377+
local file
378+
file="$(current_dir)/fixtures/find_total_tests/simple_test.sh"
379+
380+
assert_same "test_first" "$(helper::find_function_at_line "$file" 4)"
381+
}
382+
383+
function test_find_function_at_line_second_function() {
384+
local file
385+
file="$(current_dir)/fixtures/find_total_tests/simple_test.sh"
386+
387+
assert_same "test_second" "$(helper::find_function_at_line "$file" 8)"
388+
}
389+
390+
function test_find_function_at_line_exact_function_line() {
391+
local file
392+
file="$(current_dir)/fixtures/find_total_tests/simple_test.sh"
393+
394+
assert_same "test_first" "$(helper::find_function_at_line "$file" 3)"
395+
}
396+
397+
function test_find_function_at_line_before_any_function() {
398+
local file
399+
file="$(current_dir)/fixtures/find_total_tests/simple_test.sh"
400+
401+
assert_same "" "$(helper::find_function_at_line "$file" 1)"
402+
}
403+
404+
function test_find_function_at_line_nonexistent_file() {
405+
assert_general_error "$(helper::find_function_at_line "/nonexistent/file.sh" 10)"
406+
}

0 commit comments

Comments
 (0)