Packages
localize
0.19.0
1.0.0-rc.4
1.0.0-rc.3
1.0.0-rc.2
1.0.0-rc.1
1.0.0-rc.0
0.50.0
0.49.0
0.48.0
0.47.0
0.46.0
0.45.0
0.44.0
0.41.3
0.41.2
0.41.1
0.41.0
0.40.0
0.39.0
0.38.0
0.37.0
0.36.0
0.35.0
0.34.0
0.33.0
0.32.0
0.31.0
0.30.1
0.30.0
retired
0.29.0
0.28.0
0.27.0
0.26.0
0.25.0
0.24.0
0.23.0
0.22.0
0.21.0
0.20.0
0.19.0
0.18.0
0.16.0
0.15.0
0.14.0
0.13.0
0.12.0
0.11.0
0.10.0
0.9.0
0.8.0
0.7.0
0.6.0
0.5.0
0.4.0
0.3.0
0.2.0
0.1.0
0.1.0-alpha.1
Localization (parsing, formatting) of numbers, dates/time/calendar, units of measure, messages and lists. Includes localized collation.
Current section
Files
Jump to
Current section
Files
lib/localize/unit/custom_registry.ex
defmodule Localize.Unit.CustomRegistry do
@moduledoc """
Runtime registry for user-defined units backed by `:persistent_term`.
Custom units are stored in a single map keyed by unit name. The registry
is checked by `Localize.Unit.Data`, `Localize.Unit.BaseUnit`,
`Localize.Unit.Conversion`, and the unit formatter to overlay
runtime definitions on top of the compile-time CLDR data.
## Definition structure
Each definition is a map with the following keys:
* `:base_unit` (required) — the CLDR base unit this custom unit converts to
(e.g., `"meter"`, `"kilogram"`, `"second"`).
* `:factor` (required) — the conversion factor:
`1 custom_unit = factor * base_unit`.
* `:offset` (optional) — additive offset for the conversion. Defaults to `0.0`.
* `:category` (required) — the unit category (e.g., `"length"`, `"mass"`).
* `:display` (optional) — locale-specific display patterns. A nested map of
`locale => style => plural_patterns`.
"""
@persistent_term_key :localize_custom_units
@doc """
Returns the definition for a custom unit, or `nil` if not registered.
### Arguments
* `unit_name` — the unit identifier string.
### Returns
* A definition map or `nil`.
"""
@spec get(String.t()) :: map() | nil
def get(unit_name) do
all() |> Map.get(unit_name)
end
@doc """
Returns whether a unit name is registered in the custom registry.
"""
@spec registered?(String.t()) :: boolean()
def registered?(unit_name) do
all() |> Map.has_key?(unit_name)
end
@doc """
Returns all registered custom units as a map of name to definition.
"""
@spec all() :: %{String.t() => map()}
def all do
:persistent_term.get(@persistent_term_key, %{})
end
@doc """
Registers a custom unit definition.
### Arguments
* `name` — the unit identifier string.
* `definition` — a map with `:base_unit`, `:factor`, and `:category` keys.
### Returns
* `:ok` on success.
* `{:error, reason}` if validation fails.
"""
@spec register(String.t(), map()) :: :ok | {:error, String.t()}
def register(name, definition) do
with :ok <- validate_name(name),
:ok <- validate_definition(definition),
:ok <- validate_no_collision(name) do
current = all()
:persistent_term.put(@persistent_term_key, Map.put(current, name, definition))
:ok
end
end
@doc """
Registers multiple custom units in a single `persistent_term` update.
This is significantly more memory-efficient than calling `register/2`
in a loop, because it avoids creating intermediate `persistent_term`
snapshots for each unit. Each snapshot is stored in the BEAM's literal
area and is not freed until a global garbage collection sweep, so
bulk registration via `register/2` can exhaust literal memory.
### Arguments
* `definitions` — a map of `%{name => definition}` where each definition
has `:base_unit`, `:factor`, and `:category` keys.
### Returns
* `{:ok, count}` with the number of units registered.
* `{:error, reason}` if any validation fails. No units are registered
on error (the operation is atomic).
"""
@spec register_batch(%{String.t() => map()}) :: {:ok, non_neg_integer()}
def register_batch(definitions) when is_map(definitions) do
validated_map =
Enum.reduce(definitions, %{}, fn {name, definition}, acc ->
with :ok <- validate_name(name),
:ok <- validate_definition(definition),
:ok <- validate_no_collision(name) do
Map.put(acc, name, definition)
else
{:error, _reason} -> acc
end
end)
current = all()
:persistent_term.put(@persistent_term_key, Map.merge(current, validated_map))
{:ok, map_size(validated_map)}
end
@doc """
Loads custom unit definitions from an `.exs` file.
The file must evaluate to a list of maps, each with a `:unit` key
and the standard definition fields.
> #### Security {: .warning}
>
> This function uses `Code.eval_file/1` to evaluate the given
> file, which executes arbitrary Elixir code. Only load files
> from trusted sources. Never call this function with unsanitised
> user input or paths derived from external data.
### Arguments
* `path` — path to the `.exs` file.
### Returns
* `{:ok, count}` with the number of units loaded.
* `{:error, reason}` on failure.
"""
@spec load_file(String.t()) :: {:ok, non_neg_integer()} | {:error, String.t()}
def load_file(path) do
expanded = Path.expand(path)
case File.exists?(expanded) do
false ->
{:error, "file not found: #{expanded}"}
true ->
try do
{definitions, _bindings} = Code.eval_file(expanded)
case definitions do
defs when is_list(defs) ->
batch =
Enum.reduce_while(defs, {:ok, %{}}, fn
%{unit: name} = definition, {:ok, acc} ->
{:cont, {:ok, Map.put(acc, name, Map.delete(definition, :unit))}}
other, _acc ->
{:halt,
{:error,
"invalid definition: expected map with :unit key, got #{inspect(other)}"}}
end)
case batch do
{:ok, batch_map} -> register_batch(batch_map)
{:error, _} = error -> error
end
other ->
{:error, "expected a list of definitions, got #{inspect(other, limit: 50)}"}
end
rescue
error -> {:error, Exception.message(error)}
end
end
end
@doc """
Removes all custom unit registrations. Primarily for testing.
"""
@spec clear() :: :ok
def clear do
:persistent_term.put(@persistent_term_key, %{})
:ok
end
# ── Validation ──
@name_pattern ~r/^[a-z][a-z0-9_-]*$/
defp validate_name(name) when is_binary(name) do
if Regex.match?(@name_pattern, name) do
:ok
else
{:error,
"invalid unit name #{inspect(name)}: must start with a lowercase letter and contain only lowercase letters, digits, and hyphens"}
end
end
defp validate_name(name) do
{:error, "unit name must be a string, got #{inspect(name)}"}
end
defp validate_definition(%{factor: :special} = definition) do
with :ok <- validate_required_key(definition, :base_unit, &is_binary/1),
:ok <- validate_required_key(definition, :category, &is_binary/1),
:ok <- validate_base_unit(definition.base_unit),
:ok <- validate_category(definition.category),
:ok <- validate_conversion_fun(definition, :forward),
:ok <- validate_conversion_fun(definition, :inverse) do
:ok
end
end
defp validate_definition(definition) when is_map(definition) do
with :ok <- validate_required_key(definition, :base_unit, &is_binary/1),
:ok <- validate_required_key(definition, :factor, &is_number/1),
:ok <- validate_required_key(definition, :category, &is_binary/1),
:ok <- validate_base_unit(definition.base_unit),
:ok <- validate_category(definition.category),
:ok <- validate_positive_factor(definition.factor) do
:ok
end
end
defp validate_definition(_definition) do
{:error, "definition must be a map"}
end
defp validate_required_key(map, key, validator) do
case Map.fetch(map, key) do
{:ok, value} ->
if validator.(value) do
:ok
else
{:error, "#{key} has invalid type: #{inspect(value)}"}
end
:error ->
{:error, "missing required key: #{key}"}
end
end
defp validate_base_unit(base_unit) do
case Localize.Unit.BaseUnit.base_unit(base_unit) do
{:ok, _} -> :ok
{:error, _} -> {:error, "unknown base unit: #{inspect(base_unit)}"}
end
end
defp validate_category(category) when is_binary(category) and category != "", do: :ok
defp validate_category(category) do
{:error, "invalid category: #{inspect(category)}, expected a non-empty string"}
end
defp validate_conversion_fun(definition, key) do
case Map.fetch(definition, key) do
{:ok, {module, function}} when is_atom(module) and is_atom(function) ->
Code.ensure_loaded(module)
if function_exported?(module, function, 1) do
:ok
else
{:error, "#{key}: #{inspect(module)}.#{function}/1 is not exported"}
end
{:ok, other} ->
{:error, "#{key}: expected {module, function} tuple, got #{inspect(other)}"}
:error ->
{:error, "missing required key: #{key} (required for special conversions)"}
end
end
defp validate_positive_factor(factor) when factor > 0, do: :ok
defp validate_positive_factor(factor) do
{:error, "factor must be positive, got #{inspect(factor)}"}
end
defp validate_no_collision(name) do
known = Localize.Unit.Data.conversions()
if Map.has_key?(known, name) do
{:error, "unit #{inspect(name)} already exists as a CLDR unit"}
else
:ok
end
end
end