Skip to content

Commit 4547cf2

Browse files
committed
Add localized parse to Date, Time and DateTime, and gate guide examples in CI
1 parent 06f0a7c commit 4547cf2

10 files changed

Lines changed: 523 additions & 0 deletions

File tree

lib/localize/date.ex

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -472,4 +472,59 @@ defmodule Localize.Date do
472472
# ── Locale resolution ──────────────────────────────────────
473473

474474
defp resolve_locale_id(locale), do: Localize.Locale.cldr_locale_id_from(locale)
475+
476+
@doc """
477+
Parses a localized date string.
478+
479+
Parsing lives in the companion [calendrical](https://hex.pm/packages/calendrical)
480+
package, which carries the calendar systems Localize formats for.
481+
`calendrical` depends on Localize, so Localize resolves it at runtime rather
482+
than depending on it in return — add `{:calendrical, "~> 1.0"}` to your
483+
dependencies to use this function.
484+
485+
### Arguments
486+
487+
* `string` is a string in any shape the locale accepts, including the
488+
locale's CLDR short, medium, long and full patterns and ISO 8601.
489+
490+
* `options` is a keyword list of options.
491+
492+
### Options
493+
494+
* `:locale` is a locale identifier. The default is the locale returned by
495+
`Localize.get_locale/0`.
496+
497+
* Remaining options are passed to `Calendrical.Date.parse/2`, which
498+
documents them.
499+
500+
### Returns
501+
502+
* `{:ok, value}` where `value` is a `t:Date.t()` , or
503+
504+
* `{:error, exception}` if the string does not parse, or a
505+
`t:Localize.DependencyRequiredError.t/0` if `calendrical` is not among
506+
the application's dependencies.
507+
508+
### Examples
509+
510+
Shown rather than run as doctests: `calendrical` is not a dependency of
511+
Localize itself, so the call does not resolve in this package's own tests.
512+
513+
Localize.Date.parse("22.03.2026", locale: :de)
514+
#=> {:ok, ~D[2026-03-22]}
515+
516+
Localize.Date.parse("March 22, 2026", locale: :en)
517+
#=> {:ok, ~D[2026-03-22]}
518+
519+
"""
520+
@spec parse(String.t(), Keyword.t()) :: {:ok, Date.t()} | {:error, Exception.t()}
521+
def parse(string, options \\ []) when is_binary(string) do
522+
Localize.OptionalDependency.call(
523+
"Calendrical.Date",
524+
:parse,
525+
[string, options],
526+
package: "calendrical",
527+
operation: "Localize.Date.parse/2"
528+
)
529+
end
475530
end

lib/localize/datetime.ex

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -559,4 +559,60 @@ defmodule Localize.DateTime do
559559
end
560560

