Skip to content

Commit 74df710

Browse files
authored
Add --name-pattern option to mix test and regex support to OptionParser (#14674)
1 parent 8b19221 commit 74df710

File tree

4 files changed

+143
-27
lines changed

4 files changed

+143
-27
lines changed

lib/elixir/lib/option_parser.ex

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,7 @@ defmodule OptionParser do
129129
* `:integer` - parses the value as an integer
130130
* `:float` - parses the value as a float
131131
* `:string` - parses the value as a string
132+
* `:regex` - parses the value as a regular expression with Unicode support
132133
133134
If a switch can't be parsed according to the given type, it is
134135
returned in the invalid options list.
@@ -664,7 +665,7 @@ defmodule OptionParser do
664665
end
665666

666667
defp validate_switch({_name, type_or_type_and_modifiers}) do
667-
valid = [:boolean, :count, :integer, :float, :string, :keep]
668+
valid = [:boolean, :count, :integer, :float, :string, :regex, :keep]
668669
invalid = List.wrap(type_or_type_and_modifiers) -- valid
669670

670671
if invalid != [] do
@@ -704,6 +705,12 @@ defmodule OptionParser do
704705
_ -> {true, value}
705706
end
706707

708+
:regex in kinds ->
709+
case Regex.compile(value, "u") do
710+
{:ok, regex} -> {false, regex}
711+
{:error, _} -> {true, value}
712+
end
713+
707714
true ->
708715
{false, value}
709716
end
@@ -896,7 +903,13 @@ defmodule OptionParser do
896903

897904
defp format_error({option, value}, opts, types) do
898905
type = get_type(option, opts, types)
899-
"#{option} : Expected type #{type}, got #{inspect(value)}"
906+
907+
with :regex <- type,
908+
{:error, {reason, position}} <- Regex.compile(value, "u") do
909+
"#{option} : Invalid regular expression #{inspect(value)}: #{reason} at position #{position}"
910+
else
911+
_ -> "#{option} : Expected type #{type}, got #{inspect(value)}"
912+
end
900913
end
901914

902915
defp get_type(option, opts, types) do

lib/elixir/test/elixir/option_parser_test.exs

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -418,6 +418,80 @@ defmodule OptionParserTest do
418418
assert OptionParser.parse(["arg1", "--option", "-43.2"], opts) ==
419419
{[option: -43.2], ["arg1"], []}
420420
end
421+
422+
test "parses configured regexes" do
423+
assert {[pattern: regex], ["foo"], []} =
424+
OptionParser.parse(["--pattern", "a.*b", "foo"], switches: [pattern: :regex])
425+
426+
assert Regex.match?(regex, "aXXXb")
427+
refute Regex.match?(regex, "xyz")
428+
429+
assert {[pattern: regex], ["foo"], []} =
430+
OptionParser.parse(["--pattern=a.*b", "foo"], switches: [pattern: :regex])
431+
432+
assert Regex.match?(regex, "aXXXb")
433+
434+
# Test Unicode support
435+
assert {[pattern: regex], ["foo"], []} =
436+
OptionParser.parse(["--pattern", "café.*résumé", "foo"],
437+
switches: [pattern: :regex]
438+
)
439+
440+
assert Regex.match?(regex, "café test résumé")
441+
refute Regex.match?(regex, "ascii only")
442+
443+
# Test invalid regex
444+
assert OptionParser.parse(["--pattern", "[invalid", "foo"], switches: [pattern: :regex]) ==
445+
{[], ["foo"], [{"--pattern", "[invalid"}]}
446+
end
447+
448+
test "parses configured regexes with keep" do
449+
argv = ["--pattern", "a.*", "--pattern", "b.*", "foo"]
450+
451+
assert {[pattern: regex1, pattern: regex2], ["foo"], []} =
452+
OptionParser.parse(argv, switches: [pattern: [:regex, :keep]])
453+
454+
assert Regex.match?(regex1, "aXXX")
455+
assert Regex.match?(regex2, "bXXX")
456+
refute Regex.match?(regex1, "bXXX")
457+
refute Regex.match?(regex2, "aXXX")
458+
459+
argv = ["--pattern=a.*", "foo", "--pattern=b.*", "bar"]
460+
461+
assert {[pattern: regex1, pattern: regex2], ["foo", "bar"], []} =
462+
OptionParser.parse(argv, switches: [pattern: [:regex, :keep]])
463+
464+
assert Regex.match?(regex1, "aXXX")
465+
assert Regex.match?(regex2, "bXXX")
466+
end
467+
468+
test "correctly handles regex compilation errors" do
469+
opts = [switches: [pattern: :regex]]
470+
471+
# Invalid regex patterns should be treated as errors
472+
assert OptionParser.parse(["--pattern", "*invalid"], opts) ==
473+
{[], [], [{"--pattern", "*invalid"}]}
474+
475+
assert OptionParser.parse(["--pattern", "[unclosed"], opts) ==
476+
{[], [], [{"--pattern", "[unclosed"}]}
477+
478+
assert OptionParser.parse(["--pattern", "(?invalid)"], opts) ==
479+
{[], [], [{"--pattern", "(?invalid)"}]}
480+
end
481+
482+
test "parse! raises an exception for invalid regex patterns" do
483+
assert_raise OptionParser.ParseError,
484+
~r/Invalid regular expression \"\[invalid\": missing terminating \] for character class at position \d/,
485+
fn ->
486+
OptionParser.parse!(["--pattern", "[invalid"], switches: [pattern: :regex])
487+
end
488+
489+
assert_raise OptionParser.ParseError,
490+
~r/Invalid regular expression \"\(\?invalid\)\": unrecognized character after \(\? or \(\?\- at position \d/,
491+
fn ->
492+
OptionParser.parse!(["--pattern", "(?invalid)"], switches: [pattern: :regex])
493+
end
494+
end
421495
end
422496

423497
describe "next" do

lib/mix/lib/mix/tasks/test.ex

Lines changed: 34 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,9 @@ defmodule Mix.Tasks.Test do
157157
* `--max-requires` - sets the maximum number of test files to compile in parallel.
158158
Setting this to 1 will compile test files sequentially.
159159
160+
* `-n`, `--name-pattern` *(since v1.19.0)* - only run tests with names that match
161+
the given regular expression
162+
160163
* `--no-archives-check` - does not check archives
161164
162165
* `--no-color` - disables color in the output
@@ -481,6 +484,7 @@ defmodule Mix.Tasks.Test do
481484
include: :keep,
482485
exclude: :keep,
483486
seed: :integer,
487+
name_pattern: :regex,
484488
only: :keep,
485489
compile: :boolean,
486490
start: :boolean,
@@ -504,11 +508,13 @@ defmodule Mix.Tasks.Test do
504508
dry_run: :boolean
505509
]
506510

