From 19e03fac4a137507e1874858e1002f9abd26c28c Mon Sep 17 00:00:00 2001 From: Kip Cole Date: Tue, 23 Apr 2024 16:50:15 +1000 Subject: [PATCH] Add docs --- .github/workflows/ci.yml | 106 ++++++++ CHANGELOG.md | 2 +- README.md | 74 +++++- lib/cldr/person_name.ex | 421 +++++++++++++++++++++++++++--- lib/cldr/person_name/formatter.ex | 31 ++- lib/cldr/protocol/string_chars.ex | 6 + mix.exs | 4 +- test/cldr_chars_test.exs | 17 +- 8 files changed, 615 insertions(+), 46 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 lib/cldr/protocol/string_chars.ex diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..e6d60ac --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,106 @@ +name: Elixir CI + +# Define workflow that runs when changes are pushed to the +# `main` branch or pushed to a PR branch that targets the `main` +# branch. Change the branch name if your project uses a +# different name for the main branch like "master" or "production". +on: + push: + branches: [ "main" ] # adapt branch for project + pull_request: + branches: [ "main" ] # adapt branch for project + +# Sets the ENV `MIX_ENV` to `test` for running tests +env: + MIX_ENV: test + +permissions: + contents: read + +jobs: + test: + # Set up a Postgres DB service. By default, Phoenix applications + # use Postgres. This creates a database for running tests. + # Additional services can be defined here if required. + # services: + # db: + # image: postgres:12 + # ports: ['5432:5432'] + # env: + # POSTGRES_PASSWORD: postgres + # options: >- + # --health-cmd pg_isready + # --health-interval 10s + # --health-timeout 5s + # --health-retries 5 + + runs-on: ubuntu-latest + name: Test on OTP ${{matrix.otp}} / Elixir ${{matrix.elixir}} + strategy: + # Specify the OTP and Elixir versions to use when building + # and running the workflow steps. + matrix: + otp: ['26.2'] # Define the OTP version [required] + elixir: ['1.16.2-otp-26'] # Define the elixir version [required] + steps: + # Step: Setup Elixir + Erlang image as the base. + - name: Set up Elixir + uses: erlef/setup-beam@v1 + with: + otp-version: ${{matrix.otp}} + elixir-version: ${{matrix.elixir}} + + # Step: Check out the code. + - name: Checkout code + uses: actions/checkout@v3 + + # Step: Define how to cache deps. Restores existing cache if present. + - name: Cache deps + id: cache-deps + uses: actions/cache@v3 + env: + cache-name: cache-elixir-deps + with: + path: deps + key: ${{ runner.os }}-mix-${{ env.cache-name }}-${{ hashFiles('**/mix.lock') }} + restore-keys: | + ${{ runner.os }}-mix-${{ env.cache-name }}- + + # Step: Define how to cache the `_build` directory. After the first run, + # this speeds up tests runs a lot. This includes not re-compiling our + # project's downloaded deps every run. + - name: Cache compiled build + id: cache-build + uses: actions/cache@v3 + env: + cache-name: cache-compiled-build + with: + path: _build + key: ${{ runner.os }}-mix-${{ env.cache-name }}-${{ hashFiles('**/mix.lock') }} + restore-keys: | + ${{ runner.os }}-mix-${{ env.cache-name }}- + ${{ runner.os }}-mix- + + # Step: Download project dependencies. If unchanged, uses + # the cached version. + - name: Install dependencies + run: mix deps.get + + # Step: Compile the project treating any warnings as errors. + # Customize this step if a different behavior is desired. + - name: Compiles without warnings + run: mix compile --warnings-as-errors + + # Step: Check that the checked in code has already been formatted. + # This step fails if something was found unformatted. + # Customize this step as desired. + # - name: Check Formatting + # run: mix format --check-formatted + + # Step: Execute the tests. + - name: Run tests + run: mix test + + # Step: Execute dialyzer. + - name: Run dialyzer + run: mix dialyzer diff --git a/CHANGELOG.md b/CHANGELOG.md index 93968fc..d6bcf34 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ ## Cldr Person Names v0.1.0 -This is the changelog for `:ex_cldr_person_names` v0.1.0 released on ____, 2023. For older changelogs please consult the release tag on [GitHub](https://github.com/elixir-cldr/cldr_person_names/tags) +This is the changelog for `ex_cldr_person_names` v0.1.0 released on April 23rd, 2023. For older changelogs please consult the release tag on [GitHub](https://github.com/elixir-cldr/cldr_person_names/tags) ### Enhancements diff --git a/README.md b/README.md index 0c6059e..6b6e541 100644 --- a/README.md +++ b/README.md @@ -25,9 +25,9 @@ end Note the `:provider` configuration key which is required to include `Cldr.PersonName` in order for person name formatting to be configured for this backend. -## Installation +### Installation -Note that `:ex_cldr_person_names` requires Elixir 1.11 or later. +Note that `:ex_cldr_person_names` requires Elixir 1.12 or later. Add `ex_cldr_person_names` as a dependency to your `mix` project: @@ -42,12 +42,74 @@ then retrieve `ex_cldr_person_names` from [hex](https://hex.pm/packages/ex_cldr_ mix deps.get mix deps.compile -## Examples +## Presentations -### Cldr.PersonName struct +* Launch presentation at the [Elixir Sydney Meetup](https://www.youtube.com/watch?v=pBR-n_dA3lo) in February 2024. +* The slides from the launch presetnation are available in [Powerpoint format](https://github.com/elixir-cldr/cldr_person_names/raw/main/presentations/Person%20Name%20Formatting.pptx) and [PDF format](https://github.com/elixir-cldr/cldr_person_names/raw/main/presentations/Person%20Name%20Formatting.pdf) -### Formatting +The livebook used at the launch presentation is also available. -#### When the name locale differs from the formatting locale +[![Run in Livebook](https://livebook.dev/badge/v1/blue.svg)](https://livebook.dev/run?url=https%3A%2F%2Fraw.githubusercontent.com%2Felixir-cldr%2Fcldr_person_names%2Fmain%2Flivebooks%2Fperson_name_formatting_explorer.livemd) +## Why Person Name Formatting? + +`ex_cldr_person_names` provides formatting for person names, such as John Smith or 宮崎駿 based upon the [CLDR Person Names](https://www.unicode.org/reports/tr35/tr35-personNames.html) specification. These use patterns to show how a name object (for example, from a database) should be formatted for a particular locale. Name data has fields for the parts of people’s names, such as a given name field with a value of “Maria”, and a surname field value of “Schmidt”. +There is a wide variety in the way that people’s names appear in different languages. + +* People may have a different number of names, depending on their culture—they might have only one name (“Zendaya”), two (“Albert Einstein”), or three or more. +* People may have multiple words in a particular name field, eg “Mary Beth” as a given name, or “van Berg” as a surname. +* Some languages, such as Spanish, have two surnames (where each can be composed of multiple words). +* The ordering of name fields can be different across languages, as well as the spacing (or lack thereof) and punctuation. +* Name formatting needs to be adapted to different circumstances, such as a need to be presented shorter or longer; formal or informal context; or when talking about someone, or talking to someone, or as a monogram (JFK). + +The `ex_cldr_person_names` functionality is targeted at formatting names for typical usage on computers (e.g. contact names, automated greetings, etc.), rather than being designed for special circumstances or protocol, such addressing royalty. However, the structure may be enhanced in the future when it becomes clear that additional features are needed for some languages. + +### Not in scope + +The following features are currently out of scope for Person Names formating: + +* Grammatical inflection of formatted names. +* Context-specific cultural aspects, such as when to use “-san” vs “-sama” when addressing a Japanese person. +* Providing locale-specific lists of titles, generation terms, and credentials for use in pull-down menus or validation (Mr, Ms., Mx., Dr., Jr., M.D., etc.). +* Validation of input, such as which fields are required, and what characters are allowed. +* Combining alternative names, such as multicultural names in Hong Kong "Jackie Chan Kong-Sang”, or ‘Dwayne “The Rock” Johnson’. +* More than two levels of formality for names. +* Parsing of names. + * Parsing of name strings into specific name parts such as given and given2. A name like "Mary Beth Estrella" could conceivably be any of the following. + + | Given Name | Other Given Names | Surname | Other Surnames | + | ---------- | ----------------- | ------- | -------------- | + | Mary | Beth | Estrella | | + | Mary Beth | | Estrella | | + | Mary | | Beth Estrella | | + | Mary | | Beth | Estrella | + + * Parsing out the other components of a name in a string, such as surname prefixes (Tussenvoegsel in Dutch). + +## Structure of a Person Name + +Person name formatting depends on data supplied by a `t:Cldr.PersonName.t/0` data structure. A `Cldr.PersonName` behaviour and a `Cldr.PersonName.Format` protocol are provided to support easy integration with existing data structures. + +The `t:Cldr.PersonName.t/0` struct is composed of one or more name parts: + +* `title` - a string that represents one or more honorifics or titles, such as “Mr.”, or “Herr Doctor”. +* `given_name` - usually a name given to someone that is not passed to a person by way of parentage. +* `informal_given_name` - usually either a nickname or a shortened form of the given name that is used to address a person informally. +* `other_given_names` - name or names that may appear between the first given name string and the surname. In the West, this may be a middle name, in Slavic regions it may be a patronymic name, and in parts of the Middle East, it may be the nasab (نسب) or series of patronymics. +* `surname` - usually the family name passed to a person that indicates their family, tribe, or community. In most Western languages, this is known as the last name. +* `other_surnames` - in some cultures, both the parent’s surnames are used and need to be handled separately for formatting in different contexts. +* `generation` - a string that represents a generation marker, such as “Jr.” or “III”. +* `credentials` - a string that represents one or more credentials or accreditations, such as “M.D.”, or “MBA”. +* `locale` - defines the `t.Cldr.LanguageTag.t/0` of a name. This allows different formatting of a name depending on whether it is being formatted for its native locale, or for a different locale. +* `name_order` - an atom indicating the preferred name order for this name. The valid values are `:given_first`, `:surname_first`, `:sorting`. By default, `ex_cldr_person_names` will derive the name order based upon the name's locale and the formatting locale. + +**At mininum, a `given_name` is required. All other data elements are optional**. + +## Integration with Existing Data + +Its clear that existing person name data isn't going to be neatly structured in a `t:Cldr.PersonName.t/0`. `ex_cldr_person_names` provides two mechanisms to integrate existing data: + +* The `Cldr.PersonName` behaviour can be used when the developer has control over the data structure, and the data structure is an Elixir `struct`. This is the recommended approach when the developer has control over the struct module. In this case, [callbacks](Cldr.PersonName#callbacks) can be implemented for the `struct` that return the person name data to the formatter. The formatter implementation will call `Cldr.PersonName.cast_to_person_name/1` using the callbacks. + +* The `Cldr.PersonName.Format` protocol is useful when the developer has no control over the existing data structure. Therefore the `Cldr.PesonName.Format.to_string/2` function can be called and the protocol implementation is expected to craft a structure compatible with - or actually is - a `t:Cldr.PersonName.t/0` struct, and then calls the `Cldr.PersonName.to_string/2` function. Since `t:Cldr.PersonName.t/0` implements the `Cldr.PersonName.Format` protocol, `Cldr.PersonName.Format.to_string/2` can be used consistently throughout an application if preferred. diff --git a/lib/cldr/person_name.ex b/lib/cldr/person_name.ex index 8633189..de3314f 100644 --- a/lib/cldr/person_name.ex +++ b/lib/cldr/person_name.ex @@ -1,37 +1,35 @@ defmodule Cldr.PersonName do - @moduledoc """ - Cldr module to formats person names. - - """ + @readme Path.expand("README.md") + @moduledoc @readme |> File.read!() |> String.split("") |> List.last() |> String.trim() import Kernel, except: [to_string: 1] alias Cldr.PersonName.Formatter - @doc "Return the title as a string or nil for the given struct" + @doc "Return the title as a `t:String.t/0` or `nil` for the given struct" @callback title(name :: struct()) :: String.t() | nil @doc "Return the given name as a stringor nil for the given struct" @callback given_name(name :: struct()) :: String.t() | nil - @doc "Return the informal given name as a string or nil for the given struct" + @doc "Return the informal given name as a `t:String.t/0` or `nil` for the given struct" @callback informal_given_name(name :: struct()) :: String.t() | nil - @doc "Return the other given names as a string or nil for the given struct" + @doc "Return the other given names as a `t:String.t/0` or `nil` for the given struct" @callback other_given_names(name :: struct()) :: String.t() | nil - @doc "Return the surname prefix as a string or nil for the given struct" + @doc "Return the surname prefix as a `t:String.t/0` or `nil` for the given struct" @callback surname_prefix(name :: struct()) :: String.t() | nil - @doc "Return the surname as a string or nil for the given struct" + @doc "Return the surname as a `t:String.t/0` or `nil` for the given struct" @callback surname(name :: struct()) :: String.t() | nil - @doc "Return the other surnames as a string or nil for the given struct" + @doc "Return the other surnames as a `t:String.t/0` or `nil` for the given struct" @callback other_surnames(name :: struct()) :: String.t() | nil - @doc "Return the generation as a string or nil for the given struct" + @doc "Return the generation as a `t:String.t/0` or `nil` for the given struct" @callback generation(name :: struct()) :: String.t() | nil - @doc "Return the credentials as a string or nil for the given struct" + @doc "Return the credentials as a `t:String.t/0` or `nil` for the given struct" @callback credentials(name :: struct()) :: String.t() | nil @doc "Return the locale reference or nil for the given struct" @@ -77,15 +75,163 @@ defmodule Cldr.PersonName do locale: Cldr.Locale.locale_reference() } + @typedoc "Standard error response" @type error_message() :: String.t() | {module(), String.t()} - @spec new(options :: Keyword.t()) :: {:ok, t()} | {:error, error_message()} - def new(options \\ []) do - with {:ok, validated} <- validate_name(options) do - {:ok, struct(__MODULE__, validated)} - end + @doc """ + Returns a `t:Cldr.PersonName.t/0` struct crafted + from a keyword list of attributes. + + ### Arguments + + * `attributes` is a keyword list of person name + attributes that is used to contruct a `t:Cldr.PersonName.t/0`. + + ### Attributes + + * `:given_name` is a persons given name. This is a required + attribute. The value is any `t:String.t/0` + + * `:title` is a person's title such as "Mr," or "Dr.". + + * `:other_given_names` is any `t:String.t/0` or `nil`. The + default is `nil`. + + * `:informal_given_name` is any `t:String.t/0` or `nil`. The + default is `nil`. + + * `:surname` is any `t:String.t/0` or `nil`. The + default is `nil`. + + * `:other_surnames` is any `t:String.t/0` or `nil`. The + default is `nil`. + + * `:generation` is any `t:String.t/0` or `nil`. The + default is `nil`. + + * `:credentials` is any `t:String.t/0` or `nil`. The + default is `nil`. + + * `:locale` is any `t:Cldr.LanguageTag.t/0` or nil. The + default is `nil`. + + * `:backend` is any `Cldr` backend. That is, any module that + contains `use Cldr`. This is used to validate the `:locale` + only. The default is `Cldr.default_backend!/0`. + + * `:name_order` is one of `:given_name`, `:last_name` or + `:sorting`. The default is `nil`, meaning that the name order + is derived from the name's locale and the formatting locale. + + ### Returns + + * `{:ok, person_name_struct}` or + + * `{:error, reason}` + + ### Examples + + iex> Cldr.PersonName.new(title: "Mr.", given_name: "José", surname: "Valim", credentials: "Ph.D.", locale: "pt") + {:ok, + %Cldr.PersonName{ + title: "Mr.", + given_name: "José", + other_given_names: nil, + informal_given_name: nil, + surname_prefix: nil, + surname: "Valim", + other_surnames: nil, + generation: nil, + credentials: "Ph.D.", + preferred_order: nil, + locale: AllBackend.Cldr.Locale.new!("pt") + }} + + iex> Cldr.PersonName.new(surname: "Valim") + {:error, "Person Name requires at least a :given_name"} + + """ + + @spec new(attributes :: Keyword.t()) :: {:ok, t()} | {:error, error_message()} + def new(attributes \\ []) do + validate_name(attributes) end + @doc """ + Returns a formatted person name as an + `:erlang.iodata` term. + + ### Arguments + + * `person_name` is any struct that implements the + `Cldr.PersonName` behaviour, including the native + `t:Cldr.PersonName.t/0` struct. + + * `options` is a keyword list of options. + + ### Options + + * `:format` is the relative length of a formatted name + depending on context. For example, a long formal + name in English might include `:title`, `:given_name`, + `:other_given_names`, `:surname` plus `:generation` and + `:credentials`; whereas a short informal name may only be + the `:given_name`. The valid values are `:short`, `:medium` + and `:long`. The default is derived from the formatting + locales preferences. + + * `:usage` indicates if the formatted name is being used + to address someone, refer to someone, or present their + name in an abbreviated form. The valid values are`:referring`, + `:addressing` or `:monogram`. The default is `:referring`. The pattern + for `:referring` may be the same as the pattern for + `:addressing`. + + * `:formality` indicates the formality of usage. A name on a + badge for an informal gathering may be much different + from an award announcement at the Nobel Prize Ceremonies. The + valid values are `:formal` and `:informal`. Note that the + formats may be the same for different formality scenarios + depending on the length, usage, and cultural conventions + for the locale. For example short formal and short + informal may both be just the given name. The default is + derived from the formatting locale preferences. + + * `:order` is used express preference for the orders of attributes + in the formatted string. The valid values are `:given_first`, + `:surname_first` and `:sorting`. The default is based on features + of the person name struct and the formatting locale. The + option `:sorting` is only every defined as an option - not the + person name or locale data. + + ### Notes + + The formats may be the same for different lengths + depending on the formality, usage, and cultural conventions + for the locale. + + For example, medium and short may be the same for a + particular context. + + ### Returns + + * `{:ok, formatted_name}` or + + * `{:error, reason}`. + + ### Examples + + iex> {:ok, jose} = Cldr.PersonName.new(title: "Mr.", given_name: "José", surname: "Valim", credentials: "Ph.D.", locale: "pt") + iex> Cldr.PersonName.to_string(jose) + {:ok, "José"} + iex> Cldr.PersonName.to_string(jose, format: :long) + {:ok, "José"} + iex> Cldr.PersonName.to_string(jose, format: :long, formality: :formal) + {:ok, "Mr. Valim"} + iex> Cldr.PersonName.to_string(jose, format: :long, formality: :formal, usage: :referring) + {:ok, "Mr. José Valim Ph.D."} + + """ @spec to_string(name :: struct(), options :: Formatter.format_options()) :: {:ok, String.t()} | {:error, error_message()} @@ -95,7 +241,83 @@ defmodule Cldr.PersonName do end end - @spec to_string(name :: struct(), options :: Formatter.format_options()) :: + @doc """ + Returns a formatted person name as an + `:erlang.iodata` term. + + ### Arguments + + * `person_name` is any struct that implements the + `Cldr.PersonName` behaviour, including the native + `t:Cldr.PersonName.t/0` struct. + + * `options` is a keyword list of options. + + ### Options + + * `:format` is the relative length of a formatted name + depending on context. For example, a long formal + name in English might include `:title`, `:given_name`, + `:other_given_names`, `:surname` plus `:generation` and + `:credentials`; whereas a short informal name may only be + the `:given_name`. The valid values are `:short`, `:medium` + and `:long`. The default is derived from the formatting + locales preferences. + + * `:usage` indicates if the formatted name is being used + to address someone, refer to someone, or present their + name in an abbreviated form. The valid values are`:referring`, + `:addressing` or `:monogram`. The default is `:referring`. The pattern + for `:referring` may be the same as the pattern for + `:addressing`. + + * `:formality` indicates the formality of usage. A name on a + badge for an informal gathering may be much different + from an award announcement at the Nobel Prize Ceremonies. The + valid values are `:formal` and `:informal`. Note that the + formats may be the same for different formality scenarios + depending on the length, usage, and cultural conventions + for the locale. For example short formal and short + informal may both be just the given name. The default is + derived from the formatting locale preferences. + + * `:order` is used express preference for the orders of attributes + in the formatted string. The valid values are `:given_first`, + `:surname_first` and `:sorting`. The default is based on features + of the person name struct and the formatting locale. The + option `:sorting` is only every defined as an option - not the + person name or locale data. + + ### Notes + + The formats may be the same for different lengths + depending on the formality, usage, and cultural conventions + for the locale. + + For example, medium and short may be the same for a + particular context. + + ### Returns + + * `{:ok, formatted_name}` or + + * `{:error, reason}`. + + ### Examples + + iex> {:ok, jose} = Cldr.PersonName.new(title: "Mr.", given_name: "José", surname: "Valim", credentials: "Ph.D.", locale: "pt") + iex> Cldr.PersonName.to_string!(jose) + "José" + iex> Cldr.PersonName.to_string!(jose, format: :long) + "José" + iex> Cldr.PersonName.to_string!(jose, format: :long, formality: :formal) + "Mr. Valim" + iex> Cldr.PersonName.to_string!(jose, format: :long, formality: :formal, usage: :referring) + "Mr. José Valim Ph.D." + + """ + + @spec to_string!(name :: struct(), options :: Formatter.format_options()) :: String.t() | no_return() def to_string!(name, options \\ []) when is_struct(name) do @@ -105,24 +327,99 @@ defmodule Cldr.PersonName do end end - @spec to_iodata(name :: struct(), options :: Formatter.format_options()) :: + @doc """ + Returns a formatted person name as an + `:erlang.iodata` term. + + ### Arguments + + * `person_name` is any struct that implements the + `Cldr.PersonName` behaviour, including the native + `t:Cldr.PersonName.t/0` struct. + + * `options` is a keyword list of options. + + ### Options + + * `:format` is the relative length of a formatted name + depending on context. For example, a long formal + name in English might include `:title`, `:given_name`, + `:other_given_names`, `:surname` plus `:generation` and + `:credentials`; whereas a short informal name may only be + the `:given_name`. The valid values are `:short`, `:medium` + and `:long`. The default is derived from the formatting + locales preferences. + + * `:usage` indicates if the formatted name is being used + to address someone, refer to someone, or present their + name in an abbreviated form. The valid values are`:referring`, + `:addressing` or `:monogram`. The default is `:referring`. The pattern + for `:referring` may be the same as the pattern for + `:addressing`. + + * `:formality` indicates the formality of usage. A name on a + badge for an informal gathering may be much different + from an award announcement at the Nobel Prize Ceremonies. The + valid values are `:formal` and `:informal`. Note that the + formats may be the same for different formality scenarios + depending on the length, usage, and cultural conventions + for the locale. For example short formal and short + informal may both be just the given name. The default is + derived from the formatting locale preferences. + + * `:order` is used express preference for the orders of attributes + in the formatted string. The valid values are `:given_first`, + `:surname_first` and `:sorting`. The default is based on features + of the person name struct and the formatting locale. The + option `:sorting` is only every defined as an option - not the + person name or locale data. + + ### Notes + + The formats may be the same for different lengths + depending on the formality, usage, and cultural conventions + for the locale. + + For example, medium and short may be the same for a + particular context. + + ### Returns + + * `{:ok, formatted_name_as_iodata}` or + + * `{:error, reason}`. + + ### Examples + + iex> {:ok, jose} = Cldr.PersonName.new(title: "Mr.", given_name: "José", surname: "Valim", credentials: "Ph.D.", locale: "pt") + iex> Cldr.PersonName.to_iodata(jose) + {:ok, ["José"]} + iex> Cldr.PersonName.to_iodata(jose, format: :long) + {:ok, ["José"]} + iex> Cldr.PersonName.to_iodata(jose, format: :long, formality: :formal) + {:ok, ["Mr.", " ", "Valim"]} + iex> Cldr.PersonName.to_iodata(jose, format: :long, formality: :formal, usage: :referring) + {:ok, ["Mr.", " ", "José", " ", "Valim", " ", "Ph.D."]} + + """ + + @spec to_iodata(person_name :: struct(), options :: Formatter.format_options()) :: {:ok, :erlang.iodata()} | {:error, error_message()} - def to_iodata(name, options \\ []) when is_struct(name) do + def to_iodata(person_name, options \\ []) when is_struct(person_name) do {locale, backend} = Cldr.locale_and_backend_from(options) - with {:ok, name} <- maybe_cast_name(name), - {:ok, name} <- validate_name(name), + with {:ok, person_name} <- maybe_cast_name(person_name), {:ok, formatting_locale} <- Cldr.validate_locale(locale, backend) do - Formatter.to_iodata(name, formatting_locale, backend, options) + Formatter.to_iodata(person_name, formatting_locale, backend, options) end end - @spec to_iodata!(name :: struct(), options :: Formatter.format_options()) :: + @spec to_iodata!(person_name :: struct(), options :: Formatter.format_options()) :: :erlang.iodata() | no_return() - def to_iodata!(name, options \\ []) when is_struct(name) do - case to_iodata(name, options) do + def to_iodata!(person_name, options \\ []) when is_struct(person_name) do + case to_iodata(person_name, options) do {:ok, iodata} -> iodata {:error, reason} -> raise_error(reason) end @@ -147,6 +444,15 @@ defmodule Cldr.PersonName do Casts any struct that implements the `#{inspect __MODULE__}` behaviour into a `t:Cldr.PersonName.t/0` struct. + ### Arguments + + * `struct` is any struct that implements the + `#{inspect __MODULE__}` behaviour. + + ### Returns + + * A `t:Cldr.PersonName.t/0` struct. + """ @spec cast_to_person_name(struct()) :: t() def cast_to_person_name(%module{}) do @@ -233,12 +539,67 @@ defmodule Cldr.PersonName do end # A name needs only a given name to be minimally viable. - defp validate_name(%{given_name: given_name} = name) when is_binary(given_name) do - {:ok, name} + @string_attributes [:title, :given_name, :other_given_names, :surname, :other_surnames, :generation, :credentials] + @all_attributes @string_attributes ++ [:locale, :backend, :name_order] + @valid_name_order Formatter.valid_name_order() + + defp validate_name(attributes) when is_list(attributes) do + validated = + Enum.reduce_while attributes, %__MODULE__{}, fn + {attribute, value}, acc when attribute in @string_attributes and is_binary(value) -> + {:cont, Map.put(acc, attribute, value)} + + {attribute, value}, acc when attribute in @string_attributes and is_nil(value) -> + {:cont, Map.put(acc, attribute, value)} + + {:locale, %Cldr.LanguageTag{} = locale}, acc -> + {:cont, Map.put(acc, :locale, locale)} + + {:locale, _locale_reference}, acc -> + case validate_locale(attributes) do + {:ok, locale} -> {:cont, Map.put(acc, :locale, locale)} + other -> {:halt, other} + end + + {:name_order, name_order}, acc when name_order in @valid_name_order -> + {:cont, Map.put(acc, :name_order, name_order)} + + {:backend, _backend}, acc -> + {:cont, acc} + + {attribute, _value}, _acc when attribute not in @all_attributes -> + {:halt, {:error, "Invalid attribute found: #{inspect attribute}. Valid attributes are #{inspect @all_attributes}"}} + + {attribute, value}, _acc -> + {:halt, {:error, "Invalid attribute value found for #{inspect attribute}. Found #{inspect value}"}} + end + + case validated do + {:error, reason} -> + {:error, reason} + + %__MODULE__{} = person_name -> + validate_given_name_presence(person_name) + end + end + + # The contract is that the %__MODULE__{} struct is structurally + # sound so we just check there is a `:given_name`. + defp validate_name(%{} = person_name) do + validate_given_name_presence(person_name) end - defp validate_name(name) do - {:error, "Name requires at least a :given_name. Found #{inspect(name)}"} + defp validate_locale(options) do + {locale, backend} = Cldr.locale_and_backend_from(options) + Cldr.validate_locale(locale, backend) + end + + defp validate_given_name_presence(%{} = person_name) do + if person_name.given_name do + {:ok, person_name} + else + {:error, "Person Name requires at least a :given_name"} + end end defp raise_error(reason) when is_binary(reason) do diff --git a/lib/cldr/person_name/formatter.ex b/lib/cldr/person_name/formatter.ex index 0d7d1f9..fdc5802 100644 --- a/lib/cldr/person_name/formatter.ex +++ b/lib/cldr/person_name/formatter.ex @@ -1,5 +1,8 @@ defmodule Cldr.PersonName.Formatter do - @moduledoc false + @moduledoc """ + Implements the person name formatting specification. + + """ type_from_list = &Enum.reduce(&1, fn x, acc -> {:|, [], [x, acc]} end) @@ -40,6 +43,11 @@ defmodule Cldr.PersonName.Formatter do defguardp is_initial(term) when is_list(term) + @doc false + def valid_name_order do + [:given_first, :surname_first, :sorting] + end + def to_iodata(name, formatting_locale, backend, options) do with {:ok, name_locale} <- derive_name_locale(name, formatting_locale), {:ok, formats} <-formats(formatting_locale, name_locale, backend), @@ -113,6 +121,7 @@ defmodule Cldr.PersonName.Formatter do # Otherwise the result is A + B, further modified by replacing any sequence of two or more # white space characters by the first whitespace character. + @doc false def interpolate_format(name, locale, elements, formats) do elements |> Enum.map(&interpolate_element(name, &1, locale, formats)) @@ -127,6 +136,7 @@ defmodule Cldr.PersonName.Formatter do # list until we get to a value. Unlike the standard text (but consistent with ICU) a binary # before the first populated field is ok. + @doc false def remove_leading_emptiness([{:field, value} | rest]), do: [{:field, value} | rest] def remove_leading_emptiness([_first | rest]), do: remove_leading_emptiness(rest) def remove_leading_emptiness([]), do: [] @@ -134,6 +144,7 @@ defmodule Cldr.PersonName.Formatter do # If one or more fields at the end of the pattern are empty, all fields and literal text after # the last populated field are omitted. + @doc false def remove_trailing_emptiness(elements) do elements |> Enum.reverse() @@ -141,6 +152,7 @@ defmodule Cldr.PersonName.Formatter do |> Enum.reverse() end + @doc false def maybe_remove_leading_emptiness(elements) do Enum.reduce_while(elements, nil, fn # We found an empty element before any populated elements @@ -183,11 +195,7 @@ defmodule Cldr.PersonName.Formatter do # 2. Deletes an unpopulated field (nil) *and* a binary is the binary directly follows # the unpopulated field and the binary is whitespace. - # def remove_empty_fields([binary_1, nil, binary_2 | rest]) - # when is_binary(binary_1) and is_binary(binary_2) do - # remove_empty_fields([binary_1 | rest]) - # end - + @doc false def remove_empty_fields([nil | rest]) do case remove_up_to_nil(rest) do [] -> remove_empty_fields(rest) @@ -234,10 +242,12 @@ defmodule Cldr.PersonName.Formatter do # Otherwise the result is A + B, further modified by replacing any sequence of two or more # white space characters by the first whitespace character. + @doc false def combine_binary(first, ""), do: first def combine_binary("", second), do: second def combine_binary(first, first), do: first + @doc false def combine_binary(first, second) do if String.ends_with?(first, second) do first @@ -263,6 +273,7 @@ defmodule Cldr.PersonName.Formatter do # Replace multiple whitespace with the first # whitespace grapheme. + @doc false def remove_duplicate_whitespace(string) do case Regex.named_captures(~r/(?\s+)/u, string) do %{"whitespace" => whitespace} -> @@ -296,6 +307,7 @@ defmodule Cldr.PersonName.Formatter do # For the purposes of this algorithm, two base languages are said to match when they are # identical, or if both are in {ja, zh, yue}. + @doc false def foreign_or_native_space_replacement(list, name_locale, formatting_locale, formats) do replacement = foreign_or_native(name_locale.language, formatting_locale.language, formats) @@ -305,6 +317,7 @@ defmodule Cldr.PersonName.Formatter do end) end + @doc false def foreign_or_native(name_language, formatting_language, formats) do if considered_the_same_language?(name_language, formatting_language) do formats.native_space_replacement @@ -321,6 +334,7 @@ defmodule Cldr.PersonName.Formatter do name_language in [:ja, :zh, :yue] && formatting_language in [:ja, :zh, :yue] end + @doc false def wrap(term, atom) do {atom, term} end @@ -342,6 +356,7 @@ defmodule Cldr.PersonName.Formatter do #. modifiers) # Any request for a given name field (with any modifiers) returns "" (empty string) + @doc false def adjust_for_mononym(%{surname: surname} = name, format) when is_binary(surname) do {:ok, name, format} end @@ -681,6 +696,7 @@ defmodule Cldr.PersonName.Formatter do # the following paths. If at least one of them doesn’t inherit their value from root, then the # locale has name formatting data. + @doc false def derive_name_locale(%{locale: %Cldr.LanguageTag{} = name_locale} = name, _formatting_locale) do name_script = dominant_script(name) @@ -740,6 +756,7 @@ defmodule Cldr.PersonName.Formatter do {:ok, formats} end + @doc false def determine_name_order(name, name_locale, backend, options) do language = name_locale.language backend = Module.concat(backend, PersonName) @@ -752,6 +769,7 @@ defmodule Cldr.PersonName.Formatter do {:ok, Keyword.put(options, :order, order)} end + @doc false def select_format(name, formats, options) do # IO.inspect formats, label: "Select format" keys = [:person_name, options[:order], options[:format], options[:usage], options[:formality]] @@ -817,6 +835,7 @@ defmodule Cldr.PersonName.Formatter do # Return the number fields present (they are a binary) and # the number of fields in the format. + @doc false def score(name, format) do Enum.reduce(format, {0, 0}, fn field, {fields, populated} when is_binary(field) -> diff --git a/lib/cldr/protocol/string_chars.ex b/lib/cldr/protocol/string_chars.ex new file mode 100644 index 0000000..307a60b --- /dev/null +++ b/lib/cldr/protocol/string_chars.ex @@ -0,0 +1,6 @@ +defimpl String.Chars, for: Cldr.PersonName do + def to_string(name) do + locale = Cldr.get_locale() + Cldr.PersonName.to_string!(name, locale: locale, backend: locale.backend) + end +end diff --git a/mix.exs b/mix.exs index ff21d83..559d555 100644 --- a/mix.exs +++ b/mix.exs @@ -8,8 +8,8 @@ defmodule Cldr.PersonName.Mixfile do app: :ex_cldr_person_names, version: @version, docs: docs(), - elixir: "~> 1.11", - name: "Cldr Lists", + elixir: "~> 1.12", + name: "Cldr Person Names", source_url: "https://github.com/elixir-cldr/cldr_person_names", description: description(), package: package(), diff --git a/test/cldr_chars_test.exs b/test/cldr_chars_test.exs index dd60619..f22abd0 100644 --- a/test/cldr_chars_test.exs +++ b/test/cldr_chars_test.exs @@ -1,6 +1,21 @@ defmodule Cldr.PersonName.CharsTest do use ExUnit.Case, async: true - test "to_string on a person name" do + test "Cldr.to_string/1 on a person name" do + string = + Cldr.PersonName.Names.names() + |> Map.get(:mary) + |> Cldr.to_string() + + assert string == "Mary Sue" + end + + test "Kernel.to_string/1 on a person name" do + string = + Cldr.PersonName.Names.names() + |> Map.get(:mary) + |> to_string() + + assert string == "Mary Sue" end end