Skip to content

Commit fb2a0ff

Browse files
committed
MF2: first-class function namespaces and the l:quantify function
1 parent e3a882c commit fb2a0ff

9 files changed

Lines changed: 507 additions & 25 deletions

File tree

guides/conformance.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -475,6 +475,19 @@ Two areas are explicitly out of scope:
475475
| Function | Notes |
476476
|----------|-------|
477477
| `:list` | Formats a list operand by delegating to `Localize.List.to_string/2`. Each element is itself formatted via `Localize.Chars`, so a list of dates, numbers, units, etc. picks up the message's locale and forwarded options. Supports a `style` (or `type`) option whose values map to CLDR list styles: `"and"`, `"and-short"`, `"and-narrow"`, `"or"`, `"or-short"`, `"or-narrow"`, `"unit"`, `"unit-short"`, `"unit-narrow"`. Default is `"and"`. |
478+
| `:l:inflect` | Inflects a phrase operand for the grammatical constraints in its options (`grammaticalCase`, `grammaticalGender`, `grammaticalNumber`, `grammaticalDefiniteness`, `grammaticalPerson`), delegating to `Localize.Inflection.inflect/3`. Requires the locale's inflection data; a missing locale or absent data is a clean error, never a crash. |
479+
| `:l:pronoun` | Selects a pronoun, or re-inflects the operand pronoun, via `Localize.Inflection.pronoun/2,3`. |
480+
| `:l:quantify` | Joins a `count` option with the noun operand so the noun agrees with the number (Slavic numeral government, the Arabic counted-noun cases, per-language number agreement), delegating to `Localize.Inflection.quantify/4`. |
481+
482+
These live in the reserved single-letter `l:` namespace; see *MF2 function namespaces* below.
483+
484+
### MF2 function namespaces
485+
486+
| Feature | Status | Notes |
487+
|---------|--------|-------|
488+
| Namespaced function names (`:ns:name`) | Implemented | The parser accepts `namespace:name`; the interpreter resolves the namespace after the built-in functions. |
489+
| Reserved namespaces (`l`, `u`) | Implemented | `l` is Localize's own (`:l:inflect` / `:l:pronoun` / `:l:quantify`); `u` is the CLDR-managed namespace the spec reserves for options. Neither routes to a user handler, and an unhandled `l:` / `u:` function is an Unknown Function error — which the spec permits, since implementations are not required to support every namespace. |
490+
| Custom namespace handlers | Implemented | Register a `Localize.Message.Namespace` handler for a namespace via `config :localize, :mf2_namespaces` or the per-call `:namespaces` option; one handler dispatches every function in its namespace by local name. The flat-string `:mf2_functions` custom registry continues to work for back-compat. Built-in functions are authoritative and cannot be shadowed by a custom function or namespace. |
478491

479492
### MF2 Error Handling
480493

