lib/parse/datetime/parsers/iso8601_extended.ex

defmodule Timex.Parse.DateTime.Parsers.ISO8601Extended do
  use Combine.Helpers
  alias Combine.ParserState

  defparser parse(%ParserState{status: :ok, column: col, input: input, results: results} = state) do
    case parse_extended(input) do
      {:ok, parts, len, remaining} ->
        %{
          state
          | :column => col + len,
            :input => remaining,
            :results => [Enum.reverse(parts) | results]
        }

      {:error, reason, count} ->
        %{state | :status => :error, :column => col + count, :error => reason}
    end
  end

  def parse_extended(<<>>), do: {:error, "Expected year, but got end of input."}
  def parse_extended(input), do: parse_extended(input, :year, [], 0)

  def parse_extended(
        <<y1::utf8, y2::utf8, y3::utf8, y4::utf8, "-", rest::binary>>,
        :year,
        acc,
        count
      )
      when y1 >= ?0 and y1 <= ?9 and
             y2 >= ?0 and y2 <= ?9 and
             y3 >= ?0 and y3 <= ?9 and
             y4 >= ?0 and y4 <= ?9 do
    year = String.to_integer(<<y1::utf8, y2::utf8, y3::utf8, y4::utf8>>)
    parse_extended(rest, :month, [{:year4, year} | acc], count + 4)
  end

  def parse_extended(<<h::utf8, _::binary>>, :year, _acc, count),
    do: {:error, "Expected 4 digit year, but got `#{<<h::utf8>>}` instead.", count}

  def parse_extended(_, :year, _acc, count),
    do: {:error, "Expected 4 digit year.", count}

  def parse_extended(<<m1::utf8, m2::utf8, "-", rest::binary>>, :month, acc, count)
      when m1 >= ?0 and m1 < ?2 and
             m2 >= ?0 and m2 <= ?9 do
    month = String.to_integer(<<m1::utf8, m2::utf8>>)

    cond do
      month > 0 and month < 13 ->
        parse_extended(rest, :day, [{:month, month} | acc], count + 2)

      :else ->
        {:error, "Expected month between 1-12, but got `#{month}` instead.", count}
    end
  end

  def parse_extended(<<h::utf8, _::binary>>, :month, _acc, count),
    do: {:error, "Expected 2 digit month, but got `#{<<h::utf8>>}` instead.", count}

  def parse_extended(_, :month, _acc, count),
    do: {:error, "Expected 2 digit month.", count}

  def parse_extended(<<d1::utf8, d2::utf8, sep::utf8, rest::binary>>, :day, acc, count)
      when d1 >= ?0 and d1 <= ?3 and
             d2 >= ?0 and d2 <= ?9 do
    cond do
      sep in [?T, ?\s] ->
        day = String.to_integer(<<d1::utf8, d2::utf8>>)

        cond do
          day > 0 and day < 32 ->
            parse_extended(rest, :hour, [{:day, day} | acc], count + 3)

          :else ->
            {:error, "Expected day between 1-31, but got `#{day}` instead.", count}
        end

      :else ->
        {:error,
         "Expected valid date/time separator (T or space), but got `#{<<sep::utf8>>}` instead.",
         count + 2}
    end
  end

  def parse_extended(<<h::utf8, _::binary>>, :day, _acc, count),
    do: {:error, "Expected 2 digit day, but got `#{<<h::utf8>>}` instead.", count}

  def parse_extended(_, :day, _acc, count),
    do: {:error, "Expected 2 digit day.", count}

  def parse_extended(<<h1::utf8, h2::utf8, rest::binary>>, :hour, acc, count)
      when h1 >= ?0 and h1 < ?3 and
             h2 >= ?0 and h2 <= ?9 do
    hour = String.to_integer(<<h1::utf8, h2::utf8>>)

    cond do
      hour >= 0 and hour <= 24 ->
        case rest do
          <<":", rest::binary>> ->
            parse_extended(rest, :minute, [{:hour24, hour} | acc], count + 3)

          _ ->
            parse_offset(rest, [{:hour24, hour} | acc], count + 2)
        end

      :else ->
        {:error, "Expected hour between 0-24, but got `#{hour}` instead.", count}
    end
  end

  def parse_extended(<<h::utf8, _::binary>>, :hour, _acc, count),
    do: {:error, "Expected 2 digit hour, but got `#{<<h::utf8>>}` instead.", count}

  def parse_extended(_, :hour, _acc, count),
    do: {:error, "Expected 2 digit hour.", count}

  # Minutes are optional
  def parse_extended(<<m1::utf8, m2::utf8, rest::binary>>, :minute, acc, count)
      when m1 >= ?0 and m1 < ?6 and
             m2 >= ?0 and m2 <= ?9 do
    minute = String.to_integer(<<m1::utf8, m2::utf8>>)

    cond do
      minute >= 0 and minute <= 60 ->
        case rest do
          <<":", rest::binary>> ->
            parse_extended(rest, :second, [{:min, minute} | acc], count + 3)

          _ ->
            parse_offset(rest, [{:min, minute} | acc], count + 2)
        end

      :else ->
        {:error, "Expected minute between 0-60, but got `#{minute}` instead.", count}
    end
  end

  def parse_extended(<<m1::utf8, _::binary>>, :minute, _acc, count),
    do: {:error, "Expected 2 digit minute, but got `#{<<m1::utf8>>}` instead.", count}

  def parse_extended(_, :minute, _acc, count),
    do: {:error, "Expected 2 digit minute.", count}

  # Seconds are optional
  # Has fractional seconds
  def parse_extended(<<s1::utf8, s2::utf8, ".", rest::binary>>, :second, acc, count)
      when s1 >= ?0 and s1 < ?6 and
             s2 >= ?0 and s2 <= ?9 do
    case parse_fractional_seconds(rest, count, <<>>) do
      {:ok, fraction, count, rest} ->
        seconds = String.to_integer(<<s1::utf8, s2::utf8>>)
        precision = byte_size(fraction)
        fraction = if precision > 6, do: binary_part(fraction, 0, 6), else: fraction
        precision = if precision > 6, do: 6, else: precision
        fractional = String.to_integer(fraction)
        fractional = fractional * div(1_000_000, trunc(:math.pow(10, precision)))

        cond do
          seconds >= 0 and seconds <= 60 ->
            parse_offset(
              rest,
              [{:sec_fractional, {fractional, precision}}, {:sec, seconds} | acc],
              count + 2
            )

          :else ->
            {:error, "Expected second between 0-60, but got `#{seconds}` instead.", count}
        end

      {:error, _reason, _count} = err ->
        err
    end
  end

  # No fractional seconds
  def parse_extended(<<s1::utf8, s2::utf8, rest::binary>>, :second, acc, count)
      when s1 >= ?0 and s1 < ?6 and
             s2 >= ?0 and s2 <= ?9 do
    second = String.to_integer(<<s1::utf8, s2::utf8>>)

    cond do
      second >= 0 and second <= 60 ->
        parse_offset(rest, [{:sec, second} | acc], count + 2)

      :else ->
        {:error, "Expected second between 0-60, but got `#{second}` instead.", count}
    end
  end

  def parse_extended(<<h::utf8, _::binary>>, :second, _acc, count),
    do: {:error, "Expected valid value for seconds, but got `#{<<h::utf8>>}` instead.", count}

  def parse_extended(_, :second, _acc, count),
    do: {:error, "Expected valid value for seconds.", count}

  def parse_fractional_seconds(<<digit::utf8, rest::binary>>, count, acc)
      when digit >= ?0 and digit <= ?9 do
    parse_fractional_seconds(rest, count + 1, <<acc::binary, digit::utf8>>)
  end

  def parse_fractional_seconds(_rest, count, "") do
    {:error, "Expected at least one digit after the decimal sign, but found none", count}
  end

  def parse_fractional_seconds(rest, count, acc) do
    {:ok, acc, count, rest}
  end

  def parse_offset(<<"Z", rest::binary>>, acc, count),
    do: {:ok, [{:zname, "Etc/UTC"} | acc], count + 1, rest}

  def parse_offset(<<dir::utf8, rest::binary>>, acc, count) when dir in [?+, ?-] do
    parse_offset(dir, rest, acc, count + 1)
  end

  def parse_offset("", acc, count), do: {:ok, acc, count, ""}

  def parse_offset(str, _acc, count),
    do: {:error, "Expected either Z or a valid timezone offset, but got `#{str}`", count}

  # +/-HH:MM:SS (seconds are currently unhandled in offsets)
  def parse_offset(
        dir,
        <<h1::utf8, h2::utf8, ":", m1::utf8, m2::utf8, ":", s1::utf8, s2::utf8, rest::binary>>,
        acc,
        count
      )
      when h1 >= ?0 and h1 < ?2 and
             h2 >= ?0 and h2 <= ?9 and
             m1 >= ?0 and m1 < ?6 and
             m2 >= ?0 and m2 <= ?9 and
             s1 >= ?0 and s1 < ?6 and
             s2 >= ?0 and s2 <= ?9 do
    {:ok, [{:zname, <<dir::utf8, h1::utf8, h2::utf8, ":", m1::utf8, m2::utf8>>} | acc], count + 7,
     rest}
  end

  # +/-HH:MM
  def parse_offset(dir, <<h1::utf8, h2::utf8, ":", m1::utf8, m2::utf8, rest::binary>>, acc, count)
      when h1 >= ?0 and h1 < ?2 and
             h2 >= ?0 and h2 <= ?9 and
             m1 >= ?0 and m1 < ?6 and
             m2 >= ?0 and m2 <= ?9 do
    {:ok, [{:zname, <<dir::utf8, h1::utf8, h2::utf8, ":", m1::utf8, m2::utf8>>} | acc], count + 5,
     rest}
  end

  # +/-HHMM
  def parse_offset(dir, <<h1::utf8, h2::utf8, m1::utf8, m2::utf8, rest::binary>>, acc, count)
      when h1 >= ?0 and h1 < ?2 and
             h2 >= ?0 and h2 <= ?9 and
             m1 >= ?0 and m1 < ?6 and
             m2 >= ?0 and m2 <= ?9 do
    {:ok, [{:zname, <<dir::utf8, h1::utf8, h2::utf8, ":", m1::utf8, m2::utf8>>} | acc], count + 5,
     rest}
  end

  # +/-HH
  def parse_offset(dir, <<h1::utf8, h2::utf8, rest::binary>>, acc, count)
      when h1 >= ?0 and h1 < ?2 and
             h2 >= ?0 and h2 <= ?9 do
    {:ok, [{:zname, <<dir::utf8, h1::utf8, h2::utf8, ":00">>} | acc], count + 2, rest}
  end

  def parse_offset(_, <<h::utf8, _rest::binary>>, _acc, count),
    do: {:error, "Expected valid offset, but got `#{<<h::utf8>>}` instead.", count}
end