Current section
Files
Jump to
Current section
Files
lib/segment.ex
defmodule Text.Segment do
@moduledoc """
Locale-aware word and sentence segmentation.
Implements the public surface area you reach for when you want to
break a string into its semantic pieces — words or sentences —
following the Unicode segmentation rules in
[UAX #29 (Text Segmentation)](https://unicode.org/reports/tr29/).
Line-break segmentation (UAX #14) is available via `stream/2` for
the small set of callers who need it.
This module is a thin facade over the
[`unicode_string`](https://hex.pm/packages/unicode_string) package,
exposed under the `Text` namespace so callers don't have to know
where the underlying primitives live. Two adjustments over the raw
`Unicode.String.split/2`:
* **`words/2`** drops punctuation tokens by default. The Unicode word
segmentation algorithm treats `","` and `"!"` as words just like
`"hello"`; in practice every text-processing pipeline immediately
filters those out, so this module does it by default.
### Locale input shapes
The `:locale` option (where it appears) accepts an atom (`:fr`), a
string (`"fr"`, `"fr-CA"`, `"zh-Hans-CN"`), or a
`Localize.LanguageTag` struct when the optional `:localize`
dependency is loaded. See `Text.Language` for details.
* **`sentences/2`** trims trailing whitespace from each sentence so
callers don't have to. The Unicode rules attach trailing whitespace
to the preceding sentence, which is rarely what you want.
Pass `punctuation: :keep` or `trim: false` to opt back in to the raw
Unicode behaviour.
### Locale-awareness and abbreviation suppressions
Word and sentence segmentation rules differ across languages —
German treats `"Donaudampfschiffahrtsgesellschaft"` as one word,
Japanese needs dictionary-based segmentation for any meaningful
tokenisation, and Thai has no spaces at all. Pass `locale: "ja"`,
`locale: "th"`, etc., to pick up those tailorings; without a locale
option the default Unicode rules apply.
When a locale is supplied, `sentences/2` also applies CLDR's
*suppression* list for that locale by default, which keeps common
abbreviations like `"i.e."` and `"e.g."` from being treated as
sentence terminators. The list is partial — it covers the most
common forms but not every abbreviation in use — so callers needing
comprehensive abbreviation handling should still layer their own
rules on top. Pass `suppressions: false` to opt out of even the
default list.
### Streaming
For very large inputs (log files, full books) prefer `stream/2`,
which returns a lazy `Stream` rather than realising the entire token
list in memory.
"""
@typedoc "A segmentation break type understood by Unicode UAX #29."
@type break :: :word | :sentence | :line | :grapheme
@doc """
Splits `text` into word tokens, dropping whitespace and punctuation
by default.
### Arguments
* `text` is a UTF-8 string.
### Options
* `:punctuation` — `:drop` (default) or `:keep`. Whether to include
word-typed punctuation tokens (`","`, `"!"`, `"?"`, etc.) in the
output.
* `:locale` — a BCP-47 locale string (`"en"`, `"ja"`, ...). When
given, locale-specific break rules from CLDR are applied. Defaults
to the Unicode root locale rules.
### Returns
* A list of strings.
### Examples
iex> Text.Segment.words("Hello, world! How are you?")
["Hello", "world", "How", "are", "you"]
iex> Text.Segment.words("Hello, world!", punctuation: :keep)
["Hello", ",", "world", "!"]
iex> Text.Segment.words("naïve café")
["naïve", "café"]
iex> Text.Segment.words("")
[]
"""
@spec words(String.t(), keyword()) :: [String.t()]
def words(text, options \\ []) when is_binary(text) do
punctuation = Keyword.get(options, :punctuation, :drop)
locale = Keyword.get(options, :locale)
split_options = [break: :word, trim: true] ++ locale_option(locale)
text
|> Unicode.String.split(split_options)
|> filter_punctuation(punctuation)
end
defp filter_punctuation(tokens, :keep), do: tokens
defp filter_punctuation(tokens, :drop) do
Enum.reject(tokens, &punctuation_only?/1)
end
# A token is "punctuation only" if every grapheme in it falls into a
# Unicode punctuation or symbol category. We don't have direct access
# to the category from `unicode_string`, but `Regex` covers the same
# ground via `\p{P}` and `\p{S}`.
defp punctuation_only?(""), do: true
defp punctuation_only?(token) do
Regex.match?(~r/^[\p{P}\p{S}]+$/u, token)
end
defp locale_option(nil), do: []
defp locale_option(locale) do
[locale: Text.Language.to_locale_string(locale)]
end
@doc """
Splits `text` into sentences, trimming trailing whitespace by default.
### Arguments
* `text` is a UTF-8 string.
### Options
* `:trim` — `true` (default) trims leading and trailing whitespace
from each sentence. `false` preserves the raw Unicode-segmentation
output where the trailing whitespace before the next sentence is
attached to the preceding sentence.
* `:locale` — a BCP-47 locale string. When given, locale-specific
sentence break rules from CLDR are applied. Also enables CLDR's
abbreviation suppression list (see `:suppressions`).
* `:suppressions` — `true` (default) applies CLDR's per-locale
abbreviation suppression list, which prevents common forms like
`"i.e."` and `"e.g."` from being treated as sentence terminators.
Has no effect when `:locale` is not set.
### Returns
* A list of strings.
### Examples
iex> Text.Segment.sentences("Hello, world! How are you?")
["Hello, world!", "How are you?"]
iex> Text.Segment.sentences("First. Second! Third? Yes.")
["First.", "Second!", "Third?", "Yes."]
iex> Text.Segment.sentences("Hello, world! How are you?", trim: false)
["Hello, world! ", "How are you?"]
iex> Text.Segment.sentences("He used i.e. and e.g. in his memo. Then stopped.", locale: "en")
["He used i.e. and e.g. in his memo.", "Then stopped."]
iex> Text.Segment.sentences("")
[]
"""
@spec sentences(String.t(), keyword()) :: [String.t()]
def sentences(text, options \\ []) when is_binary(text) do
trim? = Keyword.get(options, :trim, true)
locale = Keyword.get(options, :locale)
suppressions? = Keyword.get(options, :suppressions, true)
split_options =
[break: :sentence, trim: true] ++
locale_option(locale) ++
[suppressions: suppressions?]
raw = Unicode.String.split(text, split_options)
if trim? do
Enum.map(raw, &String.trim/1)
else
raw
end
end
@doc """
Returns a `Stream` that lazily yields segments of `text`.
Use this for inputs large enough that materialising the entire token
list at once would be wasteful. The `:break` option chooses the
segmentation level (`:word`, `:sentence`, `:line`, or `:grapheme`).
### Arguments
* `text` is a UTF-8 string.
### Options
* `:break` — required. One of `:word`, `:sentence`, `:line`, or
`:grapheme`.
* `:trim` — defaults to `true`. Drops whitespace-only tokens.
* `:locale` — a BCP-47 locale string.
### Returns
* A `Stream`.
### Examples
iex> Text.Segment.stream("Hello world", break: :word) |> Enum.to_list()
["Hello", "world"]
"""
@spec stream(String.t(), keyword()) :: Enumerable.t()
def stream(text, options) when is_binary(text) do
break = Keyword.fetch!(options, :break)
trim? = Keyword.get(options, :trim, true)
locale = Keyword.get(options, :locale)
Unicode.String.stream(
text,
[break: break, trim: trim?] ++ locale_option(locale)
)
end
end