defmodule AddressFormatting do @moduledoc """ Documentation for `AddressFormatting`. """ alias AddressFormatting.Address alias AddressFormatting.Constants def render(variables) when is_map(variables) do case get_template(variables) do {nil, _, _} -> variables |> Map.delete("country_code") |> Map.values() |> Enum.join("\n") |> Kernel.<>("\n") data -> render(data) end end @spec render({String.t(), Address.t() | map, list}) :: String.t() def render({template, address, postformat}) do template |> :bbmustache.compile(address, key_type: :atom) |> String.trim() |> run_postformat(postformat) |> fix_duplicates() |> Kernel.<>("\n") end def fix_duplicates(string) do string |> String.split("\n") |> List.foldr([], fn current, acc -> previous = List.first(acc) if current == previous do acc else [current | acc] end end) |> Enum.map_join("\n", &deduplicate_csv/1) end # Remove consecutive duplicate segments within a comma-separated line. # e.g. "Limón, Limón, Barrio Hospital" → "Limón, Barrio Hospital" defp deduplicate_csv(line) do line |> String.split(", ") |> List.foldr([], fn part, acc -> if List.first(acc) == part, do: acc, else: [part | acc] end) |> Enum.join(", ") end def run_postformat(rendered, postformat) do Enum.reduce(postformat, rendered, fn [from, to], rendered_acc -> Regex.replace(from, rendered_acc, to) end) end def run_replace(address, country_data) do address_new = country_data |> Map.get("replace", []) |> Enum.reduce(address, fn [field_from, to], acc -> to_sub = String.replace(to, "$", "\\") case String.split(field_from, "=") do [from_pattern] -> # No field specified — apply regex to every string field regex = Regex.compile!(from_pattern) acc |> Map.from_struct() |> Enum.reduce(acc, fn {k, v}, inner_acc -> if is_binary(v) do Map.put(inner_acc, k, Regex.replace(regex, v, to_sub)) else inner_acc end end) ["country_code", from] -> case Map.get(acc, :country_code) do nil -> acc ^from -> Map.put(acc, :country_code, to) _ -> acc end [field, from] -> atom_field = String.to_existing_atom(field) regex = Regex.compile!(from) case Map.get(acc, atom_field) do nil -> acc value -> Map.put(acc, atom_field, Regex.replace(regex, value, to_sub)) end end end) {:ok, address_new} end def get_template(variables) do country_code = variables |> Map.get("country_code") |> upcase() case Map.get(Constants.worldwide_data(), country_code) do nil -> {:ok, address} = convert_component_aliases(%Address{}, variables) { Constants.fallback_template(), address, default_postformat_regex() } country_data -> with {:ok, address} <- convert_component_aliases(%Address{}, variables), {:ok, address} <- check_country_case(address, variables, country_data), {:ok, address} <- convert_constants(address), {:ok, address} <- convert_country_numeric(address), {:ok, address} <- run_replace(address, country_data), {:ok, address} <- check_use_country(address, country_data), {:ok, address} <- add_component(address, country_data), {:ok, address} <- check_change_country(address, country_data), {:ok, address} <- add_codes(address, :county_code), {:ok, address} <- add_codes(address, :state_code) do template_key = String.upcase(address.country_code) final_country_data = Map.get(Constants.worldwide_data(), template_key) || country_data {:ok, postformat} = get_postformat(final_country_data) template = if use_fallback?(address) do Map.get(Constants.worldwide_fallback_templates(), template_key) || Constants.fallback_template() else Map.get(Constants.worldwide_templates(), template_key) || Constants.fallback_template() end {template, address, postformat} end end end defp use_fallback?(address) do address.road == nil && address.postcode == nil end def default_postformat_regex do [ [~r/\'\;/, "'"], [~r/[\},\s]+$/, ""], [~r/^[,\s]+/, ""], # linestarting with dash due to a parameter missing [~r/^- /, ""], [~r/\n\h-/, "\n"], # multiple commas to one [~r/,\s*,/, ", "], # one horiz whitespace behind comma [~r/\h+,\h+/, ", "], # multiple horiz whitespace to one [~r/\h\h+/, " "], # horiz whitespace, newline to newline [~r/\h\n/, "\n"], # newline comma to just newline [~r/\n,/, "\n"], # multiple commas to one [~r/,,+/, ","], # comma newline to just newline [~r/,\n/, "\n"], # newline plus space to newline [~r/\n\h+/, "\n"], [~r/\n\n+/, "\n"] ] end def get_postformat(country_data) do postformat = case Map.get(country_data, "postformat_replace") do nil -> default_postformat_regex() postformat_regex_list -> country_rules = Enum.map(postformat_regex_list, fn [from, to] -> [Regex.compile!(from), String.replace(to, "$", "\\")] end) # Run defaults first, then country rules, then a final whitespace cleanup # pass so that country substitutions don't leave stray spaces on lines. default_postformat_regex() ++ country_rules ++ [[~r/\h\n/, "\n"], [~r/\n\h+/, "\n"]] end {:ok, postformat} end def add_component(address, country_data) do case Map.get(country_data, "add_component") do nil -> {:ok, address} add_component -> case String.split(add_component, "=") do ["country_code", value] -> {:ok, %{address | country_code: value}} [field, value] -> {:ok, Map.put(address, to_existing_atom(field, :attention), value)} end end end def convert_component_aliases(address, variables) do # Override: upstream components.yaml maps "district" → neighbourhood, but # the reference implementation treats it as state_district. components = Map.put(Constants.components(), :district, :state_district) address = Enum.reduce(variables, address, fn {key, v}, acc -> atom_key = safe_to_atom(key) cond do atom_key == nil -> %{acc | attention: v} Map.has_key?(acc, atom_key) -> # Field exists directly in struct — set it, then populate canonical if nil new_value = adjust_value(v, atom_key) acc = Map.put(acc, atom_key, new_value) case Map.get(components, atom_key) do nil -> acc ^atom_key -> # Already canonical acc canonical_key -> if Map.get(acc, canonical_key) == nil do Map.put(acc, canonical_key, adjust_value(v, canonical_key)) else acc end end true -> # Not a direct struct field — resolve through alias map case Map.get(components, atom_key) do nil -> %{acc | attention: v} canonical_key -> Map.put(acc, canonical_key, adjust_value(v, canonical_key)) end end end) {:ok, address} end def convert_constants(address) do address = case Map.get(address, :country_code) do "UK" -> Map.put(address, :country_code, "GB") "NL" -> state = Map.get(address, :state) cond do state == nil -> address state == "Curaçao" -> %{address | country: "Curaçao", country_code: "CW"} String.downcase(state) =~ "sint maarten" -> %{address | country: "Sint Maarten", country_code: "SX"} String.downcase(state) =~ "aruba" -> %{address | country: "Aruba", country_code: "AW"} true -> address end _other -> address end {:ok, address} end def convert_country_numeric(address) do address = if is_integer?(address.country) do %{address | country: address.state, state: nil} else address end {:ok, address} end def add_codes(address, :state_code) do address = if address.state_code == nil do country_code = address.country_code case lookup_code(Constants.get_codes_dict("state"), country_code, address.state) do nil -> address code -> %{address | state_code: code} end else address end {:ok, address} end def add_codes(address, :county_code) do address = if address.county_code == nil do country_code = address.country_code case lookup_code(Constants.get_codes_dict("county"), country_code, address.county) do nil -> address code -> %{address | county_code: code} end else address end {:ok, address} end defp lookup_code(_codes_map, nil, _name), do: nil defp lookup_code(_codes_map, _country_code, nil), do: nil defp lookup_code(codes_map, country_code, name) do country_codes = Map.get(codes_map, country_code, %{}) name_lower = String.downcase(name) Enum.find_value(country_codes, fn {k, v} when is_binary(k) -> if String.downcase(k) == name_lower, do: v _ -> nil end) end def check_change_country(address, country_data) do case Map.get(country_data, "change_country") do nil -> {:ok, address} change_country -> country = Regex.replace(~r/\$([a-z_]+)/, change_country, fn _, field -> Map.get(address, String.to_existing_atom(field)) || "" end) {:ok, %{address | country: country}} end end def check_country_case(address, variables, _country_data) do country_code = Map.get(variables, "country_code") {:ok, Map.put(address, :country_code, String.upcase(country_code))} end def check_use_country(address, country_data) do case Map.get(country_data, "use_country") do nil -> {:ok, address} country_code -> {:ok, %{address | country_code: String.upcase(country_code)}} end end def upcase(nil), do: nil def upcase(string), do: String.upcase(string) def is_integer?(val) when is_integer(val), do: true def is_integer?(val) when is_bitstring(val) do Regex.match?(~r{\A\d+\z}, val) end def is_integer?(_), do: false def adjust_value(value, :postcode) when is_bitstring(value) and byte_size(value) > 9, do: nil def adjust_value(value, _key), do: value def to_existing_atom(key, _) when is_atom(key), do: key def to_existing_atom(key, default) do String.to_existing_atom(key) rescue _e -> IO.puts("#{key}: is not an existing atom") default end defp safe_to_atom(key) when is_atom(key), do: key defp safe_to_atom(key) do String.to_existing_atom(key) rescue _ -> IO.puts("#{key}: is not an existing atom") nil end end