Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 15 additions & 2 deletions lib/elixir/lib/option_parser.ex
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,7 @@ defmodule OptionParser do
* `:integer` - parses the value as an integer
* `:float` - parses the value as a float
* `:string` - parses the value as a string
* `:regex` - parses the value as a regular expression with Unicode support

If a switch can't be parsed according to the given type, it is
returned in the invalid options list.
Expand Down Expand Up @@ -664,7 +665,7 @@ defmodule OptionParser do
end

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

if invalid != [] do
Expand Down Expand Up @@ -704,6 +705,12 @@ defmodule OptionParser do
_ -> {true, value}
end

:regex in kinds ->
case Regex.compile(value, "u") do
{:ok, regex} -> {false, regex}
{:error, _} -> {true, value}
end

true ->
{false, value}
end
Expand Down Expand Up @@ -891,7 +898,13 @@ defmodule OptionParser do

defp format_error({option, value}, opts, types) do
type = get_type(option, opts, types)
"#{option} : Expected type #{type}, got #{inspect(value)}"

with :regex <- type,
{:error, {reason, position}} <- Regex.compile(value, "u") do
"#{option} : Invalid regular expression #{inspect(value)}: #{reason} at position #{position}"
else
_ -> "#{option} : Expected type #{type}, got #{inspect(value)}"
end
end

defp get_type(option, opts, types) do
Expand Down
74 changes: 74 additions & 0 deletions lib/elixir/test/elixir/option_parser_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -350,6 +350,80 @@ defmodule OptionParserTest do
assert OptionParser.parse(["arg1", "--option", "-43.2"], opts) ==
{[option: -43.2], ["arg1"], []}
end

test "parses configured regexes" do
assert {[pattern: regex], ["foo"], []} =
OptionParser.parse(["--pattern", "a.*b", "foo"], switches: [pattern: :regex])

assert Regex.match?(regex, "aXXXb")
refute Regex.match?(regex, "xyz")

assert {[pattern: regex], ["foo"], []} =
OptionParser.parse(["--pattern=a.*b", "foo"], switches: [pattern: :regex])

assert Regex.match?(regex, "aXXXb")

# Test Unicode support
assert {[pattern: regex], ["foo"], []} =
OptionParser.parse(["--pattern", "café.*résumé", "foo"],
switches: [pattern: :regex]
)

assert Regex.match?(regex, "café test résumé")
refute Regex.match?(regex, "ascii only")

# Test invalid regex
assert OptionParser.parse(["--pattern", "[invalid", "foo"], switches: [pattern: :regex]) ==
{[], ["foo"], [{"--pattern", "[invalid"}]}
end

test "parses configured regexes with keep" do
argv = ["--pattern", "a.*", "--pattern", "b.*", "foo"]

assert {[pattern: regex1, pattern: regex2], ["foo"], []} =
OptionParser.parse(argv, switches: [pattern: [:regex, :keep]])

assert Regex.match?(regex1, "aXXX")
assert Regex.match?(regex2, "bXXX")
refute Regex.match?(regex1, "bXXX")
refute Regex.match?(regex2, "aXXX")

argv = ["--pattern=a.*", "foo", "--pattern=b.*", "bar"]

assert {[pattern: regex1, pattern: regex2], ["foo", "bar"], []} =
OptionParser.parse(argv, switches: [pattern: [:regex, :keep]])

assert Regex.match?(regex1, "aXXX")
assert Regex.match?(regex2, "bXXX")
end

test "correctly handles regex compilation errors" do
opts = [switches: [pattern: :regex]]

# Invalid regex patterns should be treated as errors
assert OptionParser.parse(["--pattern", "*invalid"], opts) ==
{[], [], [{"--pattern", "*invalid"}]}

assert OptionParser.parse(["--pattern", "[unclosed"], opts) ==
{[], [], [{"--pattern", "[unclosed"}]}

assert OptionParser.parse(["--pattern", "(?invalid)"], opts) ==
{[], [], [{"--pattern", "(?invalid)"}]}
end

test "parse! raises an exception for invalid regex patterns" do
assert_raise OptionParser.ParseError,
~r/Invalid regular expression \"\[invalid\": missing terminating \] for character class at position \d/,
fn ->
OptionParser.parse!(["--pattern", "[invalid"], switches: [pattern: :regex])
end

assert_raise OptionParser.ParseError,
~r/Invalid regular expression \"\(\?invalid\)\": unrecognized character after \(\? or \(\?\- at position \d/,
fn ->
OptionParser.parse!(["--pattern", "(?invalid)"], switches: [pattern: :regex])
end
end
end

describe "next" do
Expand Down
49 changes: 34 additions & 15 deletions lib/mix/lib/mix/tasks/test.ex
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,9 @@ defmodule Mix.Tasks.Test do
* `--max-requires` - sets the maximum number of test files to compile in parallel.
Setting this to 1 will compile test files sequentially.

* `-n`, `--name-pattern` *(since v1.19.0)* - only run tests which the name matching
the given regular expression

* `--no-archives-check` - does not check archives

* `--no-color` - disables color in the output
Expand Down Expand Up @@ -481,6 +484,7 @@ defmodule Mix.Tasks.Test do
include: :keep,
exclude: :keep,
seed: :integer,
name_pattern: :regex,
only: :keep,
compile: :boolean,
start: :boolean,
Expand All @@ -504,11 +508,13 @@ defmodule Mix.Tasks.Test do
dry_run: :boolean
]

