Packages

Ultra fast English pluralize and singularize noun inflection for Elixir. Convert plural to singular, singular to plural, and detect noun forms. Zero-regex, compile-time data, O(1) dispatch. Classical English mode available.

Current section

Files

Jump to
plurality lib plurality.ex
Raw

lib/plurality.ex

defmodule Plurality do
@moduledoc """
Fast, zero-regex English noun inflection for Elixir.
Operates on English nouns only. Other parts of speech (pronouns, adjectives,
adverbs, etc.) are not supported and will produce undefined results.
Plurality provides 99%+ accuracy on business and technical English,
using compile-time binary pattern matching instead of runtime regex.
## Quick start
Plurality.pluralize("leaf") #=> "leaves"
Plurality.singularize("leaves") #=> "leaf"
Plurality.plural?("leaves") #=> true
Plurality.singular?("leaf") #=> true
Plurality.inflect("leaf", 2) #=> "leaves"
Plurality.inflect("leaf", 1) #=> "leaf"
## Safe pluralization
Pass `check: true` to avoid double-pluralizing words that are already plural.
Plurality.pluralize("children", check: true) #=> "children"
Plurality.pluralize("people", check: true) #=> "people"
Plurality.pluralize("leaf", check: true) #=> "leaves"
## Case preservation
Input casing is detected and applied to the output automatically.
Three styles are recognized: ALL CAPS, Title Case, and lowercase.
Plurality.pluralize("LEAF") #=> "LEAVES"
Plurality.pluralize("Leaf") #=> "Leaves"
Plurality.singularize("WOMEN") #=> "WOMAN"
Plurality.singularize("Children") #=> "Child"
## Uncountable words
Words like "sheep", "software", and "news" are returned unchanged by both
`pluralize/2` and `singularize/1`. Detection functions `plural?/1` and
`singular?/1` return `true` for uncountables since they are valid in either
context.
Plurality.pluralize("sheep") #=> "sheep"
Plurality.singularize("sheep") #=> "sheep"
Plurality.plural?("sheep") #=> true
Plurality.singular?("sheep") #=> true
## Compound nouns
Multi-word nouns are split on the last space and the final word is inflected.
Known multi-word irregulars in the data (e.g., `"coup d'etat"`) take priority
over splitting.
Plurality.pluralize("status code") #=> "status codes"
Plurality.pluralize("field mouse") #=> "field mice"
Plurality.singularize("ice creams") #=> "ice cream"
## Domain customization
Use `Plurality.Custom` to define a module with your own irregulars and
uncountables. Overrides compile into function heads that take priority
over the built-in data.
defmodule MyApp.Inflection do
use Plurality.Custom,
irregulars: [{"regex", "regexen"}],
uncountables: ["kubernetes"]
end
MyApp.Inflection.pluralize("regex") #=> "regexen"
MyApp.Inflection.pluralize("kubernetes") #=> "kubernetes"
MyApp.Inflection.pluralize("leaf") #=> "leaves"
See `Plurality.Custom` for full documentation.
## Classical mode
Pass `classical: true` to get Latin/Greek plural forms instead of modern
English forms. Default behavior is unchanged — classical mode is opt-in.
Plurality.pluralize("aquarium") #=> "aquariums"
Plurality.pluralize("aquarium", classical: true) #=> "aquaria"
Plurality.pluralize("trauma", classical: true) #=> "traumata"
Plurality.pluralize("formula", classical: true) #=> "formulae"
Singularization handles both forms automatically, no option needed:
Plurality.singularize("aquariums") #=> "aquarium"
Plurality.singularize("aquaria") #=> "aquarium"
## Explicit configuration
Plurality does not read application configuration. Pass options at the call
site and call custom inflection modules directly:
Plurality.pluralize("aquarium", classical: true) #=> "aquaria"
MyApp.Inflection.pluralize("regex") #=> "regexen"
This keeps library behavior local to each caller and avoids global state.
## Ash integration
If your project uses Ash, Plurality ships optional changes, validations,
and calculations that compile away to nothing if Ash is not loaded. See:
* `Plurality.Ash.Changes.Pluralize`
* `Plurality.Ash.Changes.Singularize`
* `Plurality.Ash.Validations.PluralForm`
* `Plurality.Ash.Validations.SingularForm`
* `Plurality.Ash.Calculations.Pluralize`
* `Plurality.Ash.Calculations.Singularize`
## Architecture
Plurality uses a three-tier resolution engine (originated by Conway's 1998
paper, same architecture as Rails and pluralize.js):
1. **Uncountables** (`MapSet`, O(1)) — word returned unchanged
2. **Irregulars** (`Map`, O(1)) — direct lookup from merged cross-ecosystem data
3. **Suffix rules** (last-byte dispatch) — BEAM `select_val` jump table, zero regex
All data is compiled into module attributes at build time from TSV/TXT files
in `priv/`. There is zero runtime file I/O and zero regex execution.
## Data
Plurality ships with ~1,110 irregular pairs, ~1,022 uncountable words, and
108 suffix rules — curated from multiple sources across the Elixir, Ruby,
JavaScript, Go, C#, and Rust ecosystems, then verified against Oxford and
Merriam-Webster dictionaries. All data is loaded at compile time from
`priv/data/`.
"""
@typedoc """
Options for `pluralize/2` and `inflect/3`.
* `:check` — when `true`, tests whether the word is already in plural form
before transforming it. If the word is already plural, it is returned
unchanged. This prevents double-pluralization errors like
`"children"``"childrens"`. Defaults to `false`.
* `:classical` — when `true`, uses classical Latin/Greek plural forms
instead of modern English forms (e.g., `"aquarium"``"aquaria"`
instead of `"aquariums"`). Defaults to `false`.
"""
@type pluralize_opts :: [check: boolean(), classical: boolean()]
@doc """
Converts an English noun to its plural form.
Returns the word unchanged if it is uncountable (e.g., `"sheep"`, `"software"`).
Preserves the casing style of the input (ALL CAPS, Title Case, or lowercase).
For custom domain rules, define a `Plurality.Custom` module and call it
directly.
## Options
* `:check` (`boolean()`) - When `true`, checks whether the word is already
plural and returns it unchanged if so. Prevents double-pluralization
(e.g., `"children"` staying `"children"` instead of becoming
`"childrens"`). Defaults to `false`.
* `:classical` (`boolean()`) - When `true`, uses classical Latin/Greek
plural forms instead of modern English forms. Defaults to `false`.
## Resolution order
1. Uncountable? → return unchanged
2. `check: true` and already a known irregular plural? → return unchanged
3. `classical: true` and word has a known classical form? → return classical plural
4. Known irregular singular? → return the mapped plural
5. `check: true` and rule-based detection says plural? → return unchanged
6. Compound noun? → split on last space, inflect last word
7. Apply suffix rules (last-byte dispatch, classical-aware)
## Examples
iex> Plurality.pluralize("leaf")
"leaves"
iex> Plurality.pluralize("child")
"children"
iex> Plurality.pluralize("sheep")
"sheep"
iex> Plurality.pluralize("schema")
"schemas"
iex> Plurality.pluralize("children", check: true)
"children"
iex> Plurality.pluralize("LEAF")
"LEAVES"
iex> Plurality.pluralize("Leaf")
"Leaves"
iex> Plurality.pluralize("status code")
"status codes"
iex> Plurality.pluralize("")
""
### Classical mode
iex> Plurality.pluralize("aquarium", classical: true)
"aquaria"
iex> Plurality.pluralize("formula", classical: true)
"formulae"
iex> Plurality.pluralize("trauma", classical: true)
"traumata"
iex> Plurality.pluralize("cactus")
"cactuses"
iex> Plurality.pluralize("leaf", classical: true)
"leaves"
"""
@spec pluralize(word :: String.t(), opts :: pluralize_opts()) :: String.t()
def pluralize(word, opts \\ []) do
Plurality.Engine.pluralize(word, opts)
end
@doc """
Converts an English noun from plural to singular form.
Returns the word unchanged if it is uncountable. Preserves casing style.
For custom domain rules, define a `Plurality.Custom` module and call it
directly.
For words that appear in both the uncountables set and the irregular plurals
map (e.g., `"data"`, `"graffiti"`), the irregular reverse lookup takes
priority so that singularization resolves correctly:
Plurality.singularize("data") #=> "datum"
Plurality.singularize("graffiti") #=> "graffito"
Singularization is mode-independent — it handles both modern and classical
plural forms without needing a `classical:` option:
Plurality.singularize("aquariums") #=> "aquarium"
Plurality.singularize("aquaria") #=> "aquarium"
## Resolution order
1. Known irregular plural (including classical forms)? → return the mapped singular
2. Uncountable? → return unchanged
3. Compound noun? → split on last space, singularize last word
4. Apply suffix rules (last-byte dispatch)
## Examples
iex> Plurality.singularize("leaves")
"leaf"
iex> Plurality.singularize("children")
"child"
iex> Plurality.singularize("sheep")
"sheep"
iex> Plurality.singularize("taxes")
"tax"
iex> Plurality.singularize("statuses")
"status"
iex> Plurality.singularize("WOMEN")
"WOMAN"
iex> Plurality.singularize("status codes")
"status code"
iex> Plurality.singularize("")
""
### Classical plural forms
iex> Plurality.singularize("aquaria")
"aquarium"
iex> Plurality.singularize("antennae")
"antenna"
iex> Plurality.singularize("traumata")
"trauma"
"""
@spec singularize(word :: String.t()) :: String.t()
def singularize(word) do
Plurality.Engine.singularize(word)
end
@doc """
Returns `true` if the word is in plural form (or is uncountable).
Uncountable words like `"sheep"` and `"software"` return `true` for both
`plural?/1` and `singular?/1`, since they are valid in either context.
For custom domain rules, define a `Plurality.Custom` module and call it
directly.
## Detection strategy
Detection is derived from transformation (inspired by
[pluralize.js](https://github.com/plurals/pluralize)):
1. Uncountable? → `true`
2. Known irregular plural (in the plural→singular map)? → `true`
3. Try `singularize/1` — if it produces a different word, and
`pluralize/1` on that result gives back the original, then `true`
## Examples
iex> Plurality.plural?("leaves")
true
iex> Plurality.plural?("children")
true
iex> Plurality.plural?("leaf")
false
iex> Plurality.plural?("sheep")
true
iex> Plurality.plural?("")
false
"""
@spec plural?(word :: String.t()) :: boolean()
def plural?(word) do
Plurality.Engine.plural?(word)
end
@doc """
Returns `true` if the word is in singular form (or is uncountable).
Uncountable words return `true` for both `plural?/1` and `singular?/1`.
For custom domain rules, define a `Plurality.Custom` module and call it
directly.
## Detection strategy
1. Uncountable? → `true`
2. Known irregular singular (in the singular→plural map)? → `true`
3. Known irregular plural (in the plural→singular map)? → `false`
4. Otherwise, check whether the word appears to already be plural
via rule-based round-tripping
## Examples
iex> Plurality.singular?("leaf")
true
iex> Plurality.singular?("child")
true
iex> Plurality.singular?("leaves")
false
iex> Plurality.singular?("sheep")
true
iex> Plurality.singular?("")
false
"""
@spec singular?(word :: String.t()) :: boolean()
def singular?(word) do
Plurality.Engine.singular?(word)
end
@doc """
Inflects a word based on a numeric count.
Returns the singular form when `count` is exactly `1`, and the plural form
for all other values (including `0`, negative numbers, and numbers greater
than `1`).
For custom domain rules, define a `Plurality.Custom` module and call it
directly.
This follows standard English convention where zero and plural counts
use the plural form: "0 items", "2 items", but "1 item".
## Options
Accepts the same options as `pluralize/2` (`:check`, `:classical`).
Options are passed through to `pluralize/2` when the count is not `1`.
## Examples
iex> Plurality.inflect("leaf", 1)
"leaf"
iex> Plurality.inflect("leaf", 2)
"leaves"
iex> Plurality.inflect("leaf", 0)
"leaves"
iex> Plurality.inflect("child", 1)
"child"
iex> Plurality.inflect("child", 3)
"children"
iex> Plurality.inflect("sheep", 1)
"sheep"
iex> Plurality.inflect("sheep", 100)
"sheep"
iex> Plurality.inflect("aquarium", 2, classical: true)
"aquaria"
iex> Plurality.inflect("aquarium", 1, classical: true)
"aquarium"
"""
@spec inflect(word :: String.t(), count :: integer(), opts :: pluralize_opts()) :: String.t()
def inflect(word, count, opts \\ []) do
Plurality.Engine.inflect(word, count, opts)
end
end