lib/lcov_ex.ex

defmodule LcovEx do
  @moduledoc """
  Lcov file generator for Elixir projects.

  Go to https://github.com/dariodf/lcov_ex for installation and usage instructions.
  """

  alias LcovEx.{Formatter, Stats}

  def start(compile_path, opts) do
    log_info("Compiling coverage... ")
    :cover.start()

    case :cover.compile_beam_directory(compile_path |> to_charlist) do
      results when is_list(results) ->
        :ok

      {:error, _} ->
        Mix.raise("Failed to compile coverage for directory: " <> compile_path)
    end

    output = opts[:output]
    caller_cwd = opts[:cwd] || File.cwd!()
    ignored_paths = Keyword.get(opts, :ignore_paths, [])

    fn ->
      log_info("\nGenerating lcov file...")

      lcov =
        :cover.modules()
        |> Enum.sort()
        |> Enum.map(&calculate_module_coverage(&1, ignored_paths, caller_cwd))

      File.mkdir_p!(output)
      path = "#{output}/lcov.info"
      File.write!(path, lcov, [:write])

      inform_file_written(opts)

      :cover.stop()
    end
  end

  defp calculate_module_coverage(mod, ignored_paths, cwd) do
    path = mod.module_info(:compile)[:source] |> to_string() |> Path.relative_to(cwd)

    # Ignore compiled modules with path:
    # - not relative to the app (e.g. generated by umbrella dependencies)
    # - ignored by configuration
    if Path.type(path) != :relative or Enum.any?(ignored_paths, &String.starts_with?(path, &1)) do
      []
    else
      calculate_and_format_coverage(mod, path)
    end
  end

  defp calculate_and_format_coverage(mod, path) do
    {:ok, fun_data} = :cover.analyse(mod, :calls, :function)
    {functions_coverage, %{fnf: fnf, fnh: fnh}} = Stats.function_coverage_data(fun_data)

    {:ok, lines_data} = :cover.analyse(mod, :calls, :line)
    {lines_coverage, %{lf: lf, lh: lh}} = Stats.line_coverage_data(lines_data)

    Formatter.format_lcov(mod, path, functions_coverage, fnf, fnh, lines_coverage, lf, lh)
  end

  defp inform_file_written(opts) do
    output = opts[:output]
    caller_cwd = opts[:cwd] || File.cwd!()
    path = "#{output}/lcov.info"
    keep? = opts[:keep] || false
    recursing? = Mix.Task.recursing?()
    app_path = opts[:app_path]
    app = Mix.Project.config()[:app]
    app_lcov_path = File.cwd!() |> Path.relative_to(caller_cwd) |> Path.join(path)

    cond do
      recursing? && keep? ->
        # Using --keep option from umbrella
        log_info("\nCoverage file for #{app} created at #{app_lcov_path}")

      app_path && File.cwd!() != caller_cwd ->
        # Using an umbrella app path from umbrella
        log_info("\nCoverage file created at #{app_lcov_path}")

      File.cwd!() == caller_cwd ->
        # Not an umbrella
        log_info("\nCoverage file created at #{path}")

      true ->
        # Don't log for umbrellas unless using --keep
        :no_log
    end
  end

  defp log_info(msg) do
    Mix.shell().info(msg)
  end
end