511+
@aliases [b: :breakpoints, n: :name_pattern]
512+
507513
@cover [output: "cover", tool: Mix.Tasks.Test.Coverage]
508514

509515
@impl true
510516
def run(args) do
511-
{opts, files} = OptionParser.parse!(args, strict: @switches, aliases: [b: :breakpoints])
517+
{opts, files} = OptionParser.parse!(args, strict: @switches, aliases: @aliases)
512518

513519
if not Mix.Task.recursing?() do
514520
do_run(opts, args, files)
@@ -707,8 +713,10 @@ defmodule Mix.Tasks.Test do
707713
end)
708714

709715
excluded == total and Keyword.has_key?(opts, :only) ->
710-
message = "The --only option was given to \"mix test\" but no test was executed"
711-
raise_or_error_at_exit(shell, message, opts)
716+
nothing_executed(shell, "--only", opts)
717+
718+
excluded == total and Keyword.has_key?(opts, :name_pattern) ->
719+
nothing_executed(shell, "--name-pattern", opts)
712720

713721
true ->
714722
:ok
@@ -763,6 +771,11 @@ defmodule Mix.Tasks.Test do
763771
Mix.raise(message)
764772
end
765773

774+
defp nothing_executed(shell, option, opts) do
775+
message = "The #{option} option was given to \"mix test\" but no test was executed"
776+
raise_or_error_at_exit(shell, message, opts)
777+
end
778+
766779
defp raise_or_error_at_exit(shell, message, opts) do
767780
cond do
768781
opts[:raise] ->
@@ -866,7 +879,7 @@ defmodule Mix.Tasks.Test do
866879
opts
867880
|> filter_opts(:include)
868881
|> filter_opts(:exclude)
869-
|> filter_opts(:only)
882+
|> filter_only_and_name_pattern()
870883
|> formatter_opts()
871884
|> color_opts()
872885
|> exit_status_opts()
@@ -893,24 +906,30 @@ defmodule Mix.Tasks.Test do
893906
defp parse_filters(opts, key) do
894907
if Keyword.has_key?(opts, key) do
895908
ExUnit.Filters.parse(Keyword.get_values(opts, key))
909+
else
910+
[]
896911
end
897912
end
898913

