Skip to content

Commit 4caee2b

Browse files
committed
MF2 signDisplay, implicit locale-aware number formatting, and re-annotation on original values
1 parent 519b1af commit 4caee2b

6 files changed

Lines changed: 136 additions & 39 deletions

File tree

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,16 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
1212

1313
* The MessageFormat working group's `:test:function`, `:test:format` and `:test:select` registry functions are implemented, so the WG conformance suite's selection-mechanics and fallback cases now run.
1414

15+
* The MF2 numeric functions accept the `signDisplay` option (`auto`, `always`, `exceptZero`, `negative`, `never`), with the plus sign taken from the locale's number symbols.
16+
17+
* An MF2 pattern expression that re-annotates a declared variable operates on the declaration's original value, and numeric functions inherit unset options from a numeric declaration — `.local $x = {41 :integer signDisplay=always}` rendered with `{$x :offset add=1}` produces "+42".
18+
1519
* The locale download base URL can be overridden with `config :localize, locale_base_url: "..."` for deployments mirroring the locale files; downloads are verified against the bundled hash manifest regardless of source.
1620

1721
### Changed
1822

23+
* An unannotated MF2 placeholder with a numeric operand formats with the locale-aware `:number` default (`{$pi}` renders "3.142" in en and "3,142" in fr), matching the MF2 implicit formatting rules; previously the value was stringified verbatim.
24+
1925
* MessageFormat 2 follows the specification's error semantics: unknown functions are an error (`Localize.FormatError` reason `:unknown_function`) instead of formatting the operand, and data-model validation rejects duplicate declarations, duplicate option names, and duplicate variants (NFC-normalized keys) with dedicated error reasons.
2026

2127
* MessageFormat 2 function validation is strict per TR35: the `select` option must be a literal set directly on the selector expression, digit-size options must be non-negative integers, string operands of numeric functions must match the `number-literal` production, and `:currency`, `:unit`, `:date`, `:time` and `:datetime` are rejected as selectors.

guides/conformance.md

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -460,7 +460,7 @@ Two areas are explicitly out of scope:
460460
| Feature | Status | Notes |
461461
|---------|--------|-------|
462462
| `:string` | Implemented | |
463-
| `:number` | Implemented | Delegates to `Localize.Number.to_string/2`. |
463+
| `:number` | Implemented | Delegates to `Localize.Number.to_string/2`. All numeric functions support `signDisplay`, and an unannotated numeric operand formats with the locale-aware `:number` default. |
464464
| `:integer` | Implemented | |
465465
| `:percent` | Implemented | |
466466
| `:currency` | Implemented | |
@@ -496,16 +496,10 @@ Two areas are explicitly out of scope:
496496

497497
The MessageFormat working group conformance suite — including the WG `:test:function` / `:test:format` / `:test:select` registry functions it uses to exercise selection mechanics — runs against both the parser and the formatter (`test/localize/message/formatter_conformance_test.exs`). The few cases the implementation cannot yet satisfy are excluded there, each with a documented reason:
498498

499-
* Declarations bind the formatted string, so re-annotating an already-annotated variable in a later declaration fails (one case each in the currency, date, time and percent suites).
500-
501499
* The `u:dir` / `u:id` expression *options* are not implemented (the `@u:dir` attribute form is).
502500

503501
* The `:isolate` bidi strategy isolates every placeholder where the WG default strategy leaves known-LTR placeholders unisolated (three cases).
504502

505-
* The `:offset` function's `signDisplay` option is not implemented (two cases).
506-
507-
* Unannotated number operands are not implicitly formatted with the locale-aware `:number` function (one case).
508-
509503
---
510504

511505
## Summary

lib/localize/message/interpreter.ex

Lines changed: 124 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,7 @@ defmodule Localize.Message.Interpreter do
156156
{:error, [], [], [var_name]}
157157

158158
{bindings, bound, selector_meta} ->
159+
options = Keyword.put(options, :declaration_meta, selector_meta)
159160
format_complex_body(body, bindings, options, bound, selector_meta)
160161
end
161162
end
@@ -403,6 +404,8 @@ defmodule Localize.Message.Interpreter do
403404
{:error, [], [], [var_name]}
404405

