Skip to content

Commit f50f35f

Browse files
committed
Add --name-pattern option to mix test and regex support to OptionParser (#14674)
1 parent 5bc81aa commit f50f35f

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
@@ -153,6 +153,9 @@ defmodule Mix.Tasks.Test do
153153
* `--max-requires` - sets the maximum number of test files to compile in parallel.
154154
Setting this to 1 will compile test files sequentially.
155155
156+
* `-n`, `--name-pattern` *(since v1.19.0)* - only run tests with names that match
157+
the given regular expression
158+
156159
* `--no-archives-check` - does not check archives
157160
158161
* `--no-color` - disables color in the output
@@ -475,6 +478,7 @@ defmodule Mix.Tasks.Test do
475478
include: :keep,
476479
exclude: :keep,
477480
seed: :integer,
481+
name_pattern: :regex,
478482
only: :keep,
479483
compile: :boolean,
480484
start: :boolean,
@@ -497,11 +501,13 @@ defmodule Mix.Tasks.Test do
497501
repeat_until_failure: :integer
498502
]
499503

504+
@aliases [b: :breakpoints, n: :name_pattern]
505+
500506
@cover [output: "cover", tool: Mix.Tasks.Test.Coverage]
501507

502508
@impl true
503509
def run(args) do
504-
{opts, files} = OptionParser.parse!(args, strict: @switches, aliases: [b: :breakpoints])
510+
{opts, files} = OptionParser.parse!(args, strict: @switches, aliases: @aliases)
505511

506512
if not Mix.Task.recursing?() do
507513
do_run(opts, args, files)
@@ -700,8 +706,10 @@ defmodule Mix.Tasks.Test do
700706
end)
701707

702708
excluded == total and Keyword.has_key?(opts, :only) ->
703-
message = "The --only option was given to \"mix test\" but no test was executed"
704-
raise_or_error_at_exit(shell, message, opts)
709+
nothing_executed(shell, "--only", opts)
710+
711+
excluded == total and Keyword.has_key?(opts, :name_pattern) ->
712+
nothing_executed(shell, "--name-pattern", opts)
705713

706714
true ->
707715
:ok
@@ -756,6 +764,11 @@ defmodule Mix.Tasks.Test do
756764
Mix.raise(message)
757765
end
758766

767+
defp nothing_executed(shell, option, opts) do
768+
message = "The #{option} option was given to \"mix test\" but no test was executed"
769+
raise_or_error_at_exit(shell, message, opts)
770+
end
771+
759772
defp raise_or_error_at_exit(shell, message, opts) do
760773
cond do
761774
opts[:raise] ->
@@ -858,7 +871,7 @@ defmodule Mix.Tasks.Test do
858871
opts
859872
|> filter_opts(:include)
860873
|> filter_opts(:exclude)
861-
|> filter_opts(:only)
874+
|> filter_only_and_name_pattern()
862875
|> formatter_opts()
863876
|> color_opts()
864877
|> exit_status_opts()
@@ -885,24 +898,30 @@ defmodule Mix.Tasks.Test do
885898
defp parse_filters(opts, key) do
886899
if Keyword.has_key?(opts, key) do
887900
ExUnit.Filters.parse(Keyword.get_values(opts, key))
901+
else
902+
[]
888903
end
889904
end
890905

891-
defp filter_opts(opts, :only) do
892-
if filters = parse_filters(opts, :only) do
893-
opts
894-
|> Keyword.update(:include, filters, &(filters ++ &1))
895-
|> Keyword.update(:exclude, [:test], &[:test | &1])
896-
else
897-
opts
906+
defp filter_only_and_name_pattern(opts) do
907+
only = parse_filters(opts, :only)
908+
name_patterns = opts |> Keyword.get_values(:name_pattern) |> Enum.map(&{:test, &1})
909+
910+
case only ++ name_patterns do
911+
[] ->
912+
opts
913+
914+
filters ->
915+
opts
916+
|> Keyword.update(:include, filters, &(filters ++ &1))
917+
|> Keyword.update(:exclude, [:test], &[:test | &1])
898918
end
899919
end
900920

901921
defp filter_opts(opts, key) do
902-
if filters = parse_filters(opts, key) do
903-
Keyword.put(opts, key, filters)
904-
else
905-
opts
922+
case parse_filters(opts, key) do
923+
[] -> opts
924+
filters -> Keyword.put(opts, key, filters)
906925
end
907926
end
908927

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)