lib/benchee/conversion/format.ex

defmodule Benchee.Conversion.Format do
  @moduledoc """
  Functions for formatting values and their unit labels. Different domains
  handle this task differently, for example durations and counts.

  See `Benchee.Conversion.Count` and `Benchee.Conversion.Duration` for examples.
  """

  alias Benchee.Conversion.Unit

  @doc """
  Formats a number as a string, with a unit label. See `Benchee.Conversion.Count`
  and `Benchee.Conversion.Duration` for examples
  """
  @callback format(number) :: String.t()

  @doc """
  Formats in a more "human" way, one biggest unit at a time.

  So instead of 1.5h it says 1h 30min
  """
  @callback format_human(number) :: String.t()

  # Generic formatting functions

  @doc """
  Formats a unit value with specified label and separator
  """
  def format(number, label, separator) do
    separator = separator(label, separator)
    "#{number_format(number)}#{separator}#{label}"
  end

  defp number_format(number) when is_float(number) do
    number
    |> :erlang.float_to_list(decimals: float_precision(number))
    |> to_string
  end

  defp number_format(number) when is_integer(number) do
    to_string(number)
  end

  @doc """
  Formats a unit value in the domain described by `module`. The module should
  provide a `units/0` function that returns a Map like

      %{ :unit_name => %Benchee.Conversion.Unit{ ... } }

  Additionally, `module` may specify a `separator/0` function, which provides a
  custom separator string that will appear between the value and label in the
  formatted output. If no `separator/0` function exists, the default separator
  (a single space) will be used.

      iex> format({1.0, :kilobyte}, Benchee.Conversion.Memory)
      "1 KB"

  """
  def format({number, unit = %Unit{}}) do
    format(number, label(unit), separator())
  end

  def format({number, unit = %Unit{}}, _module) do
    format({number, unit})
  end

  def format({number, unit_atom}, module) do
    format({number, module.unit_for(unit_atom)})
  end

  def format(number, module) when is_number(number) do
    number
    |> module.scale()
    |> format
  end

  @doc """
  Human friendly duration format for time as a string.

  The output is a sequence of values and unit labels separated by a space.
  Only units whose value is non-zero are included in the output.
  The passed number is duration in the base unit - nanoseconds.
  """
  def format_human(0, module) do
    format(0, module)
  end

  def format_human(+0.0, module) do
    format(0, module)
  end

  def format_human(number, module) when is_number(number) do
    number
    |> split_into_place_values(module)
    |> Enum.map_join(" ", &format/1)
  end

  # Returns a list of place vaules with corresponding units for the `number`.
  # The output is sorted descending by magnitude of units and excludes tuples with place value 0.
  # Place values are `non_neg_integer` for non-base units,
  # however base unit may also be `float` becuase the decimals can't be split further.
  @spec split_into_place_values(number, module) :: [{number, Unit.t()}]
  defp split_into_place_values(number, module) do
    descending_units = units_descending(module)

    place_values(number, descending_units)
  end

  defp units_descending(module) do
    Enum.sort(module.units(), &(&1.magnitude >= &2.magnitude))
  end

  @spec place_values(number, [Unit.t()]) :: [{number, Unit.t()}]
  defp place_values(0, _units), do: []
  defp place_values(+0.0, _units), do: []

  # smalles unit, carries the decimal
  defp place_values(number, [base_unit = %Unit{magnitude: 1}]), do: [{number, base_unit}]

  defp place_values(number, [unit | units]) do
    integer_number = trunc(number)
    decimal_carry = number - integer_number
    int_carry = rem(integer_number, unit.magnitude)
    carry = decimal_carry + int_carry

    place_value = div(integer_number, unit.magnitude)

    case place_value do
      0 -> place_values(carry, units)
      place_value -> [{place_value, unit} | place_values(carry, units)]
    end
  end

  @default_separator " "
  # should we need it again, a customer separator could be returned
  # per module here
  defp separator do
    @default_separator
  end

  # Returns the separator, or an empty string if there isn't a label
  defp separator(label, _separator) when label == "" or label == nil, do: ""
  defp separator(_label, separator), do: separator

  # Fetches the label for the given unit
  defp label(%Unit{label: label}) do
    label
  end

  defp float_precision(float) when trunc(float) == float, do: 0
  defp float_precision(float) when float < 0.01, do: 5
  defp float_precision(float) when float < 0.1, do: 4
  defp float_precision(float) when float < 0.2, do: 3
  defp float_precision(_float), do: 2
end