405406
{bindings, bound, selector_meta} ->
407+
options = Keyword.put(options, :declaration_meta, selector_meta)
408+
406409
case body do
407410
{:quoted_pattern, parts} ->
408411
structured_pattern(parts, bindings, options, bound)
@@ -720,6 +723,8 @@ defmodule Localize.Message.Interpreter do
720723
defp format_expression(operand, func, bindings, options) do
721724
case resolve_operand(operand, bindings) do
722725
{:ok, value, bound_names} ->
726+
{value, func} = reannotate_from_declaration(operand, value, func, options)
727+
723728
case apply_function(value, func, Keyword.put(options, :bindings, bindings)) do
724729
{:ok, formatted} -> {:ok, formatted, bound_names}
725730
{:unbound, var_name} -> {:unbound, var_name}
@@ -732,6 +737,40 @@ defmodule Localize.Message.Interpreter do
732737
end
733738
end
734739

740+
# A pattern expression that re-annotates a declared variable
741+
# operates on the declaration's original (unformatted) value —
742+
# not the formatted string the declaration bound — and a numeric
743+
# function inherits unset options from a numeric declaration, so
744+
# `.local $x = {41 :integer signDisplay=always}` rendered with
745+
# `{$x :offset add=1}` produces "+42".
746+
defp reannotate_from_declaration({:variable, name}, value, {:function, _, _} = func, options) do
747+
case options |> Keyword.get(:declaration_meta, %{}) |> Map.get(name) do
748+
{original, declared_func} ->
749+
{original, merge_declared_options(normalize_function(func), declared_func)}
750+
751+
nil ->
752+
{value, func}
753+
end
754+
end
755+
756+
defp reannotate_from_declaration(_operand, value, func, _options) do
757+
{value, func}
758+
end
759+
760+
defp merge_declared_options(
761+
{:function, name, options},
762+
{:function, declared_name, declared_options}
763+
)
764+
when name in ["number", "integer", "offset", "percent", "currency"] and
765+
declared_name in ["number", "integer", "offset", "percent", "currency"] do
766+
merged = Enum.uniq_by(options ++ declared_options, fn {:option, key, _value} -> key end)
767+
{:function, name, merged}
768+
end
769+
770+
defp merge_declared_options(func, _declared_func) do
771+
func
772+
end
773+
735774
defp resolve_operand(nil, _bindings) do
736775
{:ok, nil, []}
737776
end
@@ -753,6 +792,16 @@ defmodule Localize.Message.Interpreter do
753792

754793
# ── Function dispatch ──────────────────────────────────────────
755794

795+
defp apply_function(value, nil, options) when is_number(value) do
796+
# An unannotated placeholder with a numeric operand formats with
797+
# the locale-aware default, as if annotated with `:number`.
798+
Localize.Number.to_string(value, resolve_locale_options(options))
799+
end
800+
801+
defp apply_function(%Decimal{} = value, nil, options) do
802+
Localize.Number.to_string(value, resolve_locale_options(options))
803+
end
804+
756805
defp apply_function(value, nil, _options) do
757806
{:ok, to_string_value(value)}
758807
end
@@ -817,38 +866,36 @@ defmodule Localize.Message.Interpreter do
817866
defp format_with_function("number", value, func_opts, options) do
818867
with {:ok, number} <- ensure_number(value),
819868
{:ok, options_struct} <- build_number_options(options, func_opts) do
820-
Localize.Number.to_string(number, set_number_pattern(options_struct, number))
869+
format_number_result(number, options_struct, func_opts)
821870
end
822871
end
823872

824873
defp format_with_function("integer", value, func_opts, options) do
825874
with {:ok, number} <- ensure_number(value),
826875
{:ok, options_struct} <- build_number_options(options, func_opts) do
827-
integer = trunc(number)
828-
Localize.Number.to_string(integer, set_number_pattern(options_struct, integer))
876+
format_number_result(trunc(number), options_struct, func_opts)
829877
end
830878
end
831879