guides/inflection.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -279,17 +279,20 @@ iex> Localize.Unit.to_string(unit, locale: :ru, grammatical_case: :prepositional
279279

280280
## In MessageFormat 2 messages
281281

282-
Inflection is available inside MF2 messages through the `l:` namespace: `:l:inflect` inflects its operand phrase, and `:l:pronoun` selects or re-inflects a pronoun. Grammatical constraints are passed as `grammatical*` options — `grammaticalCase`, `grammaticalGender`, `grammaticalNumber`, `grammaticalDefiniteness`, `grammaticalPerson` — mirroring the `:unit` function's naming:
282+
Inflection is available inside MF2 messages through the `l:` namespace: `:l:inflect` inflects its operand phrase, `:l:pronoun` selects or re-inflects a pronoun, and `:l:quantify` joins a `count` with a noun operand so the noun agrees with the number. Grammatical constraints are passed as `grammatical*` options — `grammaticalCase`, `grammaticalGender`, `grammaticalNumber`, `grammaticalDefiniteness`, `grammaticalPerson` — mirroring the `:unit` function's naming:
283283

284284
```elixir
285285
iex> Localize.Message.format("{$w :l:inflect grammaticalNumber=plural}", %{w: "light on the patio"}, locale: :en)
286286
{:ok, "lights on the patio"}
287287

288288
iex> Localize.Message.format("{|he| :l:pronoun grammaticalCase=accusative}", %{}, locale: :en)
289289
{:ok, "him"}
290+
291+
iex> Localize.Message.format("{$noun :l:quantify count=5}", %{noun: "час"}, locale: :ru)
292+
{:ok, "5 часов"}
290293
```
291294

292-
The functions need the locale's inflection data present; a missing locale or absent data resolves to an error rather than crashing the format. `:l:quantify` is not yet available.
295+
The functions need the locale's inflection data present; a missing locale or absent data resolves to an error rather than crashing the format.
293296

294297
## Error handling
295298

guides/message_formatting.md

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -291,6 +291,26 @@ Selects or re-inflects a pronoun for the locale. Given a pronoun operand, it re-
291291

292292
Takes the same `grammatical*` options as `:l:inflect`, and likewise requires the locale's inflection data, degrading to an error when the data or locale is unavailable.
293293

294+
### `:l:quantify`
295+
296+
Joins a number with a noun so the noun agrees with it grammatically — the count-and-noun phrase that languages build differently (Slavic numeral government, the Arabic counted-noun cases, the Finnish partitive, and so on). The operand is the noun; the number is the required `count` option.
297+
298+
```
299+
{$item :l:quantify count=$n}
300+
{|kilometer| :l:quantify count=2}
301+
```
302+
303+
The `count` drives both the number joined to the noun (formatted with the message's locale) and the plural category the noun agrees with, so one template produces the right form across categories:
304+
305+
| Locale | Template | Result |
306+
|--------|----------|--------|
307+
| `en` | `{|kilometer| :l:quantify count=2}` | `2 kilometers` |
308+
| `ru` | `{|час| :l:quantify count=2}` | `2 часа` |
309+
| `ru` | `{|час| :l:quantify count=5}` | `5 часов` |
310+
| `fi` | `{|talo| :l:quantify count=3}` | `3 taloa` |
311+
312+
It also accepts the same `grammatical*` options as `:l:inflect`, to fix a case or gender the surrounding sentence requires. Like the other `l:` functions it needs the locale's inflection data; a missing or non-numeric `count`, a non-string operand, or absent data all resolve to a clean error, never a crash.
313+
294314
## Declarations
295315

296316
Declarations appear at the start of a complex message, before the body.
@@ -585,6 +605,7 @@ The Localize MF2 implementation targets the [Unicode MessageFormat 2.0 specifica
585605
| `:list` | Localize | Locale-aware list join via `Localize.List` |
586606
| `:l:inflect` | Localize | Grammatical inflection via `Localize.Inflection` |
587607
| `:l:pronoun` | Localize | Pronoun selection via `Localize.Inflection` |
608+
| `:l:quantify` | Localize | Number-and-noun agreement via `Localize.Inflection` |
588609
589610
### `:list` — locale-aware list formatting
590611
@@ -664,7 +685,7 @@ config :localize, :mf2_functions, %{
664685
}
665686
```
666687
667-
Per-call functions take precedence over application-level functions, which take precedence over built-in functions. A function name with no built-in implementation and no registry entry is an unknown-function error, per the MF2 specification: `format/3` returns `{:error, %Localize.FormatError{reason: :unknown_function}}`.
688+
Per-call functions take precedence over application-level functions. Both registries are consulted only for a function name that no built-in already handles — the built-in functions are authoritative and cannot be shadowed. A function name with no built-in implementation and no registry entry is an unknown-function error, per the MF2 specification: `format/3` returns `{:error, %Localize.FormatError{reason: :unknown_function}}`.
668689
669690
**Implementing a custom function:**
670691
@@ -686,6 +707,38 @@ end
686707
687708
See `Localize.Message.Function` for the full callback specification.
688709
710+
### Function namespaces
711+
712+
A function name may carry a namespace — `:acme:price`. Namespaced functions resolve after the built-ins, so one handler can own an entire namespace rather than registering each function by its flattened name. Register a `Localize.Message.Namespace` handler per namespace, application-wide or per call:
713+
714+
```elixir
715+
# config/config.exs
716+
config :localize, :mf2_namespaces, %{"acme" => MyApp.AcmeFunctions}
717+
718+
# or per call
719+
Localize.Message.format(message, bindings, locale: :en, namespaces: %{"acme" => MyApp.AcmeFunctions})
720+
```
721+
722+
The handler dispatches every function in its namespace by local name:
723+
724+
```elixir
725+
defmodule MyApp.AcmeFunctions do
726+
@behaviour Localize.Message.Namespace
727+
728+
@impl true
729+
def format("price", value, func_opts, options) do
730+
locale = Keyword.get(options, :locale)
731+
MyApp.Price.to_string(value, locale: locale, currency: func_opts["currency"])
732+
end
733+
734+
def format(name, _value, _func_opts, _options) do
735+
{:error, {:unknown_function, ":acme:" <> name}}
736+
end
737+
end
738+
```
739+
740+
The single-letter namespaces `l` (Localize's own `:l:inflect` / `:l:pronoun` / `:l:quantify`) and `u` (CLDR-managed) are reserved: registering a handler for them has no effect, and an `l:` or `u:` function that no built-in handles is an unknown-function error. See `Localize.Message.Namespace` for the full callback specification.
741+
689742
## Syntax highlighting
690743
691744
For docs, playgrounds, editor tooling, or error reporting, Localize can render an MF2 message as a *classified token stream* and format it as HTML (with CSS classes) or ANSI-coloured terminal output. Highlighting runs after parsing, so only valid MF2 input is highlighted — parse errors surface the same `{:error, %ParseError{}}` tuple as elsewhere.

lib/localize/message/function.ex

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,14 @@ defmodule Localize.Message.Function do
2828
Application-level functions are available to all
2929
`Localize.Message.format/3` calls without passing `:functions`
3030
on every call. Per-call functions take precedence over
31-
application-level functions of the same name, which in turn
32-
take precedence over built-in functions.
31+
application-level functions of the same name.
32+
33+
Built-in functions are authoritative: both registries are
34+
consulted only for a function name that no built-in already
35+
handles, so a custom function cannot shadow `:number`, `:date`,
36+
`:l:inflect`, or any other built-in. To own a whole custom
37+
namespace with a single handler rather than one registration per
38+
name, implement `Localize.Message.Namespace` instead.
3339
3440
## Implementing a custom function
3541
@@ -62,7 +68,8 @@ defmodule Localize.Message.Function do
6268
6369
* `func_opts` — a map of MF2 function options parsed from the
6470
expression (e.g. `%{"format" => "long", "formality" => "formal"}`).
65-
Keys and values are strings.
71+
Keys are strings. Values are strings, except an option written as
72+
a number literal (e.g. `digits=2`) which arrives as a number.
6673
6774
* `options` — the interpreter's keyword list, which includes at
6875
least `:locale` and `:bindings`.
@@ -77,8 +84,9 @@ defmodule Localize.Message.Function do
7784
7885
* `value` is the resolved operand from the MF2 expression.
7986
80-
* `func_opts` is a map of string key/value pairs from the MF2
81-
function options (e.g. `%{"format" => "long"}`).
87+
* `func_opts` is a map of the MF2 function options with string
88+
keys (e.g. `%{"format" => "long"}`); values are strings unless
89+
written as a number literal.
8290
8391
* `options` is the interpreter's keyword list (contains at least
8492
`:locale`).

lib/localize/message/interpreter.ex

Lines changed: 101 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1047,11 +1047,12 @@ defmodule Localize.Message.Interpreter do
10471047
#
10481048
# `:l:inflect` inflects its phrase operand for the grammatical
10491049
# constraints given in its options; `:l:pronoun` selects a pronoun
1050-
# (or re-inflects the operand pronoun). Both wrap the in-tree
1051-
# `Localize.Inflection` engine and need the locale's inflection data
1052-
# present — a missing locale or absent data resolves to a clean
1053-
# error tuple, never a crash. `:l:quantify` is deferred until the
1054-
# engine ships the concept/quantity primitives it needs.
1050+
# (or re-inflects the operand pronoun); `:l:quantify` joins a
1051+
# `count` with the noun operand so the noun agrees with the number
1052+
# (Slavic numeral government, the Arabic counted-noun cases, and so
1053+
# on). All three wrap the in-tree `Localize.Inflection` engine and
1054+
# need the locale's inflection data present — a missing locale or
1055+
# absent data resolves to a clean error tuple, never a crash.
10551056

10561057
defp format_with_function("l:inflect", value, func_opts, options) when is_binary(value) do
10571058
locale = Keyword.get(options, :locale, Localize.get_locale())
@@ -1082,6 +1083,25 @@ defmodule Localize.Message.Interpreter do
10821083
end
10831084
end
10841085

1086+
defp format_with_function("l:quantify", value, func_opts, options) when is_binary(value) do
1087+
locale = Keyword.get(options, :locale, Localize.get_locale())
1088+
1089+
# The count formats through Localize's own number formatter (so
1090+
# the joined number is locale-aware) and is also passed as
1091+
# `:number` so the engine selects the plural category from it.
1092+
with {:ok, count} <- quantify_count(func_opts),
1093+
{:ok, formatted} <- Localize.Number.to_string(count, locale: locale) do
1094+
Localize.Inflection.quantify(formatted, value, locale,
1095+
number: count,
1096+
constraints: map_inflect_constraints(func_opts)
1097+
)
1098+
end
1099+
end
1100+
1101+
defp format_with_function("l:quantify", value, _func_opts, _options) do
1102+
{:error, "the :l:quantify function requires a string (noun) operand, got #{inspect(value)}"}
1103+
end
1104+
10851105
# ── Custom function registry ─────────────────────────────────────
10861106
#
10871107
# When a function name is not matched by any built-in clause
@@ -1096,18 +1116,37 @@ defmodule Localize.Message.Interpreter do
10961116
# Function resolution error per TR35.
10971117

10981118
defp format_with_function(name, value, func_opts, options) do
1119+
# Custom function and namespace handlers receive option keys as
1120+
# strings — the documented contract — even though the built-in
1121+
# clauses above read them as atoms. (Option names are atomized
1122+
# only when a matching atom already exists, so the raw map mixes
1123+
# atom and string keys.)
1124+
string_opts = stringify_option_keys(func_opts)
1125+
10991126
case resolve_custom_function(name, options) do
11001127
{:ok, module} ->
1101-
module.format(value, func_opts, options)
1128+
module.format(value, string_opts, options)
11021129

11031130
:not_found ->
1104-
# TR35 resolution error: a function that cannot be resolved
1105-
# is an Unknown Function error, not a pass-through format of
1106-
# the operand.
1107-
{:error, {:unknown_function, ":" <> name}}
1131+
case resolve_namespace_handler(name, options) do
1132+
{:ok, module, local_name} ->
1133+
module.format(local_name, value, string_opts, options)
1134+
1135+
:not_found ->
1136+
# TR35 resolution error: a function that cannot be
1137+
# resolved is an Unknown Function error, not a
1138+
# pass-through format of the operand.
1139+
{:error, {:unknown_function, ":" <> name}}
1140+
end
11081141
end
11091142
end
11101143

1144+
# Values are left as-is: a number-literal option is a number, not
1145+
# a string.
1146+
defp stringify_option_keys(func_opts) do
1147+
Map.new(func_opts, fn {key, value} -> {to_string(key), value} end)
1148+
end
1149+
11111150
# Maps MF2 grammatical option names to the inflection engine's bare
11121151
# constraint names. Values stay as strings; the engine normalizes
11131152
# both names and values (`Localize.Inflection.Feature`).
@@ -1127,6 +1166,16 @@ defmodule Localize.Message.Interpreter do
11271166
end)
11281167
end
11291168

1169+
# The `count` option of `:l:quantify` is required and must be
1170+
# numeric: it is both the number joined to the noun and the value
1171+
# the plural category is selected from.
1172+
defp quantify_count(func_opts) do
1173+
case Map.get(func_opts, :count) do
1174+
nil -> {:error, "the :l:quantify function requires a `count` option"}
1175+
value -> ensure_number(value)
1176+
end
1177+
end
1178+
11301179
defp format_number_as_unit(_number, nil, _func_opts, _options) do
11311180
{:error, "the :unit function requires a `unit` option"}
11321181
end
@@ -1288,6 +1337,48 @@ defmodule Localize.Message.Interpreter do
12881337
end
12891338
end
12901339

1340+
# Routes a namespaced function name (`ns:local`) to a registered
1341+
# namespace handler (`Localize.Message.Namespace`). The reserved
1342+
# single-letter namespaces `l` (Localize's own, handled by the
1343+
# built-in clauses above) and `u` (CLDR-managed) never route to a
1344+
# user handler, so an unhandled `l:`/`u:` function falls through to
1345+
# an Unknown Function error. The parsed `{:namespace, ns, name}` is
1346+
# recovered by splitting the flattened name on its single colon —
1347+
# MF2 identifiers contain none.
1348+
defp resolve_namespace_handler(name, options) do
1349+
case String.split(name, ":", parts: 2) do
1350+
[namespace, local_name]
1351+
when namespace not in ["l", "u"] and local_name != "" ->
1352+
case lookup_namespace(namespace, options) do
1353+
{:ok, module} -> {:ok, module, local_name}
1354+
:not_found -> :not_found
1355+
end
1356+
1357+
_other ->
1358+
:not_found
1359+
end
1360+
end
1361+
1362+
# Per-call `:namespaces` handlers take precedence over the
1363+
# application-level `:mf2_namespaces` config, mirroring the custom
1364+
# function registry.
1365+
defp lookup_namespace(namespace, options) do
1366+
per_call = Keyword.get(options, :namespaces, %{})
1367+
1368+
case Map.get(per_call, namespace) do
1369+
nil ->
1370+
app_namespaces = Application.get_env(:localize, :mf2_namespaces, %{})
1371+
1372+
case Map.get(app_namespaces, namespace) do
1373+
nil -> :not_found
1374+
module -> {:ok, module}
1375+
end
1376+
1377+
module ->
1378+
{:ok, module}
1379+
end
1380+
end
1381+
12911382
# ── Match evaluation ───────────────────────────────────────────
12921383

12931384
# MF2 match evaluation: per-selector formatted/original/function

0 commit comments

Comments
 (0)