Packages

Formatting function for durations

Current section

Files

Jump to
exduration lib ex_duration.ex
Raw

lib/ex_duration.ex

defmodule ExDuration do
@moduledoc """
Formatting durations into human readable hours, mintes, seconds and sub second strings, like _12h3m_, _45μs_, _67ms_ and _8.9s_.
"""
@hour trunc(60 * 60 * 1.0e6)
@minute trunc(60 * 1.0e6)
@second trunc(1.0e6)
@millisecond trunc(1.0e3)
@doc """
Format `duration`
## Examples
iex> ExDuration.format(100, :microsecond)
"100μs"
iex> ExDuration.format(50, :millisecond)
"50ms"
iex> ExDuration.format(25, :second)
"25s"
iex> ExDuration.format(12, :minute)
"12m"
iex> ExDuration.format(6, :hour)
"6h"
iex> ExDuration.format(22345050100, :microsecond)
"6h12m25.0501s"
"""
@spec format(duration :: integer, :microsecond | :millisecond | :second | :minute | :hour) ::
String.t()
def format(duration, _) when not is_integer(duration),
do: raise("only integer durations are supported")
def format(duration, _) when duration == 0, do: "0s"
def format(duration, unit) do
case unit do
:microsecond -> format_microsecond(duration)
:millisecond -> format_microsecond(duration * @millisecond)
:second -> format_microsecond(duration * @second)
:minute -> format_microsecond(duration * @minute)
:hour -> format_microsecond(duration * @hour)
_ -> raise "unsupported unit #{unit}"
end
end
@doc """
Format the duration since `start`.
## Examples
iex> start = :os.system_time(:second)
...> ExDuration.since(start, :second)
"0s"
"""
@spec since(start :: integer, :microsecond | :millisecond | :second) :: String.t()
def since(start, unit \\ :microsecond) do
format(:os.system_time(unit) - start, unit)
end
@doc """
Format the duration between `datetime1` and `datetime2`.
## Examples
iex> dt1 = %DateTime{year: 2000, month: 1, day: 1, zone_abbr: "CET",
...> hour: 10, minute: 10, second: 10, microsecond: {100100, 6},
...> utc_offset: 3600, std_offset: 0, time_zone: "Europe/Stockholm"}
iex> dt2 = %DateTime{year: 2000, month: 1, day: 1, zone_abbr: "CET",
...> hour: 0, minute: 0, second: 0, microsecond: {0, 0},
...> utc_offset: 3600, std_offset: 0, time_zone: "Europe/Stockholm"}
iex> ExDuration.between(dt1, dt2)
"10h10m10.1001s"
"""
@spec between(Calendar.datetime(), Calendar.datetime()) ::
String.t()
def between(datetime1, datetime2) do
diff = DateTime.diff(datetime1, datetime2, :microsecond)
format(diff, :microsecond)
end
@spec format_microsecond(duration :: integer) :: String.t()
defp format_microsecond(duration) when duration > 0,
do: format_microsecond("", duration)
defp format_microsecond(duration) when duration < 0,
do: format_microsecond("-", abs(duration))
@spec format_microsecond(prefix :: String.t(), duration :: integer) :: String.t()
defp format_microsecond(prefix, duration) when duration > 0 do
units = [{@hour, "h"}, {@minute, "m"}]
initial = {[prefix], duration}
{hm, micros} =
Enum.reduce(units, initial, fn {n, suffix}, {acc, rem} ->
case div(rem, n) do
i when i > 0 -> {acc ++ [i, suffix], rem(rem, n)}
_ -> {acc, rem}
end
end)
Enum.join(hm ++ [format_second(micros, micros == duration)])
end
@spec format_second(duration :: integer, allow_subsecond :: bool) :: String.t()
defp format_second(micros, allow_subsecond) do
seconds = trunc(micros / @second)
case {seconds, micros - seconds * @second} do
{s, rem} when allow_subsecond and s == 0 and rem < @millisecond ->
format_μs(rem)
{s, rem} when allow_subsecond and s == 0 ->
format_ms(rem)
{s, rem} when s > 0 ->
"#{s}#{format_subsecond(rem)}s"
_ ->
""
end
end
@spec format_μs(micros :: integer) :: String.t()
defp format_μs(micros), do: "#{trunc(micros)}μs"
@spec format_ms(micros :: integer) :: String.t()
defp format_ms(micros) do
ms = trunc(micros / 1000)
us =
case trunc(micros - ms * 1000) do
0 -> ""
us -> "." <> String.trim_trailing("#{us}", "0")
end
"#{ms}#{us}ms"
end
@spec format_subsecond(micros :: integer) :: String.t()
defp format_subsecond(micros) when micros == 0, do: ""
defp format_subsecond(micros) do
subsecond =
"#{trunc(micros)}"
|> String.pad_leading(6, "0")
|> String.trim_trailing("0")
"." <> subsecond
end
@subsecond_regex ~r/^([1-9][0-9]*)(ms|μs)$/
@hms_regex ~r/^(([1-9][0-9]*)h)?(([1-9][0-9]*)m)?(([1-9][0-9]*|0)(\.([0-9]{1,6}))?s)?$/
@doc """
Parses `duration` into `unit`s.
Raises `ArgumentError` if parsing `duration` fails.
## Examples
iex> ExDuration.parse!("1s", :millisecond)
1000
iex> ExDuration.parse!("", :millisecond)
** (ArgumentError) invalid duration format
iex> ExDuration.parse!("1s", :millis)
** (ArgumentError) argument error
"""
@doc since: "0.1.1"
@spec parse!(String.t(), unit :: :microsecond | :millisecond | :second | :minute | :hour) ::
integer
def parse!(duration, unit) do
case parse(duration, unit) do
{:ok, n} -> n
:error -> raise ArgumentError, message: "invalid duration format"
end
end
@doc """
Parses `duration` into `unit`s.
Returns `{:ok, integer}` or `:error` if parsing `duration` fails.
### Format: hms(.ss)
* `h` - hours as integer
* `m` - minutes as integer
* `s` - seconds as integer
* `ss` - sub seconds (optional, maximum six digits)
### Format: [ms|μs]
* `ms` - milliseconds as integer
* `μs` - microseconds as integer
## Examples
iex> ExDuration.parse("1h2m3.456789s", :second)
{:ok, 3723}
iex> ExDuration.parse("5m", :millisecond)
{:ok, 300000}
iex> ExDuration.parse("100ms", :millisecond)
{:ok, 100}
iex> ExDuration.parse("", :hour)
:error
"""
@doc since: "0.1.1"
@spec parse(String.t(), unit :: :microsecond | :millisecond | :second | :minute | :hour) ::
{:ok, integer} | :error
def parse(duration, _unit) when byte_size(duration) == 0, do: :error
def parse(duration, unit) do
case parse_to_micros(duration) do
:error -> :error
micros -> {:ok, micros_to(micros, unit)}
end
end
@spec parse_to_micros(String.t()) :: micros :: integer | :error
defp parse_to_micros(duration) do
cond do
duration == "" -> :error
String.match?(duration, @hms_regex) -> hms_to_micros(duration)
String.match?(duration, @subsecond_regex) -> subsecond_to_micros(duration)
true -> :error
end
end
@spec micros_to(micros :: integer, unit :: atom) :: integer
defp micros_to(micros, unit) do
case unit do
:microsecond -> micros
:millisecond -> div(micros, @millisecond)
:second -> div(micros, @second)
:minute -> div(micros, @minute)
:hour -> div(micros, @hour)
_ -> raise ArgumentError
end
end
@spec hms_to_micros(String.t()) :: integer
defp hms_to_micros(duration) do
case Regex.run(@hms_regex, duration) do
[_duration, _hour, h] ->
hms_to_micros(h, "", "", "")
[_duration, _hour, h, _minute, m] ->
hms_to_micros(h, m, "", "")
[_duration, _hour, h, _minute, m, _second, s] ->
hms_to_micros(h, m, s, "")
[_duration, _hour, h, _minute, m, _second, s, _sub, sub] ->
micros = String.pad_trailing(sub, 6, "0")
hms_to_micros(h, m, s, micros)
end
end
@spec hms_to_micros(
hour :: String.t(),
minute :: String.t(),
second :: String.t(),
micros :: String.t()
) :: integer
defp hms_to_micros(hour, minute, second, micros) do
[hour, minute, second, micros]
|> Enum.map(&parse_integer/1)
|> Enum.zip([@hour, @minute, @second, 1])
|> Enum.reduce(0, fn zip, acc ->
{n, multiplier} = zip
acc + n * multiplier
end)
end
@spec parse_integer(String.t()) :: integer
defp parse_integer(str) do
case str do
"" ->
0
_ ->
{i, _} = Integer.parse(str)
i
end
end
@spec subsecond_to_micros(String.t()) :: integer
defp subsecond_to_micros(duration) do
case Regex.run(@subsecond_regex, duration) do
[_duration, millis, "ms"] ->
{millis, _} = Integer.parse(millis)
millis * @millisecond
[_duration, micros, "μs"] ->
{micros, _} = Integer.parse(micros)
micros
end
end
end