Packages

Text analysis and processing for Elixir including ngram, language detection and more.

Current section

Files

Jump to
text lib stopwords.ex
Raw

lib/stopwords.ex

defmodule Text.Stopwords do
@moduledoc """
Bundled multilingual stopword lists.
Stopwords are the high-frequency function words that carry little
topical content (`the`, `is`, `of` in English; `le`, `la`, `et` in
French; …). They are routinely filtered out of text before frequency
analysis, keyword extraction, or other content-focused processing.
This module ships the [stopwords-iso](https://github.com/stopwords-iso/stopwords-iso)
collection — a community-curated set of stopword lists covering
~60 languages, distributed under the MIT license. The raw `.txt`
files live in `priv/stopwords/<lang>.txt`; this module loads them
at compile time into per-language `MapSet`s and exposes a small
query API.
### Languages
Every list is keyed by its ISO 639-1 two-letter code. Use
`available_languages/0` to enumerate the bundled set. Common codes
include `:en`, `:fr`, `:de`, `:es`, `:it`, `:pt`, `:ru`, `:zh`,
`:ja`, `:ar`, `:nl`, `:sv`, `:fi`, `:da`, `:no`, `:pl`, `:tr`,
`:ko`, `:hi`, …
### Composing lists
`union/2` merges two language sets — useful for code-mixed text or
for adding the emoticon-equivalent token list. `extend/2` returns
a new set with caller-supplied tokens added; that's how callers
layer in domain-specific stopwords (e.g. boilerplate, brand names)
without having to rebuild the whole list.
### Licensing
The bundled `.txt` files are reproduced from stopwords-iso under
the MIT license. See `priv/stopwords/LICENSE` for the upstream
attribution. Regeneration is via `mix text.gen_stopwords`.
"""
# Compile-time discovery of the bundled lists. Each .txt file under
# priv/stopwords/ becomes a MapSet keyed by the filename stem (the
# ISO 639-1 code) cast to an atom. Adding a new language is a matter
# of dropping a new file in priv/stopwords/ and recompiling — no
# changes to this module are required.
@priv_dir Path.join([__DIR__, "..", "priv", "stopwords"])
@sources @priv_dir |> File.ls!() |> Enum.filter(&String.ends_with?(&1, ".txt"))
for source <- @sources do
@external_resource Path.join(@priv_dir, source)
end
# Store as `%{lang => [string]}` rather than `%{lang => MapSet}`. A
# `MapSet` baked into a module attribute at compile time loses its
# opaque type tag through the ETF round-trip dialyzer applies to
# persisted attributes, which then trips opacity warnings on
# `MapSet.union/2` and `MapSet.member?/2`. Building the MapSet in
# `for/1` (cached in `:persistent_term` after first call) keeps the
# public surface MapSet-typed without paying that cost more than once
# per language.
load = fn path ->
path
|> File.read!()
|> String.split(~r/\r?\n/, trim: true)
|> Enum.reject(&(&1 == ""))
end
@stopword_lists (for source <- @sources, into: %{} do
lang =
source
|> String.replace_suffix(".txt", "")
|> String.to_atom()
{lang, load.(Path.join(@priv_dir, source))}
end)
@available_languages @stopword_lists |> Map.keys() |> Enum.sort()
@typedoc "An ISO 639-1 language code, atom-typed."
@type language :: atom()
@typedoc """
Anything `Text.Language.normalize/1` accepts: an atom language tag, a
BCP-47 string, or a `Localize.LanguageTag` struct.
"""
@type language_input :: atom() | String.t() | struct()
@doc """
Returns the bundled stopword `MapSet` for a language.
### Arguments
* `language` is an atom language tag (`:en`, `:fr`, …), a string
BCP-47 tag (`"en"`, `"en-US"`), or a `Localize.LanguageTag` struct.
The input is normalised via `Text.Language.normalize/1` before
lookup.
### Returns
* A `MapSet` of lowercased stopword strings.
* Raises `ArgumentError` if no list is bundled for the resolved
language. Use `available_languages/0` to enumerate the supported
set.
### Examples
iex> Text.Stopwords.for(:en) |> MapSet.member?("the")
true
iex> Text.Stopwords.for("fr") |> MapSet.member?("le")
true
iex> Text.Stopwords.for(:en) |> MapSet.member?("zebra")
false
"""
@spec for(language_input()) :: MapSet.t(String.t())
def for(language) do
lang = Text.Language.normalize(language)
case Map.fetch(@stopword_lists, lang) do
{:ok, words} ->
# `MapSet.new/1` from a ~1k-entry list runs in microseconds,
# and rebuilding each call keeps dialyzer's opacity chain
# intact (a `:persistent_term`-cached set would lose its
# opaque tag through the `term()`-returning fetch).
MapSet.new(words)
:error ->
raise ArgumentError,
"no bundled stopword list for #{inspect(lang)}. " <>
"Use Text.Stopwords.available_languages/0 to enumerate the supported set."
end
end
@doc """
Returns whether `token` is in the bundled stopword list for a language.
Equivalent to `MapSet.member?(Text.Stopwords.for(language), token)`,
with the same input flexibility on the `language` argument.
### Arguments
* `language` is any value accepted by `for/1`.
* `token` is a string. Comparison is case-sensitive against the
lowercased upstream lists; pass a folded token if you need
case-insensitive matching.
### Returns
* `true` if the token is in the list, `false` otherwise.
### Examples
iex> Text.Stopwords.contains?(:en, "the")
true
iex> Text.Stopwords.contains?(:en, "Zebra")
false
iex> Text.Stopwords.contains?(:fr, "le")
true
"""
# `for/1`'s return value loses dialyzer's opaque tag through the
# `__MODULE__.` qualified-call boundary; the warning is benign.
@dialyzer {:nowarn_function, contains?: 2, union: 2}
@spec contains?(language_input(), String.t()) :: boolean()
def contains?(language, token) when is_binary(token) do
MapSet.member?(__MODULE__.for(language), token)
end
@doc """
Returns the sorted list of bundled language tags.
### Returns
* A list of atom language tags (ISO 639-1 codes).
### Examples
iex> :en in Text.Stopwords.available_languages()
true
iex> :fr in Text.Stopwords.available_languages()
true
iex> Text.Stopwords.available_languages() |> length() > 50
true
"""
@spec available_languages() :: [language()]
def available_languages, do: @available_languages
@doc """
Returns whether a stopword list is bundled for the given language.
### Arguments
* `language` is any value accepted by `for/1`.
### Returns
* `true` if a list is bundled, `false` otherwise.
### Examples
iex> Text.Stopwords.available?(:en)
true
iex> Text.Stopwords.available?(:zz)
false
"""
@spec available?(language_input()) :: boolean()
def available?(language) do
Map.has_key?(@stopword_lists, Text.Language.normalize(language))
end
@doc """
Returns the union of two bundled stopword sets.
Useful for code-mixed input (e.g. a French-English document) or for
layering in a small auxiliary set (an `:emoticon`-style list).
### Arguments
* `first` and `second` are anything accepted by `for/1`.
### Returns
* A `MapSet` containing every token from either list.
### Examples
iex> set = Text.Stopwords.union(:en, :fr)
iex> MapSet.member?(set, "the") and MapSet.member?(set, "le")
true
"""
@spec union(language_input(), language_input()) :: MapSet.t(String.t())
def union(first, second) do
MapSet.union(__MODULE__.for(first), __MODULE__.for(second))
end
@doc """
Returns a stopword set augmented with extra tokens.
Lets callers layer domain-specific stopwords (e.g. brand names,
boilerplate, navigation chrome) on top of the bundled list without
mutating the bundled set.
### Arguments
* `language` is any value accepted by `for/1`.
* `extra` is a list, `MapSet`, or any enumerable of strings to add.
### Returns
* A `MapSet` containing the bundled set plus `extra`.
### Examples
iex> set = Text.Stopwords.extend(:en, ["acme", "lorem"])
iex> MapSet.member?(set, "the") and MapSet.member?(set, "acme")
true
"""
@spec extend(language_input(), Enumerable.t(String.t())) :: MapSet.t(String.t())
def extend(language, extra) do
base = __MODULE__.for(language)
Enum.reduce(extra, base, fn token, acc when is_binary(token) ->
MapSet.put(acc, token)
end)
end
end