diff --git a/lib/elixir/lib/calendar/datetime.ex b/lib/elixir/lib/calendar/datetime.ex index 6f19ab96492..68031584ce6 100644 --- a/lib/elixir/lib/calendar/datetime.ex +++ b/lib/elixir/lib/calendar/datetime.ex @@ -1030,9 +1030,12 @@ defmodule DateTime do By default, `DateTime.to_iso8601/2` returns datetimes formatted in the "extended" format, for human readability. It also supports the "basic" format through passing the `:basic` option. - Only supports converting datetimes which are in the ISO calendar, - attempting to convert datetimes from other calendars will raise. You can also optionally specify an offset for the formatted string. + If none is given, the one in the given `datetime` is used. + + Only supports converting datetimes which are in the ISO calendar. + If another calendar is given, it is automatically converted to ISO. + It raises if not possible. WARNING: the ISO 8601 datetime format does not contain the time zone nor its abbreviation, which means information is lost when converting to such @@ -1346,6 +1349,11 @@ defmodule DateTime do @doc """ Converts the given `datetime` to a string according to its calendar. + Unfortunately, there is no standard that specifies rendering of a + datetime with its complete time zone information, so Elixir uses a + custom (but relatively common) representation which appends the time + zone abbreviation and full name to the datetime. + ### Examples iex> dt = %DateTime{year: 2000, month: 2, day: 29, zone_abbr: "CET", diff --git a/lib/elixir/lib/calendar/duration.ex b/lib/elixir/lib/calendar/duration.ex index 1690332f148..965b752c2ab 100644 --- a/lib/elixir/lib/calendar/duration.ex +++ b/lib/elixir/lib/calendar/duration.ex @@ -355,6 +355,114 @@ defmodule Duration do end end + @doc """ + Converts the given `duration` to a human readable representation. + + ## Options + + * `:units` - the units to be used alongside each duration component. + The default units follow the ISO 80000-3 standard: + + [ + year: "a", + month: "mo", + week: "wk", + day: "d", + hour: "h", + minute: "min", + second: "s" + ] + + * `:separator` - a string used to separate the distinct components. Defaults to `" "`. + + ## Examples + + iex> Duration.to_string(Duration.new!(second: 30)) + "30s" + iex> Duration.to_string(Duration.new!(day: 40, hour: 12, minute: 42, second: 12)) + "40d 12h 42min 12s" + + By default, this function uses ISO 80000-3 units, which uses "a" for years. + But you can customize all units via the units option: + + iex> Duration.to_string(Duration.new!(year: 3)) + "3a" + iex> Duration.to_string(Duration.new!(year: 3), units: [year: "y"]) + "3y" + + You may also choose the separator: + + iex> Duration.to_string(Duration.new!(day: 40, hour: 12, minute: 42, second: 12), separator: ", ") + "40d, 12h, 42min, 12s" + + A duration without components is rendered as "0s": + + iex> Duration.to_string(Duration.new!([])) + "0s" + + Microseconds are rendered as part of seconds with the appropriate precision: + + iex> Duration.to_string(Duration.new!(second: 1, microsecond: {2_200, 3})) + "1.002s" + iex> Duration.to_string(Duration.new!(second: 1, microsecond: {-1_200_000, 4})) + "-0.2000s" + + """ + @doc since: "1.18.0" + def to_string(%Duration{} = duration, opts \\ []) do + units = Keyword.get(opts, :units, []) + separator = Keyword.get(opts, :separator, " ") + + case to_string_year(duration, [], units) do + [] -> + "0" <> Keyword.get(units, :second, "s") + + [part] -> + IO.iodata_to_binary(part) + + parts -> + parts |> Enum.reduce(&[&1, separator | &2]) |> IO.iodata_to_binary() + end + end + + defp to_string_part(0, _units, _key, _default, acc), + do: acc + + defp to_string_part(x, units, key, default, acc), + do: [[Integer.to_string(x) | Keyword.get(units, key, default)] | acc] + + defp to_string_year(%{year: year} = duration, acc, units) do + to_string_month(duration, to_string_part(year, units, :year, "a", acc), units) + end + + defp to_string_month(%{month: month} = duration, acc, units) do + to_string_week(duration, to_string_part(month, units, :month, "mo", acc), units) + end + + defp to_string_week(%{week: week} = duration, acc, units) do + to_string_day(duration, to_string_part(week, units, :week, "wk", acc), units) + end + + defp to_string_day(%{day: day} = duration, acc, units) do + to_string_hour(duration, to_string_part(day, units, :day, "d", acc), units) + end + + defp to_string_hour(%{hour: hour} = duration, acc, units) do + to_string_minute(duration, to_string_part(hour, units, :hour, "h", acc), units) + end + + defp to_string_minute(%{minute: minute} = duration, acc, units) do + to_string_second(duration, to_string_part(minute, units, :minute, "min", acc), units) + end + + defp to_string_second(%{second: 0, microsecond: {0, _}}, acc, _units) do + acc + end + + defp to_string_second(%{second: s, microsecond: {ms, p}}, acc, units) do + [[second_component(s, ms, p) | Keyword.get(units, :second, "s")] | acc] + end + @doc """ Converts the given `duration` to an [ISO 8601-2:2019](https://en.wikipedia.org/wiki/ISO_8601) formatted string. @@ -407,15 +515,15 @@ defmodule Duration do [] end - defp second_component(%{second: 0, microsecond: {_, 0}}) do - ~c"0S" + defp second_component(%{second: second, microsecond: {ms, p}}) do + [second_component(second, ms, p), ?S] end - defp second_component(%{second: second, microsecond: {_, 0}}) do - [Integer.to_string(second), ?S] + defp second_component(second, _ms, 0) do + Integer.to_string(second) end - defp second_component(%{second: second, microsecond: {ms, p}}) do + defp second_component(second, ms, p) do total_ms = second * @microseconds_per_second + ms second = total_ms |> div(@microseconds_per_second) |> abs() ms = total_ms |> rem(@microseconds_per_second) |> abs() @@ -425,8 +533,7 @@ defmodule Duration do sign, Integer.to_string(second), ?., - ms |> Integer.to_string() |> String.pad_leading(6, "0") |> binary_part(0, p), - ?S + ms |> Integer.to_string() |> String.pad_leading(6, "0") |> binary_part(0, p) ] end diff --git a/lib/elixir/lib/calendar/naive_datetime.ex b/lib/elixir/lib/calendar/naive_datetime.ex index bdfbd55d6ac..4fb676c107d 100644 --- a/lib/elixir/lib/calendar/naive_datetime.ex +++ b/lib/elixir/lib/calendar/naive_datetime.ex @@ -759,6 +759,9 @@ defmodule NaiveDateTime do @doc """ Converts the given naive datetime to a string according to its calendar. + For redability, this function follows the RFC3339 suggestion of removing + the "T" separator between the date and time components. + ### Examples iex> NaiveDateTime.to_string(~N[2000-02-28 23:00:13]) diff --git a/lib/elixir/test/elixir/calendar/duration_test.exs b/lib/elixir/test/elixir/calendar/duration_test.exs index 1240d176aab..c8f4fdf36e4 100644 --- a/lib/elixir/test/elixir/calendar/duration_test.exs +++ b/lib/elixir/test/elixir/calendar/duration_test.exs @@ -358,4 +358,75 @@ defmodule DurationTest do assert %Duration{microsecond: {-800_000, 0}} |> Duration.to_iso8601() == "PT0S" assert %Duration{microsecond: {-1_200_000, 2}} |> Duration.to_iso8601() == "PT-1.20S" end + + test "to_string/1" do + assert Duration.to_string(%Duration{year: 1, month: 2, day: 3, hour: 4, minute: 5, second: 6}) == + "1a 2mo 3d 4h 5min 6s" + + assert Duration.to_string(%Duration{week: 3, hour: 5, minute: 3}) == + "3wk 5h 3min" + + assert Duration.to_string(%Duration{hour: 5, minute: 3}) == + "5h 3min" + + assert Duration.to_string(%Duration{year: 1, month: 2, day: 3}) == + "1a 2mo 3d" + + assert Duration.to_string(%Duration{hour: 4, minute: 5, second: 6}) == + "4h 5min 6s" + + assert Duration.to_string(%Duration{year: 1, month: 2}) == + "1a 2mo" + + assert Duration.to_string(%Duration{day: 3}) == + "3d" + + assert Duration.to_string(%Duration{hour: 4, minute: 5}) == + "4h 5min" + + assert Duration.to_string(%Duration{second: 6}) == + "6s" + + assert Duration.to_string(%Duration{second: 1, microsecond: {600_000, 1}}) == + "1.6s" + + assert Duration.to_string(%Duration{second: -1, microsecond: {-600_000, 1}}) == + "-1.6s" + + assert Duration.to_string(%Duration{second: -1, microsecond: {-234_567, 6}}) == + "-1.234567s" + + assert Duration.to_string(%Duration{second: 1, microsecond: {123_456, 6}}) == + "1.123456s" + + assert Duration.to_string(%Duration{year: 3, week: 4, day: -3, second: -6}) == + "3a 4wk -3d -6s" + + assert Duration.to_string(%Duration{second: -4, microsecond: {-230_000, 2}}) == + "-4.23s" + + assert Duration.to_string(%Duration{second: -4, microsecond: {230_000, 2}}) == + "-3.77s" + + assert Duration.to_string(%Duration{second: 2, microsecond: {-1_200_000, 4}}) == + "0.8000s" + + assert Duration.to_string(%Duration{second: 1, microsecond: {-1_200_000, 3}}) == + "-0.200s" + + assert Duration.to_string(%Duration{microsecond: {-800_000, 2}}) == + "-0.80s" + + assert Duration.to_string(%Duration{microsecond: {-800_000, 0}}) == + "0s" + + assert Duration.to_string(%Duration{microsecond: {-1_200_000, 2}}) == + "-1.20s" + + assert Duration.to_string(%Duration{year: 1, month: 2, day: 3, hour: 4, minute: 5, second: 6}, + units: [year: "year", month: "month", day: "day"], + separator: "-" + ) == + "1year-2month-3day-4h-5min-6s" + end end