832880
defp format_with_function("offset", value, func_opts, options) do
833881
with {:ok, number} <- ensure_number(value),
834882
{:ok, adjustment} <- offset_adjustment(func_opts),
835883
{:ok, options_struct} <- build_number_options(options, func_opts) do
836-
adjusted = apply_offset(number, adjustment)
837-
Localize.Number.to_string(adjusted, set_number_pattern(options_struct, adjusted))
884+
format_number_result(apply_offset(number, adjustment), options_struct, func_opts)
838885
end
839886
end
840887

841888
defp format_with_function("percent", value, func_opts, options) do
842889
with {:ok, number} <- ensure_number(value),
843890
{:ok, options_struct} <- build_number_options(options, func_opts, format: :percent) do
844-
Localize.Number.to_string(number, set_number_pattern(options_struct, number))
891+
format_number_result(number, options_struct, func_opts)
845892
end
846893
end
847894

848895
defp format_with_function("currency", value, func_opts, options) do
849896
with {:ok, number} <- ensure_number(value),
850897
{:ok, options_struct} <- build_currency_options(options, func_opts) do
851-
Localize.Number.to_string(number, set_number_pattern(options_struct, number))
898+
format_number_result(number, options_struct, func_opts)
852899
end
853900
end
854901

@@ -1037,6 +1084,76 @@ defmodule Localize.Message.Interpreter do
10371084
defp to_float(%Decimal{} = number), do: Decimal.to_float(number)
10381085
defp to_float(number) when is_number(number), do: number * 1.0
10391086

1087+
# Formats a resolved numeric value honouring the MF2 `signDisplay`
1088+
# option (auto | always | exceptZero | negative | never). The plus
1089+
# sign comes from the locale's number symbols.
1090+
defp format_number_result(number, options_struct, func_opts) do
1091+
with {:ok, sign_display} <- sign_display_option(func_opts) do
1092+
format_with_sign_display(number, options_struct, sign_display)
1093+
end
1094+
end
1095+
1096+
defp format_with_sign_display(number, options_struct, "auto") do
1097+
Localize.Number.to_string(number, set_number_pattern(options_struct, number))
1098+
end
1099+
1100+
defp format_with_sign_display(number, options_struct, "never") do
1101+
magnitude = number_abs(number)
1102+
Localize.Number.to_string(magnitude, set_number_pattern(options_struct, magnitude))
1103+
end
1104+
1105+
defp format_with_sign_display(number, options_struct, "always") do
1106+
if number_negative?(number) do
1107+
format_with_sign_display(number, options_struct, "auto")
1108+
else
1109+
with {:ok, formatted} <- format_with_sign_display(number, options_struct, "auto") do
1110+
{:ok, plus_sign(options_struct) <> formatted}
1111+
end
1112+
end
1113+
end
1114+
1115+
defp format_with_sign_display(number, options_struct, "exceptZero") do
1116+
cond do
1117+
number_zero?(number) -> format_with_sign_display(number, options_struct, "never")
1118+
number_negative?(number) -> format_with_sign_display(number, options_struct, "auto")
1119+
true -> format_with_sign_display(number, options_struct, "always")
1120+
end
1121+
end
1122+
1123+
defp format_with_sign_display(number, options_struct, "negative") do
1124+
if number_zero?(number) do
1125+
format_with_sign_display(number, options_struct, "never")
1126+
else
1127+
format_with_sign_display(number, options_struct, "auto")
1128+
end
1129+
end
1130+
1131+
defp sign_display_option(func_opts) do
1132+
case func_opts[:signDisplay] || func_opts["signDisplay"] do
1133+
nil ->
1134+
{:ok, "auto"}
1135+
1136+
value when value in ["auto", "always", "exceptZero", "negative", "never"] ->
1137+
{:ok, value}
1138+
1139+
value ->
1140+
{:error,
1141+
"the signDisplay option must be one of auto, always, exceptZero, negative " <>
1142+
"or never, got #{inspect(value)}"}
1143+
end
1144+
end
1145+
1146+
defp plus_sign(%{symbols: %{plus_sign: plus}}), do: plus
1147+
1148+
defp number_negative?(%Decimal{sign: sign}), do: sign < 0
1149+
defp number_negative?(number), do: number < 0
1150+
1151+
defp number_zero?(%Decimal{} = decimal), do: Decimal.equal?(decimal, 0)
1152+
defp number_zero?(number), do: number == 0
1153+
1154+
defp number_abs(%Decimal{} = decimal), do: Decimal.abs(decimal)
1155+
defp number_abs(number), do: abs(number)
1156+
10401157
defp resolve_custom_function(name, options) do
10411158
per_call = Keyword.get(options, :functions, %{})
10421159

