Packages

Address parsing and locale-aware formatting for Elixir. Parses unstructured address strings via libpostal NIF and formats addresses using OpenCageData templates for 267 countries.

Current section

Files

Jump to
localize_address lib localize address formatter.ex
Raw

lib/localize/address/formatter.ex

defmodule Localize.Address.Formatter do
@moduledoc """
Formats a `Localize.Address` struct into a human-readable
string using OpenCageData address formatting templates.
Templates must be downloaded and compiled before use by
running `mix localize.address.download_templates`.
"""
alias Localize.Address.Address
@templates_path "priv/address_templates.etf"
if File.exists?(@templates_path) do
@external_resource @templates_path
@template_data :erlang.binary_to_term(File.read!(@templates_path))
else
@template_data nil
end
@doc """
Formats an address struct as a string for the given territory.
### Arguments
* `address` is a `Localize.Address.Address` struct.
* `territory_code` is an ISO 3166-1 alpha-2 territory code
string (e.g., `"US"`, `"GB"`).
### Returns
* `{:ok, formatted_string}` on success.
* `{:error, reason}` if formatting fails.
### Examples
iex> address = %Localize.Address.Address{
...> house_number: "301",
...> road: "Hamilton Avenue",
...> city: "Palo Alto",
...> state: "CA",
...> postcode: "94303",
...> territory: "United States of America",
...> territory_code: "US"
...> }
iex> {:ok, formatted} = Localize.Address.Formatter.format(address, "US")
iex> is_binary(formatted)
true
"""
@spec format(Address.t(), String.t()) :: {:ok, String.t()} | {:error, String.t()}
def format(%Address{} = address, territory_code) do
if @template_data do
do_format(address, String.upcase(territory_code), @template_data)
else
{:error, "address templates not compiled; run: mix localize.address.download_templates"}
end
end
@doc """
Formats an address with additional component bindings.
Like `format/2` but accepts a map of extra string-keyed bindings
that supplement the struct fields. This is used when the caller has
component values that don't map directly to struct fields (e.g.,
`"suburb"`, `"town"`, `"pedestrian"`) but are referenced by
templates.
### Arguments
* `address` is a `Localize.Address.Address` struct.
* `extra_bindings` is a map of `%{String.t() => String.t()}`
providing additional template variable values.
* `territory_code` is an ISO 3166-1 alpha-2 territory code string.
### Returns
* `{:ok, formatted_string}` on success.
* `{:error, reason}` if formatting fails.
"""
@spec format_with_bindings(Address.t(), map(), String.t()) ::
{:ok, String.t()} | {:error, String.t()}
def format_with_bindings(%Address{} = address, extra_bindings, territory_code)
when is_map(extra_bindings) do
if @template_data do
do_format(address, String.upcase(territory_code), @template_data, extra_bindings)
else
{:error, "address templates not compiled; run: mix localize.address.download_templates"}
end
end
defp do_format(address, territory_code, data, extra_bindings \\ %{}) do
# Build initial bindings to check for territory remapping
initial_bindings =
build_bindings(address)
|> Map.merge(extra_bindings)
# Remap dependent territories (e.g., NL→CW for Curaçao, CN→MO for Macau)
{territory_code, initial_bindings} = remap_territory(territory_code, initial_bindings, data)
country_config = resolve_country_config(territory_code, data)
bindings =
initial_bindings
|> apply_add_component_bindings(country_config)
|> apply_change_country_bindings(country_config)
|> apply_component_aliases(data)
|> sanitize_components()
|> sanitize_postcode()
|> apply_component_replace(country_config)
|> normalize_washington_dc(territory_code)
|> add_state_code(territory_code, data)
|> add_county_code(territory_code, data)
|> apply_attention(data)
# Use fallback template when minimal components (road + postcode) are missing
{template, first_of} =
if minimal_components?(bindings) do
{country_config.address_template, country_config.address_first_of}
else
fallback_t = country_config.fallback_template
fallback_fo = country_config.fallback_first_of
if fallback_t && fallback_t != "" do
{fallback_t, fallback_fo || %{}}
else
{country_config.address_template, country_config.address_first_of}
end
end
result = render_template(template, first_of, bindings)
result =
if empty_result?(result) do
{alt_template, alt_first_of} =
if minimal_components?(bindings) do
{country_config.fallback_template, country_config.fallback_first_of}
else
{country_config.address_template, country_config.address_first_of}
end
render_template(alt_template, alt_first_of, bindings)
else
result
end
result = apply_replace(result, country_config.replace)
result = clean_output(result)
result = apply_postformat_replace(result, country_config.postformat_replace)
result = clean_output(result)
{:ok, result}
end
# ── Territory remapping ─────────────────────────────────────────
# Remap dependent territories based on state/region values.
# Matches the Perl reference's _determine_country_code behavior.
# Netherlands: Curaçao, Aruba, Sint Maarten → use their own templates
defp remap_territory("NL", bindings, _data) do
state = Map.get(bindings, "state", "")
cond do
String.downcase(state) == "curaçao" or String.downcase(state) == "curacao" ->
{"CW", Map.put(bindings, "country", "Curaçao")}
String.match?(state, ~r/^aruba/i) ->
{"AW", Map.put(bindings, "country", "Aruba")}
String.match?(state, ~r/^sint maarten/i) ->
{"SX", Map.put(bindings, "country", "Sint Maarten")}
String.match?(state, ~r/^bonaire/i) ->
{"BQ", Map.put(bindings, "country", state)}
true ->
{"NL", bindings}
end
end
# China: Macau and Hong Kong SARs should use a standard (non-reversed)
# template. Remap to a dummy code that falls through to the default
# template, while setting the region/SAR name as a visible component.
defp remap_territory("CN", bindings, _data) do
region = Map.get(bindings, "region", "")
state = Map.get(bindings, "state", "")
cond do
String.match?(region, ~r/^macau/i) or String.match?(state, ~r/^macau/i) ->
sar_name = if region != "", do: region, else: state
bindings =
bindings
|> Map.put("country", "China")
|> Map.put("city", sar_name)
|> Map.delete("region")
# Use default template (no CN-specific reverse ordering)
{"_DEFAULT", bindings}
String.match?(region, ~r/^hong kong/i) or String.match?(state, ~r/^hong kong/i) ->
sar_name = if region != "", do: region, else: state
bindings =
bindings
|> Map.put("country", "China")
|> Map.put("city", sar_name)
|> Map.delete("region")
{"_DEFAULT", bindings}
true ->
{"CN", bindings}
end
end
defp remap_territory(territory_code, bindings, _data), do: {territory_code, bindings}
# ── Country config resolution ──────────────────────────────────
defp resolve_country_config(territory_code, data) do
case Map.get(data.countries, territory_code) do
nil ->
data.default
%{use_country: use_code} = config when is_binary(use_code) ->
parent_config = resolve_country_config(String.upcase(use_code), data)
Map.merge(parent_config, config, fn
_key, parent_value, nil -> parent_value
_key, parent_value, [] -> parent_value
_key, parent_value, %{} -> parent_value
_key, _parent_value, child_value -> child_value
end)
config ->
config
end
end
# ── Pre-processing (operates on bindings map) ───────────────────
# Apply add_component rules to the bindings map. Only sets values
# for keys that are not already present.
defp apply_add_component_bindings(bindings, %{add_component: components})
when is_list(components) do
Enum.reduce(components, bindings, fn
component, acc when is_map(component) ->
Enum.reduce(component, acc, fn {field, value}, inner_acc ->
Map.put_new(inner_acc, field, value)
end)
_, acc ->
acc
end)
end
defp apply_add_component_bindings(bindings, _config), do: bindings
# Apply change_country by interpolating $component references in
# the country binding value. Operates on bindings so it has access
# to all component values including extra_bindings.
defp apply_change_country_bindings(bindings, %{change_country: replacement})
when is_binary(replacement) do
new_country =
Regex.replace(~r/\$(\w+)/, replacement, fn _full, component ->
Map.get(bindings, component, "")
end)
Map.put(bindings, "country", new_country)
end
defp apply_change_country_bindings(bindings, _config), do: bindings
# ── Binding construction ───────────────────────────────────────
defp build_bindings(%Address{} = address) do
address
|> Map.from_struct()
|> Map.delete(:raw_input)
|> Map.delete(:territory)
|> Map.delete(:territory_code)
|> Map.put(:country, address.territory)
|> Map.put(:country_code, address.territory_code)
|> Enum.reject(fn {_key, value} -> is_nil(value) || value == "" end)
|> Enum.map(fn {key, value} -> {Atom.to_string(key), value} end)
|> Map.new()
end
# Resolve component aliases: if a binding has an alias name,
# also set the canonical name (e.g., "suburb" -> also set "neighbourhood")
defp apply_component_aliases(bindings, data) do
aliases = Map.get(data, :component_aliases, %{})
Enum.reduce(bindings, bindings, fn {key, value}, acc ->
case Map.get(aliases, key) do
nil -> acc
^key -> acc
canonical -> Map.put_new(acc, canonical, value)
end
end)
end
# Clean component values per the Perl reference's _sanity_cleaning:
# reject postcodes that are too long or contain semicolons, and
# remove values that contain URLs or other obvious garbage.
defp sanitize_components(bindings) do
bindings
|> Map.new(fn {key, value} ->
if is_binary(value) && String.match?(value, ~r{https?://}) do
{key, ""}
else
{key, value}
end
end)
|> Map.reject(fn {_key, value} -> value == "" end)
end
defp sanitize_postcode(bindings) do
case Map.get(bindings, "postcode") do
nil ->
bindings
postcode ->
cond do
String.length(postcode) > 20 ->
Map.delete(bindings, "postcode")
String.match?(postcode, ~r/\d+;\d+/) ->
Map.delete(bindings, "postcode")
match = Regex.run(~r/^(\d{5}),\d{5}/, postcode) ->
Map.put(bindings, "postcode", Enum.at(match, 1))
true ->
bindings
end
end
end
# Washington DC is both a city and a state. When the state field contains
# "Washington DC" or "Washington, D.C.", split it into city + state_code.
defp normalize_washington_dc(bindings, territory_code)
when territory_code in ["US", "VI", "GU", "AS", "MP", "PR"] do
case Map.get(bindings, "state") do
"Washington DC" ->
bindings
|> Map.put_new("city", "Washington")
|> Map.put("state_code", "DC")
|> Map.put("state", "District of Columbia")
"Washington, D.C." ->
bindings
|> Map.put_new("city", "Washington")
|> Map.put("state_code", "DC")
|> Map.put("state", "District of Columbia")
"District of Columbia" ->
bindings
|> Map.put_new("city", "Washington")
|> Map.put("state_code", "DC")
_ ->
bindings
end
end
defp normalize_washington_dc(bindings, _territory_code), do: bindings
# Derive state_code from state name using reverse lookup.
# Also checks the parent country's codes if the territory uses use_country.
defp add_state_code(bindings, territory_code, data) do
state_codes =
get_in_data(data, [:state_codes, territory_code]) ||
get_parent_codes(territory_code, data, :state_codes)
cond do
Map.has_key?(bindings, "state_code") ->
# Already has state_code; also populate state name if missing
bindings = populate_state_name(bindings, state_codes, territory_code)
bindings
Map.has_key?(bindings, "state") ->
state_name = bindings["state"]
# Try OpenCageData state_codes first. Only use Localize fallback
# when the territory has state_codes data (meaning it actually
# uses abbreviated state codes in addresses).
code =
if state_codes do
reverse_lookup(state_codes, state_name) ||
subdivision_code_from_name(territory_code, state_name)
end
if code do
Map.put(bindings, "state_code", code)
else
bindings
end
true ->
bindings
end
end
# If state_code is set but state name is not, look up the full name
defp populate_state_name(bindings, state_codes, territory_code) do
if Map.has_key?(bindings, "state") do
bindings
else
code = bindings["state_code"]
name =
if state_codes do
case Map.get(state_codes, code) do
name when is_binary(name) -> name
%{"default" => name} -> name
_ -> nil
end
end
name = name || subdivision_name_from_code(territory_code, code)
if name, do: Map.put(bindings, "state", name), else: bindings
end
end
# Use Localize to look up subdivision name from code
defp subdivision_name_from_code(territory_code, code) when is_binary(code) do
subdivision_atom =
String.to_atom(String.downcase(territory_code) <> String.downcase(code))
case Localize.Territory.Subdivision.display_name(subdivision_atom, locale: :en) do
{:ok, name} -> name
_ -> nil
end
rescue
_ -> nil
end
# Use Localize to find subdivision code from name
defp subdivision_code_from_name(territory_code, name) when is_binary(name) do
territory_lower = String.downcase(territory_code)
prefix_len = String.length(territory_lower)
upper_name = String.upcase(name)
Localize.Territory.Subdivision.known_subdivisions()
|> Enum.find_value(fn {sub_atom, _} ->
sub_str = Atom.to_string(sub_atom)
if String.starts_with?(sub_str, territory_lower) do
code_part = String.slice(sub_str, prefix_len..-1//1) |> String.upcase()
case Localize.Territory.Subdivision.display_name(sub_atom, locale: :en) do
{:ok, sub_name} ->
if String.upcase(sub_name) == upper_name, do: code_part
_ ->
nil
end
end
end)
rescue
_ -> nil
end
# Derive county_code from county name using reverse lookup
defp add_county_code(bindings, territory_code, data) do
county_codes =
get_in_data(data, [:county_codes, territory_code]) ||
get_parent_codes(territory_code, data, :county_codes)
cond do
Map.has_key?(bindings, "county_code") ->
bindings
county_codes && Map.has_key?(bindings, "county") ->
county_name = bindings["county"]
code = reverse_lookup(county_codes, county_name)
if code, do: Map.put(bindings, "county_code", code), else: bindings
true ->
bindings
end
end
# Collect unknown/POI components into "attention" field
# All known component names plus their aliases. Components not in this set
# are collected into the "attention" field for POI display.
@known_components (if @template_data do
@template_data.component_aliases
|> Map.keys()
|> MapSet.new()
|> MapSet.union(
MapSet.new([
"house_number",
"house",
"road",
"neighbourhood",
"city",
"municipality",
"county",
"state_district",
"state",
"postcode",
"country",
"country_code",
"territory",
"territory_code",
"island",
"archipelago",
"continent",
"state_code",
"county_code",
"attention"
])
)
else
MapSet.new()
end)
defp apply_attention(bindings, _data) do
if Map.has_key?(bindings, "attention") do
bindings
else
unknown_values =
bindings
|> Enum.reject(fn {key, _} -> MapSet.member?(@known_components, key) end)
|> Enum.map(fn {_, value} -> value end)
|> Enum.reject(&(&1 == "" || is_nil(&1)))
case unknown_values do
[] -> bindings
values -> Map.put(bindings, "attention", Enum.join(values, ", "))
end
end
end
# Apply replace rules to component values. Rules with "key=" prefix
# apply only to the named component. Rules without a prefix apply
# to ALL component values (the Perl reference behavior).
defp apply_component_replace(bindings, country_config) do
replacements = Map.get(country_config, :replace, [])
Enum.reduce(replacements, bindings, fn {pattern, replacement}, acc ->
case Regex.named_captures(~r/^(?<key>\w+)=(?<re>.+)$/, pattern) do
%{"key" => key, "re" => regex_str} ->
apply_regex_to_key(acc, key, regex_str, replacement)
_ ->
apply_regex_to_all(acc, pattern, replacement)
end
end)
end
defp apply_regex_to_key(bindings, key, regex_str, replacement) do
if Map.has_key?(bindings, key) do
case Regex.compile(regex_str, [:caseless]) do
{:ok, regex} ->
new_value = Regex.replace(regex, bindings[key], convert_backreferences(replacement))
Map.put(bindings, key, new_value)
{:error, _} ->
bindings
end
else
bindings
end
end
defp apply_regex_to_all(bindings, pattern, replacement) do
case Regex.compile(pattern, [:caseless]) do
{:ok, regex} ->
converted = convert_backreferences(replacement)
Map.new(bindings, fn {key, value} ->
if is_binary(value) do
{key, Regex.replace(regex, value, converted)}
else
{key, value}
end
end)
{:error, _} ->
bindings
end
end
# ── Template rendering ─────────────────────────────────────────
defp render_template(nil, _first_of, _bindings), do: ""
defp render_template(template, first_of, bindings) do
resolved_bindings = resolve_first_of(first_of, bindings)
all_bindings = Map.merge(bindings, resolved_bindings)
Regex.replace(~r/\{\$(\w+)\}/, template, fn _full, var_name ->
Map.get(all_bindings, var_name, "")
end)
end
defp resolve_first_of(first_of, bindings) when is_map(first_of) do
for {var_name, candidates} <- first_of, into: %{} do
value =
Enum.find_value(candidates, "", fn candidate ->
resolve_candidate(candidate, bindings)
end)
{var_name, value}
end
end
defp resolve_first_of(_, _bindings), do: %{}
# Simple candidate: single variable name
defp resolve_candidate(candidate, bindings) when is_binary(candidate) do
case Map.get(bindings, candidate) do
nil -> nil
"" -> nil
value -> value
end
end
# Compound candidate: a mini-template with multiple variables.
# Resolves all variables; returns nil if ANY required variable is missing.
defp resolve_candidate({:compound, template}, bindings) do
# Extract all variable names from the template
vars = Regex.scan(~r/\{\$(\w+)\}/, template) |> Enum.map(fn [_, name] -> name end)
# Check that at least one variable has a value
if Enum.any?(vars, fn var -> Map.get(bindings, var) not in [nil, ""] end) do
result =
Regex.replace(~r/\{\$(\w+)\}/, template, fn _full, var_name ->
Map.get(bindings, var_name, "")
end)
|> String.trim()
if result == "", do: nil, else: result
end
end
defp resolve_candidate(_, _bindings), do: nil
# ── Post-processing ────────────────────────────────────────────
# Apply replace rules that are NOT component-specific (no "key=" prefix)
defp apply_replace(text, replacements) when is_list(replacements) do
Enum.reduce(replacements, text, fn {pattern, replacement}, acc ->
if String.match?(pattern, ~r/^\w+=/) do
# Component-specific replace, already handled in apply_component_replace
acc
else
case Regex.compile(pattern) do
{:ok, regex} ->
Regex.replace(regex, acc, convert_backreferences(replacement))
{:error, _} ->
acc
end
end
end)
end
defp apply_replace(text, _), do: text
defp apply_postformat_replace(text, replacements) when is_list(replacements) do
Enum.reduce(replacements, text, fn {pattern, replacement}, acc ->
case Regex.compile(pattern) do
{:ok, regex} ->
Regex.replace(regex, acc, convert_backreferences(replacement))
{:error, _} ->
acc
end
end)
end
defp apply_postformat_replace(text, _), do: text
# OpenCageData uses Perl-style backreferences ($1, $2) but Elixir's
# Regex.replace/4 uses \\1, \\2. Convert at application time.
defp convert_backreferences(replacement) do
Regex.replace(~r/\$(\d+)/, replacement, "\\\\\\1")
end
defp clean_output(text) do
text
|> String.split("\n")
|> Enum.map(&clean_line/1)
|> Enum.reject(&(&1 == ""))
|> Enum.reject(&only_punctuation?/1)
|> deduplicate_lines()
|> Enum.join("\n")
|> String.trim()
end
defp clean_line(line) do
line
|> String.trim()
|> String.replace(~r/[ ]{2,}/, " ")
|> String.replace(~r/,\s*,+/, ",")
|> deduplicate_within_line()
|> String.replace(~r/^[,\s\-]+/, "")
|> String.replace(~r/[,\s\-]+$/, "")
|> String.trim()
end
# Remove duplicate comma-separated segments within a single line.
# E.g., "Alhambra, Ermita, Ermita" → "Alhambra, Ermita"
# Exception: "New York" is allowed to repeat (per Perl reference).
defp deduplicate_within_line(line) do
parts = String.split(line, ",") |> Enum.map(&String.trim/1)
{result, _seen} =
Enum.reduce(parts, {[], MapSet.new()}, fn part, {acc, seen} ->
normalized = String.downcase(part)
cond do
normalized == "" ->
{acc, seen}
normalized == "new york" ->
{acc ++ [part], seen}
MapSet.member?(seen, normalized) ->
{acc, seen}
true ->
{acc ++ [part], MapSet.put(seen, normalized)}
end
end)
Enum.join(result, ", ")
end
# Only remove CONSECUTIVE duplicate lines, not all duplicates globally.
# This preserves valid repetitions like "New York" (city) and "New York"
# (state) on non-adjacent lines, while removing true adjacent duplicates
# like "Berlin" appearing twice in a row.
defp deduplicate_lines(lines) do
lines
|> Enum.chunk_while(
nil,
fn line, prev ->
normalized = String.downcase(String.trim(line))
prev_normalized = if prev, do: String.downcase(String.trim(prev)), else: nil
if prev_normalized && normalized == prev_normalized do
{:cont, prev}
else
if prev, do: {:cont, prev, line}, else: {:cont, line}
end
end,
fn
nil -> {:cont, nil}
acc -> {:cont, acc, nil}
end
)
|> Enum.reject(&is_nil/1)
end
defp only_punctuation?(line) do
String.match?(line, ~r/^[\s,;.\-]+$/)
end
# The reference Perl implementation uses the fallback template only
# when BOTH "road" and "postcode" are missing. If at least one is
# present, the main address template is used.
defp minimal_components?(bindings) do
has_road? =
Map.has_key?(bindings, "road") || Map.has_key?(bindings, "street") ||
Map.has_key?(bindings, "pedestrian")
has_postcode? = Map.has_key?(bindings, "postcode")
has_road? or has_postcode?
end
defp empty_result?(text) do
text
|> String.replace(~r/[\s,;.\-]/, "")
|> String.trim()
|> String.length() == 0
end
# ── Helpers ────────────────────────────────────────────────────
defp get_parent_codes(territory_code, data, codes_key) do
case get_in_data(data, [:countries, territory_code, :use_country]) do
nil -> nil
parent_code -> get_in_data(data, [codes_key, String.upcase(parent_code)])
end
end
defp reverse_lookup(codes_map, name) when is_map(codes_map) and is_binary(name) do
upper_name = String.upcase(name)
Enum.find_value(codes_map, fn {code, full_name} ->
cond do
is_binary(full_name) && String.upcase(full_name) == upper_name ->
code
is_map(full_name) ->
names = Map.values(full_name)
if Enum.any?(names, fn n -> is_binary(n) && String.upcase(n) == upper_name end) do
code
end
true ->
nil
end
end)
end
defp reverse_lookup(_, _), do: nil
defp get_in_data(data, keys) do
Enum.reduce_while(keys, data, fn key, acc ->
case acc do
%{} = map -> {:cont, Map.get(map, key)}
_ -> {:halt, nil}
end
end)
end
end