lib/parse/duration/parsers/iso8601.ex

defmodule Timex.Parse.Duration.Parsers.ISO8601Parser do
  @moduledoc """
  This module parses ISO-8601 duration strings into Duration structs.
  """
  use Timex.Parse.Duration.Parser

  @numeric '.0123456789'

  @doc """
  Parses an ISO-8601 formatted duration string into a Duration struct.
  The parse result is wrapped in a :ok/:error tuple.

  ## Examples

      iex> {:ok, d} = #{__MODULE__}.parse("P15Y3M2DT1H14M37.25S")
      ...> Timex.Format.Duration.Formatter.format(d)
      "P15Y3M2DT1H14M37.25S"

      iex> {:ok, d} = #{__MODULE__}.parse("P15Y3M2D")
      ...> Timex.Format.Duration.Formatter.format(d)
      "P15Y3M2D"

      iex> {:ok, d} = #{__MODULE__}.parse("PT3H12M25.001S")
      ...> Timex.Format.Duration.Formatter.format(d)
      "PT3H12M25.001S"

      iex> {:ok, d} = #{__MODULE__}.parse("P2W")
      ...> Timex.Format.Duration.Formatter.format(d)
      "P14D"

      iex> #{__MODULE__}.parse("P15YT3D")
      {:error, "invalid use of date component after time separator"}

  """
  @spec parse(String.t()) :: {:ok, Duration.t()} | {:error, term}
  def parse(<<>>), do: {:error, "input string cannot be empty"}

  def parse(<<?P, rest::binary>>) do
    case parse_components(rest, []) do
      {:error, _} = err ->
        err

      [{?W, w}] ->
        {:ok, Duration.from_days(7 * w)}

      components when is_list(components) ->
        result =
          Enum.reduce(components, {false, Duration.zero()}, fn
            _, {:error, _} = err ->
              err

            {?Y, y}, {false, d} ->
              {false, Duration.add(d, Duration.from_days(365 * y))}

            {?M, m}, {false, d} ->
              {false, Duration.add(d, Duration.from_days(30 * m))}

            {?D, dd}, {false, d} ->
              {false, Duration.add(d, Duration.from_days(dd))}

            ?T, {false, d} ->
              {true, d}

            ?T, {true, _d} ->
              {:error, "encountered duplicate time separator T"}

            {?H, h}, {true, d} ->
              {true, Duration.add(d, Duration.from_hours(h))}

            {?M, m}, {true, d} ->
              {true, Duration.add(d, Duration.from_minutes(m))}

            {?S, s}, {true, d} ->
              {true, Duration.add(d, Duration.from_seconds(s))}

            {?W, _w}, {_, _} ->
              {:error,
               "Found 'W', a basic format designator, but the parse indicates " <>
                 "this is an extended format, mixing the two formats is disallowed"}

            {unit, _}, {true, _d} when unit in [?Y, ?D] ->
              {:error, "invalid use of date component after time separator"}

            {unit, _}, {false, _d} when unit in [?H, ?S] ->
              {:error, "missing T separator between date and time components"}
          end)

        case result do
          {:error, _} = err -> err
          {_, duration} -> {:ok, duration}
        end
    end
  end

  def parse(<<c::utf8, _::binary>>), do: {:error, "expected P, got #{<<c::utf8>>}"}
  def parse(s) when is_binary(s), do: {:error, "unexpected end of input"}

  @spec parse_components(binary, [{integer, number}]) ::
          [{integer, number}] | {:error, String.t()}
  defp parse_components(<<>>, acc),
    do: Enum.reverse(acc)

  defp parse_components(<<?T>>, _acc),
    do: {:error, "unexpected end of input at T"}

  defp parse_components(<<?T, rest::binary>>, acc),
    do: parse_components(rest, [?T | acc])

  defp parse_components(<<c::utf8>>, _acc) when c in @numeric,
    do: {:error, "unexpected end of input at #{<<c::utf8>>}"}

  defp parse_components(<<c::utf8, rest::binary>>, acc) when c in @numeric do
    case parse_component(rest, {:integer, <<c::utf8>>}) do
      {:error, _} = err -> err
      {u, n, rest} -> parse_components(rest, [{u, n} | acc])
    end
  end

  defp parse_components(<<c::utf8>>, _acc),
    do: {:error, "unexpected end of input at #{<<c::utf8>>}"}

  defp parse_components(<<c::utf8, _::binary>>, _acc),
    do: {:error, "expected numeric, but got #{<<c::utf8>>}"}

  @spec parse_component(binary, {:float | :integer, binary}) ::
          {integer, number, binary} | {:error, msg :: binary()}
  defp parse_component(<<c::utf8>>, _acc) when c in @numeric,
    do: {:error, "unexpected end of input at #{<<c::utf8>>}"}

  defp parse_component(<<c::utf8>>, {type, acc}) when c in 'WYMDHS' do
    case cast_number(type, acc) do
      {n, _} -> {c, n, <<>>}
      :error -> {:error, "invalid number `#{acc}`"}
    end
  end

  defp parse_component(<<".", rest::binary>>, {:integer, acc}) do
    parse_component(rest, {:float, <<acc::binary, ".">>})
  end

  defp parse_component(<<c::utf8, rest::binary>>, {:integer, acc}) when c in @numeric do
    parse_component(rest, {:integer, <<acc::binary, c::utf8>>})
  end

  defp parse_component(<<c::utf8, rest::binary>>, {:float, acc}) when c in @numeric do
    parse_component(rest, {:float, <<acc::binary, c::utf8>>})
  end

  defp parse_component(<<c::utf8, rest::binary>>, {type, acc}) when c in 'WYMDHS' do
    case cast_number(type, acc) do
      {n, _} -> {c, n, rest}
      :error -> {:error, "invalid number `#{acc}`"}
    end
  end

  defp parse_component(<<c::utf8>>, _acc), do: {:error, "unexpected token #{<<c::utf8>>}"}

  defp parse_component(<<c::utf8, _::binary>>, _acc),
    do: {:error, "unexpected token #{<<c::utf8>>}"}

  @spec cast_number(:float | :integer, binary) :: {number(), binary()} | :error
  defp cast_number(:integer, binary), do: Integer.parse(binary)
  defp cast_number(:float, binary), do: Float.parse(binary)
end