plans/RELEASE_1.0_READINESS_2026-07-15.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,7 @@ The July 4 plan is almost entirely executed. Milestones 1–3 are done and verif
8787
Updated July 15 (second pass): items 1, 2, 4, 5, 6, 7, 8, and 10–13 are all done as annotated above. Remaining:
8888

8989
1. Item 3 — delegate removal timing: decide whether 1.0 removes all deprecated delegates (the known_/available_ set, Duration `:style`, and the new Currency positional forms) in one sweep or carries them to a later release. All messages promise removal by 1.0 and no later than December 2026.
90-
2. The MF2 additive bucket (post-1.0 candidates or pre-1.0 polish): re-annotation of annotated variables (declarations bind formatted strings — an interpreter design change), `u:dir`/`u:id` expression options, WG-default bidi strategy, `:offset` `signDisplay`, implicit locale-aware number formatting of unannotated operands.
90+
2. The MF2 additive bucket — mostly CLOSED July 15 (third pass): `signDisplay` on all numeric functions, implicit locale-aware `:number` formatting of unannotated numeric operands, and re-annotation of declared variables (pattern expressions operate on the declaration's original value with numeric option inheritance). Remaining: `u:dir`/`u:id` expression options and the WG-default bidi strategy (five excluded suite cases in total).
9191
3. Item 9 — `localize_mcp` release (repo, review, hex publish); README MCP section.
9292
4. Full house release review; 1.0.0-rc.1; soak; 1.0.0.
9393

test/localize/message/formatter_conformance_test.exs

Lines changed: 0 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -77,35 +77,12 @@ defmodule Localize.Message.FormatterConformanceTest do
7777
# Every exclusion is a real, documented implementation gap; the
7878
# remaining cases in each file are asserted.
7979
@exclusion_groups %{
80-
"syntax.json" => %{
81-
"known gap: unannotated number operands are not implicitly formatted with the locale-aware :number function" =>
82-
[90]
83-
},
8480
"bidi.json" => %{
8581
"WG default bidi strategy leaves known-LTR placeholders unisolated; :isolate wraps all placeholders" =>
8682
[16, 19, 20]
8783
},
8884
"u-options.json" => %{
8985
"u:dir/u:id expression options not implemented" => [1, 2, 3, 5, 6, 8]
90-
},
91-
"functions/currency.json" => %{
92-
"known gap: declarations bind the formatted string, so re-annotating an annotated variable fails" =>
93-
[7]
94-
},
95-
"functions/date.json" => %{
96-
"known gap: declarations bind the formatted string, so re-annotating an annotated variable fails" =>
97-
[6]
98-
},
99-
"functions/offset.json" => %{
100-
"known gap: signDisplay option not implemented" => [10, 11]
101-
},
102-
"functions/percent.json" => %{
103-
"known gap: declarations bind the formatted string, so re-annotating an annotated variable fails" =>
104-
[5]
105-
},
106-
"functions/time.json" => %{
107-
"known gap: declarations bind the formatted string, so re-annotating an annotated variable fails" =>
108-
[5]
10986
}
11087
}
11188

test/localize/message/sigils_test.exs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -239,7 +239,10 @@ defmodule Localize.Message.SigilsTest do
239239
end
240240

241241
test "remote call on an atom module derives mod_fun" do
242-
assert AssignsFixture.atom_module_call() =~ "Pi is 3.14159"
242+
# An unannotated numeric operand formats with the locale-aware
243+
# :number default (at most three fraction digits, matching the
244+
# MF2 implicit formatting behaviour and Intl.NumberFormat).
245+
assert AssignsFixture.atom_module_call() =~ "Pi is 3.142"
243246
end
244247

245248
test "nested dot access not rooted at assigns raises" do

0 commit comments

Comments
 (0)