diff --git a/lib/parameterized_test.ex b/lib/parameterized_test.ex index 0326c22..d139f3c 100644 --- a/lib/parameterized_test.ex +++ b/lib/parameterized_test.ex @@ -75,6 +75,7 @@ defmodule ParameterizedTest do See the README for more information. """ + alias ParameterizedTest.Parser @doc """ Defines tests that use your parameters or example data. @@ -99,18 +100,18 @@ defmodule ParameterizedTest do case file_extension do ext when ext in [".md", ".markdown", ".csv"] -> str - |> parse_file_path_examples(context) + |> Parser.parse_file_path_examples(context) |> Macro.escape() _ -> str - |> parse_examples(context) + |> Parser.parse_examples(context) |> Macro.escape() end list when is_list(list) -> list - |> parse_examples(context) + |> Parser.parse_examples(context) |> Macro.escape() already_escaped when is_tuple(already_escaped) -> @@ -146,166 +147,4 @@ defmodule ParameterizedTest do end end end - - @typep context :: [{:line, integer} | {:file, String.t()}] - - @spec parse_examples(String.t() | list, context()) :: [map()] - def parse_examples(table, context \\ []) - - def parse_examples(table, context) when is_binary(table) do - table - |> String.split("\n", trim: true) - |> Enum.map(&String.trim/1) - |> parse_md_rows(context) - end - - # This function head handles a list of already-parsed examples, like: - # param_test "accepts a list of maps or keyword lists", - # [ - # [int_1: 99, int_2: 100], - # %{int_1: 101, int_2: 102} - # ], %{int_1: int_1, int_2: int_2} do - def parse_examples(table, context) when is_list(table) do - {evaled_table, _, _} = Code.eval_quoted_with_env(table, [], __ENV__) - - case evaled_table do - str when is_binary(str) -> parse_file_path_examples(str, context) - list when is_list(list) -> parse_hand_rolled_table(list, context) - end - end - - defp parse_hand_rolled_table(evaled_table, context) do - parsed_table = Enum.map(evaled_table, &Map.new/1) - - keys = MapSet.new(parsed_table, &Map.keys/1) - - if MapSet.size(keys) > 1 do - raise """ - The keys in each row must be the same across all rows in your example table. - - Found differing key sets#{file_meta(context)}: - #{for key_set <- Enum.sort(keys), do: inspect(key_set)} - """ - end - - parsed_table - end - - defp parse_file_path_examples(path, context) do - file = File.read!(path) - - case path |> Path.extname() |> String.downcase() do - md when md in [".md", ".markdown"] -> parse_examples(file, context) - ".csv" -> parse_csv_file(file, context) - ".tsv" -> parse_tsv_file(file, context) - _ -> raise "Unsupported file extension for parameterized tests #{path} #{file_meta(context)}" - end - end - - defp parse_csv_file(file, context) do - file - |> ParameterizedTest.CsvParser.parse_string(skip_headers: false) - |> parse_csv_rows(context) - end - - defp parse_tsv_file(file, context) do - file - |> ParameterizedTest.TsvParser.parse_string() - |> Enum.map(&String.trim/1) - |> parse_csv_rows(context) - end - - defp parse_md_rows(rows, context) - defp parse_md_rows([], _context), do: [] - - defp parse_md_rows([header | rows], context) do - headers = - header - |> split_cells() - |> Enum.map(&String.to_atom/1) - - rows - |> Enum.reject(&separator_or_comment_row?/1) - |> Enum.map(fn row -> - cells = - row - |> split_cells() - |> Enum.map(&eval_cell(&1, row, context)) - - check_cell_count(cells, headers, row, context) - - headers - |> Enum.zip(cells) - |> Map.new() - end) - |> Enum.reject(&(&1 == %{})) - end - - defp parse_csv_rows(rows, context) - defp parse_csv_rows([], _context), do: [] - - defp parse_csv_rows([header | rows], context) do - headers = Enum.map(header, &String.to_atom/1) - - rows - |> Enum.map(fn row -> - cells = Enum.map(row, &eval_cell(&1, row, context)) - - check_cell_count(cells, headers, row, context) - - headers - |> Enum.zip(cells) - |> Map.new() - end) - |> Enum.reject(&(&1 == %{})) - end - - defp eval_cell(cell, row, _context) do - case Code.eval_string(cell, [], log: false) do - {val, []} -> val - _ -> raise "Failed to evaluate example cell `#{cell}` in row `#{row}`}" - end - rescue - _e in [SyntaxError, CompileError, TokenMissingError] -> - String.trim(cell) - - e -> - reraise "Failed to evaluate example cell `#{cell}` in row `#{row}`. #{inspect(e)}", __STACKTRACE__ - end - - defp check_cell_count(cells, headers, row, context) do - if length(cells) != length(headers) do - raise """ - The number of cells in each row must exactly match the - number of headers on your example table. - - Problem row#{file_meta(context)}: - #{row} - - Expected headers: - #{inspect(headers)} - """ - end - end - - defp split_cells(row) do - row - |> String.split("|", trim: true) - |> Enum.map(&String.trim/1) - end - - # A regex to match rows consisting of pipes separated by hyphens, like |------|-----| - @separator_regex ~r/^\|( ?-+ ?\|)+$/ - - defp separator_or_comment_row?("#" <> _), do: true - - defp separator_or_comment_row?(row) do - Regex.match?(@separator_regex, row) - end - - defp file_meta(%{file: file, line: line}) when is_binary(file) and is_integer(line) do - " (#{file}:#{line})" - end - - defp file_meta(_), do: "" end diff --git a/lib/parameterized_test/parser.ex b/lib/parameterized_test/parser.ex new file mode 100644 index 0000000..f5dd672 --- /dev/null +++ b/lib/parameterized_test/parser.ex @@ -0,0 +1,166 @@ +defmodule ParameterizedTest.Parser do + @moduledoc false + + @typep context :: [{:line, integer} | {:file, String.t()}] + + @spec parse_examples(String.t() | list, context()) :: [map()] + def parse_examples(table, context \\ []) + + def parse_examples(table, context) when is_binary(table) do + table + |> String.split("\n", trim: true) + |> Enum.map(&String.trim/1) + |> parse_md_rows(context) + end + + # This function head handles a list of already-parsed examples, like: + # param_test "accepts a list of maps or keyword lists", + # [ + # [int_1: 99, int_2: 100], + # %{int_1: 101, int_2: 102} + # ], %{int_1: int_1, int_2: int_2} do + def parse_examples(table, context) when is_list(table) do + {evaled_table, _, _} = Code.eval_quoted_with_env(table, [], __ENV__) + + case evaled_table do + str when is_binary(str) -> parse_file_path_examples(str, context) + list when is_list(list) -> parse_hand_rolled_table(list, context) + end + end + + defp parse_hand_rolled_table(evaled_table, context) do + parsed_table = Enum.map(evaled_table, &Map.new/1) + + keys = MapSet.new(parsed_table, &Map.keys/1) + + if MapSet.size(keys) > 1 do + raise """ + The keys in each row must be the same across all rows in your example table. + + Found differing key sets#{file_meta(context)}: + #{for key_set <- Enum.sort(keys), do: inspect(key_set)} + """ + end + + parsed_table + end + + @spec parse_file_path_examples(String.t(), context()) :: [map()] + def parse_file_path_examples(path, context) do + file = File.read!(path) + + case path |> Path.extname() |> String.downcase() do + md when md in [".md", ".markdown"] -> parse_examples(file, context) + ".csv" -> parse_csv_file(file, context) + ".tsv" -> parse_tsv_file(file, context) + _ -> raise "Unsupported file extension for parameterized tests #{path} #{file_meta(context)}" + end + end + + defp parse_csv_file(file, context) do + file + |> ParameterizedTest.CsvParser.parse_string(skip_headers: false) + |> parse_csv_rows(context) + end + + defp parse_tsv_file(file, context) do + file + |> ParameterizedTest.TsvParser.parse_string() + |> Enum.map(&String.trim/1) + |> parse_csv_rows(context) + end + + defp parse_md_rows(rows, context) + defp parse_md_rows([], _context), do: [] + + defp parse_md_rows([header | rows], context) do + headers = + header + |> split_cells() + |> Enum.map(&String.to_atom/1) + + rows + |> Enum.reject(&separator_or_comment_row?/1) + |> Enum.map(fn row -> + cells = + row + |> split_cells() + |> Enum.map(&eval_cell(&1, row, context)) + + check_cell_count(cells, headers, row, context) + + headers + |> Enum.zip(cells) + |> Map.new() + end) + |> Enum.reject(&(&1 == %{})) + end + + defp parse_csv_rows(rows, context) + defp parse_csv_rows([], _context), do: [] + + defp parse_csv_rows([header | rows], context) do + headers = Enum.map(header, &String.to_atom/1) + + rows + |> Enum.map(fn row -> + cells = Enum.map(row, &eval_cell(&1, row, context)) + + check_cell_count(cells, headers, row, context) + + headers + |> Enum.zip(cells) + |> Map.new() + end) + |> Enum.reject(&(&1 == %{})) + end + + defp eval_cell(cell, row, _context) do + case Code.eval_string(cell, [], log: false) do + {val, []} -> val + _ -> raise "Failed to evaluate example cell `#{cell}` in row `#{row}`}" + end + rescue + _e in [SyntaxError, CompileError, TokenMissingError] -> + String.trim(cell) + + e -> + reraise "Failed to evaluate example cell `#{cell}` in row `#{row}`. #{inspect(e)}", __STACKTRACE__ + end + + defp check_cell_count(cells, headers, row, context) do + if length(cells) != length(headers) do + raise """ + The number of cells in each row must exactly match the + number of headers on your example table. + + Problem row#{file_meta(context)}: + #{row} + + Expected headers: + #{inspect(headers)} + """ + end + end + + defp split_cells(row) do + row + |> String.split("|", trim: true) + |> Enum.map(&String.trim/1) + end + + # A regex to match rows consisting of pipes separated by hyphens, like |------|-----| + @separator_regex ~r/^\|( ?-+ ?\|)+$/ + + defp separator_or_comment_row?("#" <> _), do: true + + defp separator_or_comment_row?(row) do + Regex.match?(@separator_regex, row) + end + + defp file_meta(%{file: file, line: line}) when is_binary(file) and is_integer(line) do + " (#{file}:#{line})" + end + + defp file_meta(_), do: "" +end diff --git a/lib/parameterized_test/sigil_v114.exs b/lib/parameterized_test/sigil_v114.exs index 3a54122..d76cb8c 100644 --- a/lib/parameterized_test/sigil_v114.exs +++ b/lib/parameterized_test/sigil_v114.exs @@ -55,6 +55,6 @@ defmodule ParameterizedTest.Sigil do @spec sigil_x(String.t(), Keyword.t()) :: [map()] # credo:disable-for-next-line Credo.Check.Readability.FunctionNames def sigil_x(table, _opts \\ []) do - ParameterizedTest.parse_examples(table) + ParameterizedTest.Parser.parse_examples(table) end end diff --git a/lib/parameterized_test/sigil_v115.exs b/lib/parameterized_test/sigil_v115.exs index 917cf01..3510b1b 100644 --- a/lib/parameterized_test/sigil_v115.exs +++ b/lib/parameterized_test/sigil_v115.exs @@ -68,6 +68,6 @@ defmodule ParameterizedTest.Sigil do @spec sigil_PARAMS(String.t(), Keyword.t()) :: [map()] # credo:disable-for-next-line Credo.Check.Readability.FunctionNames def sigil_PARAMS(table, _opts \\ []) do - ParameterizedTest.parse_examples(table) + ParameterizedTest.Parser.parse_examples(table) end end diff --git a/test/parameterized_test/parser_test.exs b/test/parameterized_test/parser_test.exs new file mode 100644 index 0000000..5342a42 --- /dev/null +++ b/test/parameterized_test/parser_test.exs @@ -0,0 +1,11 @@ +defmodule ParameterizedTest.ParserTest do + use ExUnit.Case, async: true + + describe "parse_examples/1" do + test "accepts strings that parse as empty" do + for empty <- ["", " ", "\n", "\t", "\r\n", "\n\n \r\n \t \r"] do + assert ParameterizedTest.Parser.parse_examples(empty) == [] + end + end + end +end diff --git a/test/parameterized_test_test.exs b/test/parameterized_test_test.exs index 9e2d65d..fb5a25e 100644 --- a/test/parameterized_test_test.exs +++ b/test/parameterized_test_test.exs @@ -268,7 +268,7 @@ defmodule ParameterizedTestTest do end end - @module_examples ParameterizedTest.parse_examples(""" + @module_examples ParameterizedTest.Parser.parse_examples(""" | int_1 | int_2 | | 99 | 100 | """) @@ -331,12 +331,4 @@ defmodule ParameterizedTestTest do end end end - - describe "parse_examples/1" do - test "accepts strings that parse as empty" do - for empty <- ["", " ", "\n", "\t", "\r\n", "\n\n \r\n \t \r"] do - assert ParameterizedTest.parse_examples(empty) == [] - end - end - end end