defmodule Flaky.Parser do @moduledoc """ Pure functions for parsing git diffs and test output. """ @doc """ Parses git diff output and extracts new test names grouped by file. ## Examples iex> diff = \""" ...> +++ b/test/foo_test.exs ...> + test "does something" do ...> \""" iex> Flaky.Parser.parse_diff_for_test_names(diff) %{"test/foo_test.exs" => ["does something"]} """ @spec parse_diff_for_test_names(String.t()) :: %{String.t() => list(String.t())} def parse_diff_for_test_names(diff_output) do diff_output |> String.split("\n") |> Enum.reduce({nil, %{}}, fn line, {current_file, acc} -> cond do String.starts_with?(line, "+++ b/test/") && String.ends_with?(line, "_test.exs") -> file = String.trim_leading(line, "+++ b/") {file, Map.put_new(acc, file, [])} current_file != nil && is_new_test_line?(line) -> name = extract_test_name(line) {current_file, Map.update!(acc, current_file, &[name | &1])} true -> {current_file, acc} end end) |> elem(1) |> Map.new(fn {file, names} -> {file, Enum.reverse(names)} end) end @doc """ Checks if a diff line represents a new test definition. ## Examples iex> Flaky.Parser.is_new_test_line?("+ test \\"my test\\" do") true iex> Flaky.Parser.is_new_test_line?("- test \\"my test\\" do") false iex> Flaky.Parser.is_new_test_line?(" test \\"my test\\" do") false """ @spec is_new_test_line?(String.t()) :: boolean() def is_new_test_line?(line) do String.starts_with?(line, "+") && Regex.match?(~r/^\+\s*(test)\s+"/, line) end @doc """ Extracts the test name from a diff line. ## Examples iex> Flaky.Parser.extract_test_name("+ test \\"my test\\" do") "my test" iex> Flaky.Parser.extract_test_name("invalid") "unknown" """ @spec extract_test_name(String.t()) :: String.t() def extract_test_name(line) do case Regex.run(~r/^\+\s*(?:test)\s+"([^"]+)"/, line) do [_, name] -> name _ -> "unknown" end end @doc """ Groups test targets by their file path. ## Examples iex> Flaky.Parser.group_by_file(["test/foo_test.exs:10", "test/foo_test.exs:20", "test/bar_test.exs:5"]) [{"test/bar_test.exs", ["test/bar_test.exs:5"]}, {"test/foo_test.exs", ["test/foo_test.exs:10", "test/foo_test.exs:20"]}] """ @spec group_by_file(list(String.t())) :: list({String.t(), list(String.t())}) def group_by_file(test_targets) do test_targets |> Enum.group_by(fn target -> target |> String.split(":") |> hd() end) |> Enum.map(fn {file, targets} -> {file, targets} end) end @doc """ Converts a test target path to a log filename. ## Examples iex> Flaky.Parser.target_to_filename("test/foo/bar_test.exs:42") "foo_bar_test_42.log" iex> Flaky.Parser.target_to_filename("test/simple_test.exs:10") "simple_test_10.log" """ @spec target_to_filename(String.t()) :: String.t() def target_to_filename(target) do target |> String.replace("test/", "") |> String.replace("/", "_") |> String.replace(":", "_") |> String.replace(".exs", "") |> Kernel.<>(".log") end @doc """ Generates seed arguments for mix test. ## Examples iex> Flaky.Parser.seed_args(nil) [] iex> Flaky.Parser.seed_args(12345) ["--seed", "12345"] """ @spec seed_args(integer() | nil) :: list(String.t()) def seed_args(nil), do: [] def seed_args(seed), do: ["--seed", to_string(seed)] @doc """ Parses test output to find which targets actually failed. ## Examples iex> output = " 1) test/foo_test.exs:10\\n some error" iex> Flaky.Parser.parse_failed_tests(output, ["test/foo_test.exs:10", "test/foo_test.exs:20"]) ["test/foo_test.exs:10"] """ @spec parse_failed_tests(String.t(), list(String.t())) :: list(String.t()) def parse_failed_tests(output, targets) do failed_locations = Regex.scan(~r/(test\/[^\s:]+_test\.exs):(\d+)/, output) |> Enum.map(fn [_full, file, line] -> "#{file}:#{line}" end) |> Enum.uniq() Enum.filter(targets, fn target -> target in failed_locations end) end @doc """ Counts the number of "Finished in" occurrences in test output. ## Examples iex> Flaky.Parser.count_finished("Finished in 0.1s\\nFinished in 0.2s") 2 iex> Flaky.Parser.count_finished("no matches here") 0 """ @spec count_finished(String.t()) :: non_neg_integer() def count_finished(data) do data |> String.split("\n") |> Enum.count(&String.contains?(&1, "Finished in")) end @doc """ Finds the error section in log lines, starting from the first failure marker. ## Examples iex> lines = ["some output", " 1) test failure", "details", "more details"] iex> Flaky.Parser.find_error_section(lines) [" 1) test failure", "details", "more details"] """ @spec find_error_section(list(String.t())) :: list(String.t()) def find_error_section(lines) do start_index = Enum.find_index(lines, fn line -> String.contains?(line, " 1)") or String.contains?(line, "** (") end) case start_index do nil -> Enum.take(lines, 10) idx -> Enum.slice(lines, idx, 20) end end @type test_info :: %{file: String.t(), line: pos_integer(), name: String.t()} @type line_resolver :: (String.t(), String.t() -> pos_integer()) @doc """ Parses git diff output and returns a list of new tests with their locations. Takes a line resolver function that finds the line number for a test name in a file. This allows injecting different implementations for testing vs production. ## Examples iex> diff = \""" ...> +++ b/test/foo_test.exs ...> + test "my test" do ...> \""" iex> resolver = fn _file, _name -> 42 end iex> Flaky.Parser.find_new_tests_from_diff(diff, resolver) [%{file: "test/foo_test.exs", line: 42, name: "my test"}] """ @spec find_new_tests_from_diff(String.t(), line_resolver()) :: list(test_info()) def find_new_tests_from_diff(diff_output, line_resolver) do diff_output |> parse_diff_for_test_names() |> resolve_line_numbers(line_resolver) end @spec resolve_line_numbers(%{String.t() => list(String.t())}, line_resolver()) :: list(test_info()) defp resolve_line_numbers(test_names_by_file, line_resolver) do Enum.flat_map(test_names_by_file, fn {file, names} -> Enum.map(names, fn name -> line = line_resolver.(file, name) %{file: file, line: line, name: name} end) end) end end