Current section

Files

Jump to
localize lib localize date.ex
Raw

lib/localize/date.ex

defmodule Localize.Date do
@moduledoc """
Provides localized formatting of `Date` structs and date-like maps.
Supports both full dates (`%{year: _, month: _, day: _}`) and partial
dates (any map with one or more of `:year`, `:month`, `:day`). For
partial dates, the format is derived from the available fields.
Formats are defined in CLDR and described in
[TR35](http://unicode.org/reports/tr35/tr35-dates.html).
"""
import Kernel, except: [to_string: 1]
@standard_formats [:short, :medium, :long, :full]
@default_format :medium
# Ordered by CLDR canonical skeleton order: year, month, day
@date_fields_ordered [{:year, "y"}, {:month, "M"}, {:day, "d"}]
# @date_field_names Enum.map(@date_fields_ordered, &elem(&1, 0))
defguardp is_full_date(date)
when is_map_key(date, :year) and is_map_key(date, :month) and is_map_key(date, :day)
defguardp has_date_field(date)
when is_map_key(date, :year) or is_map_key(date, :month) or is_map_key(date, :day)
@doc """
Formats a date according to a CLDR format pattern.
### Arguments
* `date` is a `t:Date.t/0` or any map with one or more of
`:year`, `:month`, `:day` keys.
* `options` is a keyword list of options.
### Options
* `:format` is a standard format name (`:short`, `:medium`,
`:long`, `:full`), a format skeleton atom, or a format
pattern string. The default is `:medium` for full dates.
For partial dates the format is derived from the available
fields.
* `:locale` is a locale identifier. The default is `:en`.
* `:prefer` selects between CLDR `alt` variants. Accepts an
atom or a list of atoms in priority order. Recognised values:
`:standard` / `:variant` (locales like en-CA publish both an
ISO pattern `"y-MM-dd"` and a locale-variant `"d/M/yy"`),
and `:unicode` / `:ascii` (NBSP and curly quotes vs ASCII).
Examples: `prefer: :variant`, `prefer: [:variant, :ascii]`.
The default is `[:standard, :unicode]`.
### Returns
* `{:ok, formatted_string}` on success.
* `{:error, exception}` if the date cannot be formatted.
### Examples
iex> Localize.Date.to_string(~D[2017-07-10], locale: :en)
{:ok, "Jul 10, 2017"}
iex> Localize.Date.to_string(~D[2017-07-10], format: :full, locale: :en)
{:ok, "Monday, July 10, 2017"}
iex> Localize.Date.to_string(~D[2017-07-10], format: :short, locale: :en)
{:ok, "7/10/17"}
iex> Localize.Date.to_string(~D[2017-07-10], format: :short, locale: :fr)
{:ok, "10/07/2017"}
iex> Localize.Date.to_string(%{year: 2024, month: 6}, format: :yMMM, locale: :fr)
{:ok, "juin 2024"}
"""
@spec to_string(map(), Keyword.t()) :: {:ok, String.t()} | {:error, Exception.t()}
def to_string(date, options \\ [])
# Full date
def to_string(%{year: _, month: _, day: _} = date, options) do
locale = Keyword.get(options, :locale, Localize.get_locale())
format = Keyword.get(options, :format, @default_format)
with {:ok, locale_id} <- resolve_locale_id(locale),
{:ok, pattern} <- find_format(date, format, locale_id, options),
overrides = number_system_overrides_for(date, format, locale_id),
formatter_options = options |> Map.new() |> Map.put(:number_system_overrides, overrides),
{:ok, formatted} <-
Localize.DateTime.Formatter.format(date, pattern, locale_id, formatter_options) do
{:ok, formatted}
end
end
# Partial date
def to_string(date, options) when has_date_field(date) do
locale = Keyword.get(options, :locale, Localize.get_locale())
format = Keyword.get(options, :format)
with {:ok, locale_id} <- resolve_locale_id(locale) do
resolved_format =
cond do
is_binary(format) ->
format
is_atom(format) and format != nil and format not in @standard_formats ->
format
format in @standard_formats ->
# Standard formats not accepted for partial dates
nil
true ->
# Derive format skeleton from available fields
derive_format_id(date)
end
if resolved_format == nil do
{:error,
Localize.DateTimeUnresolvedFormatError.exception(
format: format,
locale: locale_id
)}
else
with {:ok, pattern} <- find_format(date, resolved_format, locale_id, options),
overrides = number_system_overrides_for(date, resolved_format, locale_id),
formatter_options =
options |> Map.new() |> Map.put(:number_system_overrides, overrides),
{:ok, formatted} <-
Localize.DateTime.Formatter.format(date, pattern, locale_id, formatter_options) do
{:ok, formatted}
end
end
end
end
def to_string(_date, _options) do
{:error, Localize.DateTimeInvalidInputError.exception(type: :date)}
end
@doc """
Same as `to_string/2` but raises on error.
### Examples
iex> Localize.Date.to_string!(~D[2017-07-10], locale: :en)
"Jul 10, 2017"
iex> Localize.Date.to_string!(%{year: 2024, month: 6}, format: :yMMM, locale: :fr)
"juin 2024"
"""
@spec to_string!(map(), Keyword.t()) :: String.t()
def to_string!(date, options \\ []) do
case to_string(date, options) do
{:ok, string} -> string
{:error, exception} -> raise exception
end
end
# ── Format resolution ──────────────────────────────────────
defp find_format(_date, format, _locale_id, _options) when is_binary(format) do
{:ok, format}
end
defp find_format(date, format, locale_id, options) when is_atom(format) do
cldr_calendar = cldr_calendar_for(date)
# For standard formats on full dates, resolve via the standard format map
if format in @standard_formats and is_full_date(date) do
Localize.DateTime.Format.resolve_format(:date, format, locale_id, cldr_calendar, options)
else
# Skeleton format — look up in available_formats
resolve_skeleton(
skeleton: format,
locale_id: locale_id,
calendar: cldr_calendar,
options: options
)
end
end
defp find_format(_date, format, _locale_id, _options) do
{:error, Localize.DateTimeFormatError.exception(format: format, reason: :invalid_format)}
end
# Look up the per-field number-system overrides for the
# date's calendar. Returns `%{}` for ordinary patterns,
# `%{"all" => :hebr}` for Hebrew dates, `%{"y" => :jpanyear}`
# for Japanese imperial, etc. The formatter consults this
# map when emitting numeric fields and applies the right
# digit set or RBNF rule.
defp number_system_overrides_for(date, format, locale_id) when is_atom(format) do
Localize.DateTime.Format.number_system_overrides(
:date,
format,
locale_id,
cldr_calendar_for(date)
)
end
defp number_system_overrides_for(_date, _format, _locale_id), do: %{}
# Resolve the CLDR calendar key from a date's `:calendar`
# module. `Calendar.ISO` is Gregorian. Other calendar modules
# (e.g. Calendrical's `Calendrical.Japanese`,
# `Calendrical.Buddhist`) expose `cldr_calendar_type/0` —
# probe it via `function_exported?/3` so Localize itself
# doesn't need a hard dep on the calendar library.
defp cldr_calendar_for(%{calendar: Calendar.ISO}), do: :gregorian
defp cldr_calendar_for(%{calendar: module}) when is_atom(module) do
Code.ensure_loaded?(module)
if function_exported?(module, :cldr_calendar_type, 0) do
module.cldr_calendar_type()
else
:gregorian
end
end
defp cldr_calendar_for(_), do: :gregorian
defp resolve_skeleton(opts) when is_list(opts) do
skeleton = Keyword.fetch!(opts, :skeleton)
locale_id = Keyword.fetch!(opts, :locale_id)
calendar = Keyword.get(opts, :calendar, :gregorian)
options = Keyword.get(opts, :options, [])
# Internal: skeletons already attempted in this resolution
# chain. Prevents an infinite loop where `best_match`
# returns the same skeleton it was given because the
# candidate set already contains it (and we then re-look-up
# in the calendar's available_formats where it's missing).
seen = Keyword.get(opts, :seen, MapSet.new())
with {:ok, available} <-
Localize.DateTime.Format.available_formats(locale_id, calendar) do
case Map.get(available, skeleton) do
nil ->
resolve_skeleton_via_best_match(skeleton, locale_id, calendar, options, seen)
%{} = variant_map ->
case Localize.DateTime.Format.resolve_variant(variant_map, options) do
nil ->
{:error,
Localize.DateTimeUnresolvedFormatError.exception(
format: skeleton,
locale: locale_id
)}
pattern ->
{:ok, pattern}
end
pattern when is_binary(pattern) ->
{:ok, pattern}
end
end
end
# Two-step fallback when the exact skeleton isn't in the
# calendar's `available_formats`:
#
# 1. Ask `best_match` for the nearest skeleton in the
# **same calendar's** format set. Pass the calendar
# explicitly — the default of `:gregorian` is what
# caused the infinite loop for non-Gregorian dates
# (best_match found the skeleton in gregorian, returned
# it, and resolve_skeleton re-looked-up in the original
# non-Gregorian calendar where it's still missing).
#
# 2. If best_match for the calendar finds nothing, fall back
# to gregorian's pattern set. Skeletons are
# calendar-agnostic by design, so a user requesting
# `:yMMMM` against a Japanese date should still get
# "MMMM y" rendering rather than an error.
#
# In both cases the recursion is guarded by a `seen` set
# so a degenerate match cycle (`a → b → a`) terminates.
defp resolve_skeleton_via_best_match(skeleton, locale_id, calendar, options, seen) do
cond do
MapSet.member?(seen, skeleton) ->
{:error,
Localize.DateTimeUnresolvedFormatError.exception(
format: skeleton,
locale: locale_id
)}
true ->
seen = MapSet.put(seen, skeleton)
case Localize.DateTime.Format.Match.best_match(skeleton, locale_id, calendar) do
{:ok, matched_id} when is_atom(matched_id) and matched_id != skeleton ->
resolve_skeleton(
skeleton: matched_id,
locale_id: locale_id,
calendar: calendar,
options: options,
seen: seen
)
{:ok, {_date_id, _time_id}} ->
# Combined date+time skeleton — not applicable for
# date-only formatting.
{:error,
Localize.DateTimeUnresolvedFormatError.exception(
format: skeleton,
locale: locale_id
)}
_ when calendar != :gregorian ->
# Calendar-specific match exhausted — fall back to
# gregorian patterns. The displayed values will
# still be calendar-correct because the formatter's
# `y` / `G` tokens read from `date.calendar`; only
# the pattern selection switches to gregorian.
resolve_skeleton(
skeleton: skeleton,
locale_id: locale_id,
calendar: :gregorian,
options: options,
seen: seen
)
_ ->
{:error,
Localize.DateTimeUnresolvedFormatError.exception(
format: skeleton,
locale: locale_id
)}
end
end
end
@doc false
def derive_format_id(date) do
@date_fields_ordered
|> Enum.filter(fn {field, _symbol} -> Map.has_key?(date, field) end)
|> Enum.map(fn {_field, symbol} -> symbol end)
|> Enum.join()
|> String.to_atom()
end
# ── Locale resolution ──────────────────────────────────────
defp resolve_locale_id(locale), do: Localize.Locale.cldr_locale_id_from(locale)
end