899-
defp filter_opts(opts, :only) do
900-
if filters = parse_filters(opts, :only) do
901-
opts
902-
|> Keyword.update(:include, filters, &(filters ++ &1))
903-
|> Keyword.update(:exclude, [:test], &[:test | &1])
904-
else
905-
opts
914+
defp filter_only_and_name_pattern(opts) do
915+
only = parse_filters(opts, :only)
916+
name_patterns = opts |> Keyword.get_values(:name_pattern) |> Enum.map(&{:test, &1})
917+
918+
case only ++ name_patterns do
919+
[] ->
920+
opts
921+
922+
filters ->
923+
opts
924+
|> Keyword.update(:include, filters, &(filters ++ &1))
925+
|> Keyword.update(:exclude, [:test], &[:test | &1])
906926
end
907927
end
908928

909929
defp filter_opts(opts, key) do
910-
if filters = parse_filters(opts, key) do
911-
Keyword.put(opts, key, filters)
912-
else
913-
opts
930+
case parse_filters(opts, key) do
931+
[] -> opts
932+
filters -> Keyword.put(opts, key, filters)
914933
end
915934
end
916935

lib/mix/test/mix/tasks/test_test.exs

Lines changed: 20 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -9,31 +9,41 @@ defmodule Mix.Tasks.TestTest do
99

1010
describe "ex_unit_opts/1" do
1111
test "returns ex unit options" do
12-
assert ex_unit_opts_from_given(unknown: "ok", seed: 13) == [seed: 13]
12+
assert filtered_ex_unit_opts(unknown: "ok", seed: 13) == [seed: 13]
1313
end
1414

1515
test "returns includes and excludes" do
1616
included = [include: [:focus, key: "val"]]
17-
assert ex_unit_opts_from_given(include: "focus", include: "key:val") == included
17+
assert filtered_ex_unit_opts(include: "focus", include: "key:val") == included
1818

1919
excluded = [exclude: [:focus, key: "val"]]
20-
assert ex_unit_opts_from_given(exclude: "focus", exclude: "key:val") == excluded
20+
assert filtered_ex_unit_opts(exclude: "focus", exclude: "key:val") == excluded
2121
end
2222

2323
test "translates :only into includes and excludes" do
24-
assert ex_unit_opts_from_given(only: "focus") == [include: [:focus], exclude: [:test]]
24+
assert filtered_ex_unit_opts(only: "focus") == [include: [:focus], exclude: [:test]]
2525

2626
only = [include: [:focus, :special], exclude: [:test]]
27-
assert ex_unit_opts_from_given(only: "focus", include: "special") == only
27+
assert filtered_ex_unit_opts(only: "focus", include: "special") == only
28+
end
29+
30+
test "translates :name_pattern into includes and excludes" do
31+
assert [include: [test: hello_regex, test: world_regex], exclude: [:test]] =
32+
filtered_ex_unit_opts(name_pattern: ~r/hello/, name_pattern: ~r/world/)
33+
34+
assert Regex.match?(hello_regex, "hello")
35+
refute Regex.match?(hello_regex, "world")
36+
refute Regex.match?(world_regex, "hello")
37+
assert Regex.match?(world_regex, "world")
2838
end
2939

3040
test "translates :color into list containing an enabled key-value pair" do
31-
assert ex_unit_opts_from_given(color: false) == [colors: [enabled: false]]
32-
assert ex_unit_opts_from_given(color: true) == [colors: [enabled: true]]
41+
assert filtered_ex_unit_opts(color: false) == [colors: [enabled: false]]
42+
assert filtered_ex_unit_opts(color: true) == [colors: [enabled: true]]
3343
end
3444

3545
test "translates :formatter into list of modules" do
36-
assert ex_unit_opts_from_given(formatter: "A.B") == [formatters: [A.B]]
46+
assert filtered_ex_unit_opts(formatter: "A.B") == [formatters: [A.B]]
3747
end
3848

3949
test "accepts custom :exit_status" do
@@ -53,8 +63,8 @@ defmodule Mix.Tasks.TestTest do
5363
ex_unit_opts
5464
end
5565

56-
defp ex_unit_opts_from_given(passed) do
57-
passed
66+
defp filtered_ex_unit_opts(opts) do
67+
opts
5868
|> Keyword.put(:failures_manifest_path, "foo.bar")
5969
|> ex_unit_opts()
6070
|> Keyword.drop([:failures_manifest_path, :autorun, :exit_status])

0 commit comments

Comments
 (0)