lib/benchee/formatters/html.ex

defmodule Benchee.Formatters.HTML do
  @moduledoc """
  Functionality for converting Benchee benchmarking results to an HTML page
  with plotly.js generated graphs and friends.

  ## Examples

      list = Enum.to_list(1..10_000)
      map_fun = fn(i) -> [i, i * i] end

      Benchee.run(
        %{
          "flat_map" => fn -> Enum.flat_map(list, map_fun) end,
          "map.flatten" => fn -> list |> Enum.map(map_fun) |> List.flatten() end
        },
        formatters: [
          Benchee.Formatters.Console,
          {Benchee.Formatters.HTML, file: "samples_output/flat_map.html"}
        ]
      )
  """

  @behaviour Benchee.Formatter

  alias Benchee.{
    Configuration,
    Conversion,
    Formatters.HTML.Render,
    Formatters.JSON,
    Suite,
    Utility.FileCreation
  }

  @doc """
  Transforms the statistical results from benchmarking to html reports.

  Returns a map from file name/path to file content. This list is ready to be put into
  `Benchee.Utility.FileCreation.each/3`. It's a list of names to be "interleaved" with
  a main file name that points to the contents of the file. For example:

      %{["big list", "flat_map"] => "...file content..."}
  """
  @spec format(Suite.t(), map) :: [{list(String.t()), String.t()}]
  def format(
        %Suite{
          scenarios: scenarios,
          system: system,
          configuration: %Configuration{unit_scaling: unit_scaling}
        },
        opts
      ) do
    ensure_applications_loaded()
    %{file: filename, inline_assets: inline_assets} = merge_default_configuration(opts)

    scenario_data =
      scenarios
      # at best we'd keep the input order here, so no grouping but its easiest for now
      |> Enum.group_by(fn scenario -> scenario.input_name end)
      |> Map.new(fn input_to_scenarios = {input_name, _scenarios} ->
        {input_name,
         reports_for_input(input_to_scenarios, system, filename, unit_scaling, inline_assets)}
      end)

    index_data =
      Enum.map(scenario_data, fn {input_name, scenarios} ->
        {input_name, Enum.map(scenarios, fn {descriptors, _content} -> descriptors end)}
      end)

    scenario_pages = scenario_data |> Map.values() |> List.flatten()

    index_page = build_index(index_data, filename, system, inline_assets)

    [index_page | scenario_pages]
  end

  @doc """
  Writes the output of `Benchee.Formatters.HTML.format/2` to disk.

  Generates the following files:

  * index file (exactly like `file` is named)
  * a comparison of all the benchmarked jobs (one per benchmarked input)
  * for each job a detail page with more detailed run time graphs for that
    particular job (one per benchmark input)
  """
  @spec write(%{Suite.key() => String.t()}, map) :: :ok
  def write(data, opts) do
    %{
      file: filename,
      auto_open: auto_open?,
      inline_assets: inline_assets?
    } = merge_default_configuration(opts)

    prepare_folder_structure(filename, inline_assets?)
    FileCreation.each(data, filename)
    if auto_open?, do: open_report(filename)
    :ok
  end

  defp ensure_applications_loaded do
    _ = Application.load(:benchee)
    _ = Application.load(:benchee_html)
  end

  @default_configuration %{
    file: "benchmarks/output/results.html",
    auto_open: true,
    inline_assets: false
  }
  defp merge_default_configuration(opts) do
    Map.merge(@default_configuration, opts)
  end

  defp prepare_folder_structure(filename, inline_assets?) do
    base_directory = create_base_directory(filename)
    unless inline_assets?, do: copy_asset_files(base_directory)
    base_directory
  end

  defp create_base_directory(filename) do
    base_directory = Path.dirname(filename)
    File.mkdir_p!(base_directory)
    base_directory
  end

  @asset_directory "assets"
  defp copy_asset_files(base_directory) do
    asset_target_directory = Path.join(base_directory, @asset_directory)
    asset_source_directory = Application.app_dir(:benchee_html, "priv/assets/")
    File.cp_r!(asset_source_directory, asset_target_directory)
  end

  defp reports_for_input({input_name, scenarios}, system, filename, unit_scaling, inline_assets) do
    units = Conversion.units(scenarios, unit_scaling)

    scenario_reports =
      Enum.map(scenarios, fn scenario ->
        scenario_report(scenario, system, units, inline_assets)
      end)

    comparison = comparison_report(input_name, scenarios, system, filename, units, inline_assets)
    [comparison | scenario_reports]
  end

  defp scenario_report(scenario, system, units, inline_assets) do
    scenario_json = JSON.encode!(scenario)

    {
      [scenario.input_name, scenario.name],
      Render.scenario_detail(
        scenario,
        scenario_json,
        system,
        units,
        inline_assets
      )
    }
  end

  defp comparison_report(input_name, scenarios, system, filename, units, inline_assets) do
    scenarios_json = JSON.encode!(scenarios)

    input_suite = %{
      system: system,
      job_count: length(scenarios),
      filename: filename,
      scenarios: scenarios
    }

    {[input_name, "comparison"],
     Render.comparison(input_name, input_suite, units, scenarios_json, inline_assets)}
  end

  defp build_index(input_to_descriptors, filename, system, inline_assets?) do
    full_index_data = build_index_data(input_to_descriptors, filename)

    {"", Render.index(full_index_data, system, inline_assets?)}
  end

  defp build_index_data(input_to_descriptors, filename) do
    Enum.map(input_to_descriptors, fn {input_name, descriptors_list} ->
      {input_name,
       Enum.map(descriptors_list, fn descriptors ->
         Render.relative_file_path(filename, descriptors)
       end)}
    end)
  end

  defp open_report(filename) do
    browser = get_browser()
    {_, exit_code} = System.cmd(browser, [filename])
    unless exit_code > 0, do: IO.puts("Opened report using #{browser}")
  end

  defp get_browser do
    case :os.type() do
      {:unix, :darwin} -> "open"
      {:unix, _} -> "xdg-open"
      {:win32, _} -> "explorer"
    end
  end
end