@aliases [b: :breakpoints, n: :name_pattern]

@cover [output: "cover", tool: Mix.Tasks.Test.Coverage]

@impl true
def run(args) do
{opts, files} = OptionParser.parse!(args, strict: @switches, aliases: [b: :breakpoints])
{opts, files} = OptionParser.parse!(args, strict: @switches, aliases: @aliases)

if not Mix.Task.recursing?() do
do_run(opts, args, files)
Expand Down Expand Up @@ -707,8 +713,10 @@ defmodule Mix.Tasks.Test do
end)

excluded == total and Keyword.has_key?(opts, :only) ->
message = "The --only option was given to \"mix test\" but no test was executed"
raise_or_error_at_exit(shell, message, opts)
nothing_executed(shell, "--only", opts)

excluded == total and Keyword.has_key?(opts, :name_pattern) ->
nothing_executed(shell, "--name-pattern", opts)

true ->
:ok
Expand Down Expand Up @@ -763,6 +771,11 @@ defmodule Mix.Tasks.Test do
Mix.raise(message)
end

defp nothing_executed(shell, option, opts) do
message = "The #{option} option was given to \"mix test\" but no test was executed"
raise_or_error_at_exit(shell, message, opts)
end

defp raise_or_error_at_exit(shell, message, opts) do
cond do
opts[:raise] ->
Expand Down Expand Up @@ -866,7 +879,7 @@ defmodule Mix.Tasks.Test do
opts
|> filter_opts(:include)
|> filter_opts(:exclude)
|> filter_opts(:only)
|> filter_only_and_name_pattern()
|> formatter_opts()
|> color_opts()
|> exit_status_opts()
Expand All @@ -893,24 +906,30 @@ defmodule Mix.Tasks.Test do
defp parse_filters(opts, key) do
if Keyword.has_key?(opts, key) do
ExUnit.Filters.parse(Keyword.get_values(opts, key))
else
[]
end
end

defp filter_opts(opts, :only) do
if filters = parse_filters(opts, :only) do
opts
|> Keyword.update(:include, filters, &(filters ++ &1))
|> Keyword.update(:exclude, [:test], &[:test | &1])
else
opts
defp filter_only_and_name_pattern(opts) do
only = parse_filters(opts, :only)
name_patterns = opts |> Keyword.get_values(:name_pattern) |> Enum.map(&{:test, &1})

case only ++ name_patterns do
[] ->
opts

filters ->
opts
|> Keyword.update(:include, filters, &(filters ++ &1))
|> Keyword.update(:exclude, [:test], &[:test | &1])
end
end

defp filter_opts(opts, key) do
if filters = parse_filters(opts, key) do
Keyword.put(opts, key, filters)
else
opts
case parse_filters(opts, key) do
[] -> opts
filters -> Keyword.put(opts, key, filters)
end
end

Expand Down
30 changes: 20 additions & 10 deletions lib/mix/test/mix/tasks/test_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -9,31 +9,41 @@ defmodule Mix.Tasks.TestTest do

describe "ex_unit_opts/1" do
test "returns ex unit options" do
assert ex_unit_opts_from_given(unknown: "ok", seed: 13) == [seed: 13]
assert filtered_ex_unit_opts(unknown: "ok", seed: 13) == [seed: 13]
end

test "returns includes and excludes" do
included = [include: [:focus, key: "val"]]
assert ex_unit_opts_from_given(include: "focus", include: "key:val") == included
assert filtered_ex_unit_opts(include: "focus", include: "key:val") == included

excluded = [exclude: [:focus, key: "val"]]
assert ex_unit_opts_from_given(exclude: "focus", exclude: "key:val") == excluded
assert filtered_ex_unit_opts(exclude: "focus", exclude: "key:val") == excluded
end

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

only = [include: [:focus, :special], exclude: [:test]]
assert ex_unit_opts_from_given(only: "focus", include: "special") == only
assert filtered_ex_unit_opts(only: "focus", include: "special") == only
end

test "translates :name_pattern into includes and excludes" do
assert [include: [test: hello_regex, test: world_regex], exclude: [:test]] =
filtered_ex_unit_opts(name_pattern: ~r/hello/, name_pattern: ~r/world/)

assert Regex.match?(hello_regex, "hello")
refute Regex.match?(hello_regex, "world")
refute Regex.match?(world_regex, "hello")
assert Regex.match?(world_regex, "world")
end

test "translates :color into list containing an enabled key-value pair" do
assert ex_unit_opts_from_given(color: false) == [colors: [enabled: false]]
assert ex_unit_opts_from_given(color: true) == [colors: [enabled: true]]
assert filtered_ex_unit_opts(color: false) == [colors: [enabled: false]]
assert filtered_ex_unit_opts(color: true) == [colors: [enabled: true]]
end

test "translates :formatter into list of modules" do
assert ex_unit_opts_from_given(formatter: "A.B") == [formatters: [A.B]]
assert filtered_ex_unit_opts(formatter: "A.B") == [formatters: [A.B]]
end

test "accepts custom :exit_status" do
Expand All @@ -53,8 +63,8 @@ defmodule Mix.Tasks.TestTest do
ex_unit_opts
end

defp ex_unit_opts_from_given(passed) do
passed
defp filtered_ex_unit_opts(opts) do
opts
|> Keyword.put(:failures_manifest_path, "foo.bar")
|> ex_unit_opts()
|> Keyword.drop([:failures_manifest_path, :autorun, :exit_status])
Expand Down