561561
defp resolve_locale_id(locale), do: Localize.Locale.cldr_locale_id_from(locale)
562+
563+
@doc """
564+
Parses a localized date and time string.
565+
566+
Parsing lives in the companion [calendrical](https://hex.pm/packages/calendrical)
567+
package, which carries the calendar systems Localize formats for.
568+
`calendrical` depends on Localize, so Localize resolves it at runtime rather
569+
than depending on it in return — add `{:calendrical, "~> 1.0"}` to your
570+
dependencies to use this function.
571+
572+
### Arguments
573+
574+
* `string` is a string in any shape the locale accepts, including the
575+
locale's CLDR short, medium, long and full patterns and ISO 8601.
576+
577+
* `options` is a keyword list of options.
578+
579+
### Options
580+
581+
* `:locale` is a locale identifier. The default is the locale returned by
582+
`Localize.get_locale/0`.
583+
584+
* Remaining options are passed to `Calendrical.DateTime.parse/2`, which
585+
documents them.
586+
587+
### Returns
588+
589+
* `{:ok, value}` where `value` is a `t:NaiveDateTime.t()` , or
590+
591+
* `{:error, exception}` if the string does not parse, or a
592+
`t:Localize.DependencyRequiredError.t/0` if `calendrical` is not among
593+
the application's dependencies.
594+
595+
### Examples
596+
597+
Shown rather than run as doctests: `calendrical` is not a dependency of
598+
Localize itself, so the call does not resolve in this package's own tests.
599+
600+
Localize.DateTime.parse("22.03.2026, 14:30", locale: :de)
601+
#=> {:ok, ~N[2026-03-22 14:30:00]}
602+
603+
Localize.DateTime.parse("March 22, 2026, 2:30 PM", locale: :en)
604+
#=> {:ok, ~N[2026-03-22 14:30:00]}
605+
606+
"""
607+
@spec parse(String.t(), Keyword.t()) ::
608+
{:ok, NaiveDateTime.t() | DateTime.t()} | {:error, Exception.t()}
609+
def parse(string, options \\ []) when is_binary(string) do
610+
Localize.OptionalDependency.call(
611+
"Calendrical.DateTime",
612+
:parse,
613+
[string, options],
614+
package: "calendrical",
615+
operation: "Localize.DateTime.parse/2"
616+
)
617+
end
562618
end
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
defmodule Localize.DependencyRequiredError do
2+
@moduledoc """
3+
Exception returned when an operation needs a companion package that is
4+
not among the application's dependencies.
5+
6+
Localize delegates a few operations to sibling packages that depend on
7+
Localize in turn, so it cannot depend on them back. Those operations
8+
resolve the module at runtime and return this exception when it is
9+
absent, naming the package to add.
10+
11+
"""
12+
13+
defexception [:package, :operation]
14+
15+
@typedoc """
16+
The package that is required, and the operation that needs it.
17+
18+
"""
19+
@type t :: %__MODULE__{package: String.t(), operation: String.t()}
20+
21+
@impl true
22+
def exception(bindings) when is_list(bindings) do
23+
struct!(__MODULE__, bindings)
24+
end
25+
26+
@impl true
27+
def message(%__MODULE__{package: package, operation: operation}) do
28+
Localize.Exception.safe_message(
29+
"datetime",
30+
"{$operation} requires the {$package} package, which is not among your dependencies.",
31+
operation: operation,
32+
package: package
33+
)
34+
end
35+
end

