Current section

Files

Jump to
calendrical lib calendrical datetime parser.ex
Raw

lib/calendrical/datetime/parser.ex

defmodule Calendrical.DateTime.Parser do
@moduledoc false
# Locale-aware parser for user-typed date-time strings.
#
# Public entry point: `Calendrical.DateTime.parse/2`.
#
# Strategy:
#
# 1. Try bare ISO-8601 (`YYYY-MM-DDTHH:MM:SS[.frac][Z|±HH:MM]`).
# This is the wire format and always works.
#
# 2. Otherwise, consult the locale's CLDR date-time glue
# pattern (`{1}, {0}` in `en`, `{1} {0}` in `ja`, etc.),
# split the input on the literal glue separator, and
# delegate to `Calendrical.Date.parse/2` and
# `Calendrical.Time.parse/2` for the two halves.
#
# Implements the parts of [TR35 §Parsing Dates
# Times](https://unicode.org/reports/tr35/tr35-dates.html#Parsing_Dates_Times)
# that apply to date+time input. Time-zone resolution is
# documented as a follow-up — when the input carries a `Z` or
# `±HH:MM` suffix that ISO-8601 already accepts, we honour it;
# named timezone suffixes (`EST`, `Asia/Tokyo`, etc.) need
# per-zone IANA-database integration which lives outside this
# parser.
#
# ### Returns
#
# * `{:ok, NaiveDateTime.t()}` when no zone info was present.
# * `{:ok, DateTime.t()}` when the input carried an offset or
# `Z` and `Calendar.DateTime.from_iso8601/1` could resolve
# it.
# * `{:error, Calendrical.DateTimeParseError.t()}` on failure.
#
alias Calendrical.DateTimeParseError
alias Localize.DateTime.Format
@standard_formats [:short, :medium, :long, :full]
@doc """
Parses `input` as a locale-formatted datetime string.
See `Calendrical.DateTime.parse/2` for the public contract.
"""
@spec parse(String.t(), Keyword.t()) ::
{:ok, NaiveDateTime.t() | DateTime.t() | map()} | {:error, Exception.t()}
def parse(input, options \\ []) when is_binary(input) do
locale = Keyword.get(options, :locale) || Localize.get_locale()
cldr_calendar =
Calendrical.Date.Parser.normalise_calendar(Keyword.get(options, :calendar, :gregorian))
as = Keyword.get(options, :as, :struct)
# Strip weekday prefix here too — the glue-splitting step
# doesn't know which half is the date, so a leading
# `"Sun, "` would otherwise survive into the date half
# regardless of where the date/time boundary lands. Ordinal
# stripping doesn't need to be applied here because each
# half is forwarded through `Calendrical.Date.parse/2` /
# `Calendrical.Time.parse/2`, and `Date.parse/2` already
# runs the ordinal-fallback retry per endpoint.
input =
input
|> Calendrical.Date.Parser.normalise_input()
|> Calendrical.Date.Parser.preprocess_safe(locale, cldr_calendar)
case try_iso(input) do
{:ok, value} ->
{:ok, finalise_datetime(value, as)}
:error ->
try_locale_glue(input, locale, options, as)
end
end
# ISO 8601 always yields full year+month+day+hour+minute+second.
# The map merges the date and time fields and surfaces the
# `:time_zone` string when the input carried a `Z` or offset.
defp finalise_datetime(%NaiveDateTime{} = ndt, :struct), do: ndt
defp finalise_datetime(%DateTime{} = dt, :struct), do: dt
defp finalise_datetime(%NaiveDateTime{} = ndt, :map) do
naive_datetime_to_map(ndt)
end
defp finalise_datetime(%DateTime{} = dt, :map) do
dt
|> DateTime.to_naive()
|> naive_datetime_to_map()
|> Map.put(:time_zone, dt.time_zone)
|> Map.put(:utc_offset, dt.utc_offset)
|> Map.put(:std_offset, dt.std_offset)
|> Map.put(:zone_abbr, dt.zone_abbr)
end
defp naive_datetime_to_map(%NaiveDateTime{
year: y,
month: m,
day: d,
hour: h,
minute: mi,
second: s,
microsecond: us,
calendar: cal
}) do
base = %{
year: y,
month: m,
day: d,
hour: h,
minute: mi,
second: s,
calendar: cal
}
case us do
{_, 0} -> base
other -> Map.put(base, :microsecond, other)
end
end
defp try_locale_glue(input, locale, options, as) do
case glue_separators(locale) do
[] ->
{:error, no_match_error(input, locale)}
separators ->
# The glue separator (e.g. `, ` in en) often appears
# inside the date half too — `MMM d, y` puts a comma
# between day and year. Try every split point for every
# candidate separator and accept the first that yields
# parseable date + time halves.
candidates =
for sep <- separators,
{left, right} <- enumerate_splits(input, sep) do
{sep, left, right}
end
finder =
case as do
:map -> &try_split_as_map(&1, options)
:struct -> &try_split_as_struct(&1, options)
end
Enum.find_value(
candidates,
{:error, no_match_error(input, locale)},
finder
)
end
end
defp try_split_as_struct({_sep, left, right}, options) do
with {:ok, date} <- Calendrical.Date.parse(left, options),
{:ok, time, zone} <-
Calendrical.Time.Parser.parse_with_zone(right, options),
{:ok, ndt} <- NaiveDateTime.new(date, time) do
# If the time half carried a zone, resolve it
# into a `DateTime`. Resolution failure (e.g.
# IANA name with no TZ database loaded) falls
# back to the `NaiveDateTime` — preserving the
# parse rather than failing the whole input.
case zone do
nil ->
{:ok, ndt}
_ ->
case Calendrical.TimeZone.resolve(zone, ndt, options) do
{:ok, %DateTime{} = dt} -> {:ok, dt}
_ -> {:ok, ndt}
end
end
else
_ -> nil
end
end
defp try_split_as_map({_sep, left, right}, options) do
date_opts = Keyword.put(options, :as, :map)
time_opts = Keyword.put(options, :as, :map)
with {:ok, %{} = date_map} <- Calendrical.Date.parse(left, date_opts),
{:ok, %{} = time_map, _zone} <-
Calendrical.Time.Parser.parse_with_zone(right, time_opts) do
# Date map carries `:calendar`; time map carries the time
# fields plus `:time_zone` if any. Merge — date's
# `:calendar` wins (the time map has no calendar key).
{:ok, Map.merge(time_map, date_map)}
else
_ -> nil
end
end
# All non-empty splits of `input` on `sep`, ordered to try
# the latest-position split first (the "real" glue is usually
# the last occurrence — date halves like `MMM d, y` consume
# the earlier comma).
defp enumerate_splits(input, sep) do
case String.split(input, sep) do
[_] ->
[]
parts ->
n = length(parts) - 1
# For n split points, try them right-to-left.
for i <- n..1//-1 do
left = parts |> Enum.take(i) |> Enum.join(sep)
right = parts |> Enum.drop(i) |> Enum.join(sep)
{String.trim(left), String.trim(right)}
end
|> Enum.reject(fn {l, r} -> l == "" or r == "" end)
end
end
# ── ISO 8601 ─────────────────────────────────────────────────
defp try_iso(input) do
# Shape `YYYY-MM-DD<sep>HH:MM:SS[…]` where <sep> is `T`
# (RFC 3339 / ISO 8601) or a space (Elixir stdlib also
# accepts; widely used by Postgres, SQLite, logs, etc.).
if iso_datetime_shape?(input) do
case DateTime.from_iso8601(input) do
{:ok, dt, _offset} -> {:ok, dt}
_ -> try_iso_naive(input)
end
else
:error
end
end
# `Date.from_iso8601/1` and `NaiveDateTime.from_iso8601/1`
# only accept the *extended* format with hyphens and colons.
# Cheap shape check to avoid handing arbitrary input to the
# stdlib parser.
defp iso_datetime_shape?(input) do
Regex.match?(~r/\A\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}/u, input)
end
defp try_iso_naive(input) do
case NaiveDateTime.from_iso8601(input) do
{:ok, ndt} -> {:ok, ndt}
_ -> :error
end
end
# ── Locale glue split ───────────────────────────────────────
# Universal fallback glues — accepted in every locale on top
# of the CLDR-defined ones. Real-world inputs frequently use
# these even where CLDR specifies a more elaborate separator:
#
# * `" "` — bare space. Default for most locales (e.g. `ja`
# uses bare space) but `en` CLDR ships `, ` as the
# standard; accept bare space anyway so inputs like
# `"01/01/2018 14:44"` parse under `en`.
#
# * `" - "` — common in admin UIs and exported reports.
#
# * `" @ "` — common in human-written notes (`"23-05-2019 @ 10:01"`).
#
# Listed in descending byte-length so longer separators are
# tried first; that prevents the bare-space fallback from
# eating a hyphen-glued input before the `" - "` candidate
# gets to try.
@fallback_glue_separators [" - ", " @ ", " "]
# The CLDR date-time glue pattern is always `{1}<sep>{0}` —
# `{1}` is the date portion and `{0}` is the time portion.
# Extract `<sep>` from each standard glue pattern; the
# caller backtracks through every split point in the input
# to find one where both halves parse.
defp glue_separators(locale) do
cldr =
case Format.date_time_formats(locale, :gregorian) do
{:ok, glue_map} ->
@standard_formats
|> Enum.map(&Map.get(glue_map, &1))
|> Enum.flat_map(&extract_separator/1)
_ ->
[]
end
# CLDR 42+ carries a second set of glue patterns (atTime) whose
# separators differ from the standard set — fr's full/long glue
# is `{1} 'à' {0}` there, for example.
at_time =
case Format.date_time_at_formats(locale, :gregorian) do
{:ok, %{standard: at_map}} when is_map(at_map) ->
@standard_formats
|> Enum.map(&Map.get(at_map, &1))
|> Enum.flat_map(&extract_separator/1)
_ ->
[]
end
(cldr ++ at_time ++ @fallback_glue_separators)
|> Enum.uniq()
|> Enum.sort_by(&(-byte_size(&1)))
end
# Pattern shape is `{1}<sep>{0}`; pull <sep> via regex.
# Returns the separator(s) as a list (most patterns yield
# one; we wrap in a list for `flat_map` ergonomics).
defp extract_separator(pattern) when is_binary(pattern) do
case Regex.run(~r/\{1\}(.*?)\{0\}/u, pattern) do
[_, sep] when sep != "" -> [unquote_cldr_literal(sep)]
_ -> []
end
end
defp extract_separator(_), do: []
# CLDR quotes literal pattern text in single quotes (fr's glue is
# `{1} 'à' {0}`) and escapes a real apostrophe as `''`. Without
# unquoting, the separator ` 'à' ` never matches user input.
defp unquote_cldr_literal(text) do
text
|> String.replace("''", <<0>>)
|> String.replace(~r/'([^']*)'/u, "\\1")
|> String.replace(<<0>>, "'")
end
# ── Errors ───────────────────────────────────────────────────
defp no_match_error(input, locale) do
DateTimeParseError.exception(input: input, locale: locale)
end
end