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.ex
Raw

lib/localize/address.ex

defmodule Localize.Address do
@moduledoc """
Parses unstructured address strings and formats structured
addresses into locale-appropriate string representations.
Address parsing is powered by libpostal via NIF. Address formatting
uses templates from the OpenCageData address-formatting project,
compiled to Erlang term format.
The primary functions are `parse/2` for parsing raw address strings
and `to_string/2` for formatting an `Address` struct.
"""
alias Localize.Address.Address
alias Localize.Address.Formatter
alias Localize.Address.Nif
alias Localize.Address.Territory
@type t :: Address.t()
@titlecase_fields [
:attention,
:house,
:road,
:neighbourhood,
:city,
:municipality,
:county,
:state_district,
:state,
:territory,
:island,
:archipelago,
:continent
]
@libpostal_label_map %{
"house" => :attention,
"house_number" => :house_number,
"road" => :road,
"suburb" => :neighbourhood,
"city_district" => :neighbourhood,
"city" => :city,
"state" => :state,
"state_district" => :state_district,
"postcode" => :postcode,
"country" => :territory,
"country_region" => :state,
"island" => :island,
"neighbourhood" => :neighbourhood,
"village" => :city,
"municipality" => :municipality,
"county" => :county
}
@doc """
Returns whether the NIF backend is available.
### Returns
* `true` if the NIF shared library was loaded successfully.
* `false` if the NIF is not compiled or libpostal is missing.
### Examples
iex> is_boolean(Localize.Address.available?())
true
"""
@spec available?() :: boolean()
defdelegate available?(), to: Nif
@doc """
Parses an unstructured address string into a `Localize.Address.Address` struct.
Uses libpostal's machine-learning models to identify address
components such as house number, road, city, state, and postcode.
### Arguments
* `address_string` is the unstructured address string to parse.
### Options
* `:territory` is an explicit ISO 3166-1 alpha-2 territory code
(e.g., `"US"`, `"GB"`, or an atom like `:US`). Used as the
territory code on the resulting struct and as context for parsing.
* `:locale` is a locale identifier used to derive the territory.
Accepts a string (e.g., `"en-US"`), an atom (e.g., `:en_US`), or a
`Localize.LanguageTag.t()` struct.
When neither `:territory` nor `:locale` is given, the territory is
derived from `Localize.get_locale/0`.
### Returns
* `{:ok, address}` where `address` is a `Localize.Address.Address` struct.
* `{:error, reason}` if the address cannot be parsed.
### Examples
iex> {:ok, address} = Localize.Address.parse("301 Hamilton Avenue, Palo Alto, CA 94303")
iex> address.house_number
"301"
iex> {:ok, address} = Localize.Address.parse("10 Downing Street, London", territory: "GB")
iex> address.territory_code
"GB"
"""
@spec parse(String.t(), keyword()) :: {:ok, t()} | {:error, String.t()}
def parse(address_string, options \\ []) when is_binary(address_string) do
territory_code = Territory.resolve(options)
case Nif.parse(address_string, "") do
{:ok, components} ->
address = build_address(components, address_string, territory_code)
address =
if Keyword.get(options, :capitalize, false) do
capitalize(address, options)
else
address
end
{:ok, address}
{:error, reason} ->
{:error, reason}
end
end
@doc """
Capitalizes the text fields of an address struct.
Applies Unicode-aware titlecase to place names, street names, and
administrative region fields. Postcodes are uppercased. House numbers,
territory codes, and the raw input string are left unchanged.
### Arguments
* `address` is a `Localize.Address.Address` struct.
### Options
* `:locale` is a locale identifier passed through to
`Unicode.String.titlecase/2` for locale-specific casing rules
(e.g., Dutch "ij" → "IJ", Turkish dotted-I handling).
### Returns
* A new `Localize.Address.Address` struct with capitalized fields.
### Examples
iex> address = %Localize.Address.Address{
...> road: "hamilton avenue",
...> city: "palo alto",
...> postcode: "sw1a 2aa"
...> }
iex> capitalized = Localize.Address.capitalize(address)
iex> capitalized.road
"Hamilton Avenue"
iex> capitalized.city
"Palo Alto"
iex> capitalized.postcode
"SW1A 2AA"
"""
@spec capitalize(t(), keyword()) :: t()
def capitalize(%Address{} = address, options \\ []) do
locale = Keyword.get(options, :locale)
titlecase_options = if locale, do: [locale: locale], else: []
updated =
Enum.reduce(@titlecase_fields, address, fn field, acc ->
case Map.get(acc, field) do
nil -> acc
"" -> acc
value -> Map.put(acc, field, capitalize_value(value, titlecase_options))
end
end)
case updated.postcode do
nil -> updated
"" -> updated
postcode -> %{updated | postcode: String.upcase(postcode)}
end
end
# Directional abbreviations common in US addresses that should
# remain uppercase after titlecasing.
@directional_pattern ~r/\b(Nw|Ne|Sw|Se)\b/
# Short values without spaces (e.g., "ca", "nsw", "fi") are
# abbreviations and should be uppercased. Longer values are
# titlecased with directional abbreviations corrected.
defp capitalize_value(value, titlecase_options) do
if String.length(value) <= 3 and not String.contains?(value, " ") do
String.upcase(value)
else
value
|> Unicode.String.titlecase(titlecase_options)
|> upcase_directionals()
end
end
defp upcase_directionals(value) do
Regex.replace(@directional_pattern, value, fn match, _ -> String.upcase(match) end)
end
@doc """
Formats an address struct into a locale-appropriate string.
Uses OpenCageData address formatting templates to produce a
properly formatted address for the territory associated with
the address.
### Arguments
* `address` is a `Localize.Address.Address` struct.
### Options
* `:territory` overrides the territory code used for template
selection. Defaults to `address.territory_code`.
### 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.to_string(address)
iex> is_binary(formatted)
true
"""
@spec to_string(t(), keyword()) :: {:ok, String.t()} | {:error, String.t()}
def to_string(%Address{} = address, options \\ []) do
territory_code =
case Keyword.get(options, :territory) do
nil -> address.territory_code || Territory.resolve(options)
territory -> Territory.resolve(territory: territory)
end
Formatter.format(address, territory_code)
end
defp build_address(components, raw_input, territory_code) do
fields =
Enum.reduce(components, %{}, fn {label, value}, acc ->
case Map.get(@libpostal_label_map, label) do
nil ->
acc
field ->
Map.update(acc, field, value, fn existing ->
existing <> " " <> value
end)
end
end)
struct(
Address,
Map.merge(fields, %{
raw_input: raw_input,
territory_code: territory_code
})
)
end
end