diff --git a/lib/elixir/lib/calendar/duration.ex b/lib/elixir/lib/calendar/duration.ex index ddc8d8d09bd..4b4502839d3 100644 --- a/lib/elixir/lib/calendar/duration.ex +++ b/lib/elixir/lib/calendar/duration.ex @@ -197,4 +197,52 @@ defmodule Duration do microsecond: {-ms, p} } end + + @doc """ + Parses an ISO 8601 formatted duration string to a `Duration` struct. + + A decimal fraction may be specified for seconds only, using either a comma or a full stop. + + ## Examples + + iex> Duration.from_iso8601("P1Y2M3DT4H5M6S") + {:ok, %Duration{year: 1, month: 2, day: 3, hour: 4, minute: 5, second: 6}} + iex> Duration.from_iso8601("PT10H30M") + {:ok, %Duration{hour: 10, minute: 30, second: 0}} + iex> Duration.from_iso8601("P3Y-2MT3H") + {:ok, %Duration{year: 3, month: -2, hour: 3}} + iex> Duration.from_iso8601("P1YT4.650S") + {:ok, %Duration{year: 1, second: 4, microsecond: {650000, 3}}} + + """ + @spec from_iso8601(String.t()) :: {:ok, t} | {:error, atom} + def from_iso8601(string) when is_binary(string) do + case Calendar.ISO.parse_duration(string) do + {:ok, duration} -> + {:ok, new!(duration)} + + error -> + error + end + end + + @doc """ + Same as `from_iso8601/1` but raises an ArgumentError. + + ## Examples + + iex> Duration.from_iso8601!("P1Y2M3DT4H5M6S") + %Duration{year: 1, month: 2, day: 3, hour: 4, minute: 5, second: 6} + + """ + @spec from_iso8601!(String.t()) :: t + def from_iso8601!(string) when is_binary(string) do + case from_iso8601(string) do + {:ok, duration} -> + duration + + {:error, reason} -> + raise ArgumentError, ~s/failed to parse duration "#{string}". reason: #{inspect(reason)}/ + end + end end diff --git a/lib/elixir/lib/calendar/iso.ex b/lib/elixir/lib/calendar/iso.ex index 2d0bd59ceb2..aa9a31a437d 100644 --- a/lib/elixir/lib/calendar/iso.ex +++ b/lib/elixir/lib/calendar/iso.ex @@ -18,7 +18,8 @@ defmodule Calendar.ISO do The standard library supports a minimal set of possible ISO 8601 features. Specifically, the parser only supports calendar dates and does not support - ordinal and week formats. + ordinal and week formats. Additionally, it supports parsing ISO 8601 + formatted durations, including negative time units and fractional seconds. By default Elixir only parses extended-formatted date/times. You can opt-in to parse basic-formatted date/times. @@ -29,7 +30,7 @@ defmodule Calendar.ISO do Elixir does not support reduced accuracy formats (for example, a date without the day component) nor decimal precisions in the lowest component (such as - `10:01:25,5`). No functions exist to parse ISO 8601 durations or time intervals. + `10:01:25,5`). #### Examples @@ -663,6 +664,65 @@ defmodule Calendar.ISO do end end + @doc """ + Parses an ISO 8601 formatted duration string to a list of `Duration` compabitble unit pairs. + + See `Duration.from_iso8601/1`. + """ + @doc since: "1.17.0" + @spec parse_duration(String.t()) :: {:ok, [Duration.unit_pair()]} | {:error, atom()} + def parse_duration("P" <> string) when byte_size(string) > 0 do + parse_duration_date(string, [], year: ?Y, month: ?M, week: ?W, day: ?D) + end + + def parse_duration(_) do + {:error, :invalid_duration} + end + + defp parse_duration_date("", acc, _allowed), do: {:ok, acc} + + defp parse_duration_date("T" <> string, acc, _allowed) when byte_size(string) > 0 do + parse_duration_time(string, acc, hour: ?H, minute: ?M, second: ?S) + end + + defp parse_duration_date(string, acc, allowed) do + with {integer, <>} <- Integer.parse(string), + {key, allowed} <- find_unit(allowed, next) do + parse_duration_date(rest, [{key, integer} | acc], allowed) + else + _ -> {:error, :invalid_date_component} + end + end + + defp parse_duration_time("", acc, _allowed), do: {:ok, acc} + + defp parse_duration_time(string, acc, allowed) do + case Integer.parse(string) do + {second, <> = rest} when delimiter in [?., ?,] -> + case parse_microsecond(rest) do + {{ms, precision}, "S"} -> + ms = if second > 0, do: ms, else: -ms + {:ok, [second: second, microsecond: {ms, precision}] ++ acc} + + _ -> + {:error, :invalid_time_component} + end + + {integer, <>} -> + case find_unit(allowed, next) do + {key, allowed} -> parse_duration_time(rest, [{key, integer} | acc], allowed) + false -> {:error, :invalid_time_component} + end + + _ -> + {:error, :invalid_time_component} + end + end + + defp find_unit([{key, unit} | rest], unit), do: {key, rest} + defp find_unit([_ | rest], unit), do: find_unit(rest, unit) + defp find_unit([], _unit), do: false + @doc """ Returns the `t:Calendar.iso_days/0` format of the specified date. diff --git a/lib/elixir/test/elixir/calendar/duration_test.exs b/lib/elixir/test/elixir/calendar/duration_test.exs index 12a2b268d8b..71635e91644 100644 --- a/lib/elixir/test/elixir/calendar/duration_test.exs +++ b/lib/elixir/test/elixir/calendar/duration_test.exs @@ -217,4 +217,90 @@ defmodule DurationTest do microsecond: {0, 0} } end + + test "from_iso8601/1" do + assert Duration.from_iso8601("P1Y2M3DT4H5M6S") == + {:ok, %Duration{year: 1, month: 2, day: 3, hour: 4, minute: 5, second: 6}} + + assert Duration.from_iso8601("P3WT5H3M") == {:ok, %Duration{week: 3, hour: 5, minute: 3}} + assert Duration.from_iso8601("PT5H3M") == {:ok, %Duration{hour: 5, minute: 3}} + assert Duration.from_iso8601("P1Y2M3D") == {:ok, %Duration{year: 1, month: 2, day: 3}} + assert Duration.from_iso8601("PT4H5M6S") == {:ok, %Duration{hour: 4, minute: 5, second: 6}} + assert Duration.from_iso8601("P1Y2M") == {:ok, %Duration{year: 1, month: 2}} + assert Duration.from_iso8601("P3D") == {:ok, %Duration{day: 3}} + assert Duration.from_iso8601("PT4H5M") == {:ok, %Duration{hour: 4, minute: 5}} + assert Duration.from_iso8601("PT6S") == {:ok, %Duration{second: 6}} + assert Duration.from_iso8601("P2M4Y") == {:error, :invalid_date_component} + assert Duration.from_iso8601("P4Y2W3Y") == {:error, :invalid_date_component} + assert Duration.from_iso8601("P5HT4MT3S") == {:error, :invalid_date_component} + assert Duration.from_iso8601("P5H3HT4M") == {:error, :invalid_date_component} + assert Duration.from_iso8601("PT1D") == {:error, :invalid_time_component} + assert Duration.from_iso8601("PT.6S") == {:error, :invalid_time_component} + assert Duration.from_iso8601("invalid") == {:error, :invalid_duration} + end + + test "from_iso8601!/1" do + assert Duration.from_iso8601!("P1Y2M3DT4H5M6S") == %Duration{ + year: 1, + month: 2, + day: 3, + hour: 4, + minute: 5, + second: 6 + } + + assert Duration.from_iso8601!("P3WT5H3M") == %Duration{week: 3, hour: 5, minute: 3} + assert Duration.from_iso8601!("PT5H3M") == %Duration{hour: 5, minute: 3} + assert Duration.from_iso8601!("P1Y2M3D") == %Duration{year: 1, month: 2, day: 3} + assert Duration.from_iso8601!("PT4H5M6S") == %Duration{hour: 4, minute: 5, second: 6} + assert Duration.from_iso8601!("P1Y2M") == %Duration{year: 1, month: 2} + assert Duration.from_iso8601!("P3D") == %Duration{day: 3} + assert Duration.from_iso8601!("PT4H5M") == %Duration{hour: 4, minute: 5} + assert Duration.from_iso8601!("PT6S") == %Duration{second: 6} + assert Duration.from_iso8601!("PT1,6S") == %Duration{second: 1, microsecond: {600_000, 1}} + assert Duration.from_iso8601!("PT-1.6S") == %Duration{second: -1, microsecond: {-600_000, 1}} + + assert Duration.from_iso8601!("PT-1.234567S") == %Duration{ + second: -1, + microsecond: {-234_567, 6} + } + + assert Duration.from_iso8601!("PT1.12345678S") == %Duration{ + second: 1, + microsecond: {123_456, 6} + } + + assert Duration.from_iso8601!("P3Y4W-3DT-6S") == %Duration{ + year: 3, + week: 4, + day: -3, + second: -6 + } + + assert Duration.from_iso8601!("PT-4.23S") == %Duration{second: -4, microsecond: {-230_000, 2}} + + assert_raise ArgumentError, + ~s/failed to parse duration "P5H3HT4M". reason: :invalid_date_component/, + fn -> + Duration.from_iso8601!("P5H3HT4M") + end + + assert_raise ArgumentError, + ~s/failed to parse duration "P4Y2W3Y". reason: :invalid_date_component/, + fn -> + Duration.from_iso8601!("P4Y2W3Y") + end + + assert_raise ArgumentError, + ~s/failed to parse duration "invalid". reason: :invalid_duration/, + fn -> + Duration.from_iso8601!("invalid") + end + + assert_raise ArgumentError, + ~s/failed to parse duration "P4.5YT6S". reason: :invalid_date_component/, + fn -> + Duration.from_iso8601!("P4.5YT6S") + end + end end