lib/localize/gettext/messages.ex

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,13 @@ defmodule Localize.Gettext.Messages do
1717
def __messages__ do
1818
[
1919
# Currency
20+
# Dependencies
21+
Gettext.Macros.dpgettext_noop_with_backend(
22+
Localize.Gettext,
23+
"localize",
24+
"datetime",
25+
"{$operation} requires the {$package} package, which is not among your dependencies."
26+
),
2027
Gettext.Macros.dpgettext_noop_with_backend(
2128
Localize.Gettext,
2229
"localize",

lib/localize/nif.ex

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -215,6 +215,18 @@ defmodule Localize.Nif do
215215
An integer: `-1` (less than), `0` (equal), or `1` (greater than).
216216
217217
"""
218+
@spec nif_collation_cmp(
219+
binary(),
220+
binary(),
221+
integer(),
222+
integer(),
223+
integer(),
224+
integer(),
225+
integer(),
226+
integer(),
227+
integer(),
228+
binary()
229+
) :: -1 | 0 | 1
218230
@dialyzer {:no_return, nif_collation_cmp: 10}
219231
# The arity mirrors the C NIF signature one-to-one; collapsing the
220232
# collation options into a map would add per-call marshalling on a
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
defmodule Localize.OptionalDependency do
2+
@moduledoc false
3+
4+
# Some operations belong to a sibling package in the Localize family rather
5+
# than to Localize itself — parsing a localized date is `calendrical`'s job,
6+
# and `calendrical` depends on Localize, so Localize cannot depend on it in
7+
# return without a cycle.
8+
#
9+
# The target module is therefore built with `Module.concat/1` and called
10+
# through a variable rather than named literally. The compiler records no
11+
# dependency and emits no undefined-module warning, and the call resolves at
12+
# runtime against whatever the consuming application actually has.
13+
14+
@doc false
15+
@spec call(String.t(), atom(), [term()], keyword()) :: term() | {:error, Exception.t()}
16+
def call(module_name, function, args, context) do
17+
module = Module.concat([module_name])
18+
19+
# `Code.ensure_loaded?/1` first: `function_exported?/3` answers false for a
20+
# module that is compiled but not yet loaded, which would report the
21+
# package missing on the first call after boot.
22+
if Code.ensure_loaded?(module) and function_exported?(module, function, length(args)) do
23+
Kernel.apply(module, function, args)
24+
else
25+
{:error,
26+
Localize.DependencyRequiredError.exception(
27+
package: Keyword.fetch!(context, :package),
28+
operation: Keyword.fetch!(context, :operation)
29+
)}
30+
end
31+
end
32+
end

lib/localize/time.ex

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -539,4 +539,59 @@ defmodule Localize.Time do
539539
# ── Locale resolution ──────────────────────────────────────
540540

541541
defp resolve_locale_id(locale), do: Localize.Locale.cldr_locale_id_from(locale)
542+
543+
@doc """
544+
Parses a localized time string.
545+
546+
Parsing lives in the companion [calendrical](https://hex.pm/packages/calendrical)
547+
package, which carries the calendar systems Localize formats for.
548+
`calendrical` depends on Localize, so Localize resolves it at runtime rather
549+
than depending on it in return — add `{:calendrical, "~> 1.0"}` to your
550+
dependencies to use this function.
551+
552+
### Arguments
553+
554+
* `string` is a string in any shape the locale accepts, including the
555+
locale's CLDR short, medium, long and full patterns and ISO 8601.
556+
557+
* `options` is a keyword list of options.
558+
559+
### Options
560+
561+
* `:locale` is a locale identifier. The default is the locale returned by
562+
`Localize.get_locale/0`.
563+
564+
* Remaining options are passed to `Calendrical.Time.parse/2`, which
565+
documents them.
566+
567+
### Returns
568+
569+
* `{:ok, value}` where `value` is a `t:Time.t()` , or
570+
571+
* `{:error, exception}` if the string does not parse, or a
572+
`t:Localize.DependencyRequiredError.t/0` if `calendrical` is not among
573+
the application's dependencies.
574+
575+
### Examples
576+
577+
Shown rather than run as doctests: `calendrical` is not a dependency of
578+
Localize itself, so the call does not resolve in this package's own tests.
579+
580+
Localize.Time.parse("14:30", locale: :de)
581+
#=> {:ok, ~T[14:30:00]}
582+
583+
Localize.Time.parse("2:30 PM", locale: :en)
584+
#=> {:ok, ~T[14:30:00]}
585+
586+
"""
587+
@spec parse(String.t(), Keyword.t()) :: {:ok, Time.t()} | {:error, Exception.t()}
588+
def parse(string, options \\ []) when is_binary(string) do
589+
Localize.OptionalDependency.call(
590+
"Calendrical.Time",
591+
:parse,
592+
[string, options],
593+
package: "calendrical",
594+
operation: "Localize.Time.parse/2"
595+
)
596+
end
542597
end

lib/localize/unit/operators.ex

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ defmodule Localize.Unit.Operators do
6868
through to `Kernel.+/2`.
6969
7070
"""
71+
@spec unquote(:+)(Unit.t() | number(), Unit.t() | number()) :: Unit.t() | number()
7172
def unquote(:+)(%Unit{} = left, %Unit{} = right) do
7273
unwrap!(Unit.Math.add(left, right))
7374
end
@@ -84,6 +85,7 @@ defmodule Localize.Unit.Operators do
8485
through to `Kernel.-/2`.
8586
8687
"""
88+
@spec unquote(:-)(Unit.t() | number(), Unit.t() | number()) :: Unit.t() | number()
8789
def unquote(:-)(%Unit{} = left, %Unit{} = right) do
8890
unwrap!(Unit.Math.sub(left, right))
8991
end
@@ -101,6 +103,7 @@ defmodule Localize.Unit.Operators do
101103
`Kernel.*/2`.
102104
103105
"""
106+
@spec unquote(:*)(Unit.t() | number(), Unit.t() | number()) :: Unit.t() | number()
104107
def unquote(:*)(%Unit{} = left, %Unit{} = right) do
105108
unwrap!(Unit.Math.mult(left, right))
106109
end
@@ -128,6 +131,7 @@ defmodule Localize.Unit.Operators do
128131
`Kernel.//2`.
129132
130133
"""
134+
@spec unquote(:/)(Unit.t() | number(), Unit.t() | number()) :: Unit.t() | number()
131135
def unquote(:/)(%Unit{} = left, %Unit{} = right) do
132136
unwrap!(Unit.Math.div(left, right))
133137
end

0 commit comments

Comments
 (0)