Repository: mrmaxguns/wonderwordsmodule (language: python)

Issue:
Cursewords flag
I'm generating adjective-noun pairs as job names for my projects.

**Is your feature request related to a problem? Please describe.**
I would like *not* to serve my users random words like 'ugly fuck' or 'nasty shit'.

**Describe the solution you'd like**
I would like to pass a flag to the generator, specifying whether I want curse words sprinkled into my random words. This flag would default to False (no curse words).

**Describe alternatives you've considered**
I considered just leaving it as-is and laughing when somebody complains about their random words being something like 'massive cock'.


Discussion / hints:
I didn't notice the curse word. Thanks for opening an issue. There would be three possible ways that I though of to implement this.

1. Don't include curse words at all
2. Add a separate list for curse words that will be  loaded unless disabled
3. Generate words, and then check if they are curse words from a list of curse words, then filter if required.

I'll probably implement one of these solutions by the next release. If you think of a solution, or want to implement it, let me know.
I think removing them completely is a good solution unless somebody comes up with a good argument against removing them. There might be some use case, however, so if you decide to go for options 2. or 3., they should default to SFW wordlist only - I think most customer-facing projects using this library would appreciate that. 
I believe that choosing a SFW wordlist would be the best solution. In the next release I'll add the ability to create custom word categories, so that those who'll want to add custom word categories can do so.
Curse words have been removed and added to `profanitylist.txt`. Custom categories have also been implemented. :+1: 
Great, thanks! Just so you know, `profanitylist.txt` is empty in the current version. Not an issue for me, but I don't think that's intentional.
Yes, I am aware. That was an accident. Fixed!

Fix patch:
diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml
index 5ccca83..66e425d 100644
--- a/.github/workflows/python-package.yml
+++ b/.github/workflows/python-package.yml
@@ -5,9 +5,9 @@ name: Python package
 
 on:
   push:
-    branches: [ master ]
+    branches: [ master, "v*" ]
   pull_request:
-    branches: [ master ]
+    branches: [ master, "v*" ]
 
 jobs:
   build:
@@ -15,26 +15,32 @@ jobs:
     runs-on: ubuntu-latest
     strategy:
       matrix:
-        python-version: [3.6, 3.7, 3.8, 3.9]
+        python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"]
 
     steps:
-    - uses: actions/checkout@v2
+    - uses: actions/checkout@v4
     - name: Set up Python ${{ matrix.python-version }}
-      uses: actions/setup-python@v2
+      uses: actions/setup-python@v5
       with:
         python-version: ${{ matrix.python-version }}
     - name: Install dependencies
       run: |
         python -m pip install --upgrade pip
-        python -m pip install flake8 pytest
-        # if [ -f requirements.txt ]; then pip install -r requirements.txt; fi
-        python -m pip install .
+        python -m pip install flake8 pytest mypy
+        if [ -f requirements.txt ]; then pip install -r requirements.txt; fi
+        
+        python -m pip install build
+        python -m build
+        python -m pip install dist/*.whl
     - name: Lint with flake8
       run: |
         # stop the build if there are Python syntax errors or undefined names
-        flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics
+        python -m flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics
         # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide
-        flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics
+        python -m flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics
+    - name: Type check with MyPy
+      run: |
+        python -m mypy wonderwords
     - name: Test with pytest
       run: |
-        pytest
+        python -m pytest
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 21b55ad..f707abc 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,7 +5,33 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
 and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html)
 since version 2.
 
-## [Unreleased]
+## [3.0.0] - Unreleased
+
+### Breaking Changes
+
+- This version officially supports Python 3.8+
+- The command-line interface has changed. Shell scripts relying on
+  Wonderwords need to be updated
+
+### Added
+
+- Speed improvements when using `starts_with` and `ends_with`
+- More efficient batch operations and regular expression compilation
+- Idiomatic CLI commands
+- Profanity filtering
+- Support for custom ``Random`` objects and determinism when running
+  with a specific seed.
+
+### Fixed
+
+- Fixes and updates to word lists
+- Fixed bug where ``return_less_if_necessary`` would produce result
+  in incorrect filtering
+
+### Removed
+
+- Dropped support for Python 3.6 and 3.7
+- No more dependency on ``importlib_resources``
 
 ## [2.2.0] - 2021-02-17
 
diff --git a/LICENSE b/LICENSE
index 0ffd785..582adc8 100644
--- a/LICENSE
+++ b/LICENSE
@@ -1,6 +1,6 @@
 MIT License
 
-Copyright (c) 2020 mrmaxguns
+Copyright (c) 2020 Maxim Rebguns
 
 Permission is hereby granted, free of charge, to any person obtaining a copy
 of this software and associated documentation files (the "Software"), to deal
diff --git a/README.md b/README.md
index cb7e4b3..2586a76 100644
--- a/README.md
+++ b/README.md
@@ -1,26 +1,18 @@
-<p align="center">
-  <img width="460" height="300" src="assets/main.gif">
-</p>
-
-<h1 align="center">Wonderwords</h1>
-<p align="center">Generate random words and sentences with ease in python</p>
-<p align="center">
-  <img alt="PyPI - Downloads" src="https://img.shields.io/pypi/dm/wonderwords?style=for-the-badge">
-  <img alt="Libraries.io SourceRank" src="https://img.shields.io/librariesio/sourcerank/pypi/wonderwords?style=for-the-badge">
-  <img alt="PyPI - License" src="https://img.shields.io/pypi/l/wonderwords?style=for-the-badge">
-</p>
-<p align="center">
-  <img alt="Python package build" src="https://github.com/mrmaxguns/wonderwordsmodule/workflows/Python%20package/badge.svg">
-</p>
-<p align="center">
-  <a href="https://github.com/mrmaxguns/wonderwordsmodule">GitHub repository</a>
-  | <a href="https://pypi.org/project/wonderwords">PyPI page</a>
-  | <a href="https://wonderwords.readthedocs.io">Official Documentation</a>
-</p>
+# Wonderwords
+
+*Generate random words and sentences with ease in python*
+
+<img alt="PyPI - Downloads" src="https://img.shields.io/pypi/dm/wonderwords?style=for-the-badge">
+<img alt="Libraries.io SourceRank" src="https://img.shields.io/librariesio/sourcerank/pypi/wonderwords?style=for-the-badge">
+<img alt="PyPI - License" src="https://img.shields.io/pypi/l/wonderwords?style=for-the-badge">
+
+[GitHub Repository](https://github.com/mrmaxguns/wonderwordsmodule) |
+[PyPI](https://pypi.org/project/wonderwords) |
+[Documentation](https://wonderwords.readthedocs.io)
 
 ***
 
-Wonderwords is a python package useful for generating random words and
+Wonderwords is a Python package useful for generating random words and
 structured random sentences. It also comes with a colorful command line
 interface for quickly generating random words. The latest version is available
 [on GitHub](https://github.com/mrmaxguns/wonderwordsmodule) while the stable
@@ -42,10 +34,12 @@ version is available [on PyPI](https://pypi.org/project/wonderwords).
 
 Here's what Wonderwords is capable of:
 
-- Random word generation
+- Random word generation in English
 - Specify word length, what it starts and ends with, category, and even custom
-  regular expressions!
+  regular expressions
 - Use custom word lists and define custom categories of words
+- Generate structured random sentences
+- Basic profanity filtering
 - Beautiful command line interface
 - Easy-to-use interface and comprehensive documentation
 - Open source!
@@ -103,9 +97,13 @@ r.word(include_parts_of_speech=["nouns", "adjectives"])
 # generate a random word between the length of 3 and 8 characters
 r.word(word_min_length=3, word_max_length=8)
 
-# generate a random word with a custom regular expression
+# generate a random word with a custom Python regular expression
 r.word(regex=".*a")
 
+# some of the words in the default word lists have spaces, such as 'contact lens'
+# this option disables them
+r.word(exclude_with_spaces=True)
+
 # you can combine multiple filtering options
 r.word(starts_with="ru", word_max_length=10, include_parts_of_speech=["verbs"])
 ```
@@ -164,44 +162,96 @@ s.bare_bone_with_adjective()
 s.sentence()
 ```
 
+Words are organized in categories, such as "nouns", "verbs", and "adjectives".
+What if you had your own categories of words? You can specify your custom
+categories when instantiating the `RandomWord` class:
+
+```python
+from wonderwords import RandomWord
+
+cars = ["Chevrolet", "Subaru", "Tesla"]
+airplanes = ["Boeing", "Airbus", "Cessna"]
+w = RandomWord(cars=cars, airplanes=airplanes)
+
+# Will return a random car or airplane
+w.word()
+
+# Will return a random car
+w.word(include_categories=["cars"])
+
+# You can also mix and match custom categories with defaults
+from wonderwords import Defaults
+proper_nouns = ["Austin", "Seattle", "New York"]
+w2 = RandomWord(proper_nouns=proper_nouns, common_nouns=Defaults.NOUNS)
+
+# Will return either Seattle or seat
+w.word(regex="[Ss]eat.*")
+```
+
+Finally, starting with version 2.3, Wonderwords has explicit support for filtering
+out profanities from lists of words. At the moment, this is rudimentary:
+
+```python
+from wonderwords import is_profanity, filter_profanity
+
+# Test against words that could possibly be offensive. Good of user-facing apps.
+is_profanity("apple") # False
+
+# Can be done with a list
+words = [ ... ]
+# The function returns a generator, so we convert it to a list
+words_clean = list(filter_profanity(words))
+```
+
 More advanced usage (and a tutorial!) is found in the documentation, such as
 adding custom categories of words. The full documentation with all information
 can be found at: https://wonderwords.readthedocs.io
 
 ## The Wonderwords CLI
 
+**NOTE**: Before using the command-line interface (CLI), ensure that you installed
+all required dependencies for the CLI using `pip install wonderwords[cli]`.
+Wonderwords normally requires no dependencies, but uses Rich for colorized
+output in the command line.
+
 Wonderwords provides a command line interface, too, which can be used with the
 `wonderwords` command. Usage:
 
 ```
-usage: wonderwords [-h] [-w] [-f] [-l LIST] [-s {bb,ss,bba,s}] [-v] [-sw STARTS_WITH] [-ew ENDS_WITH]
-                   [-p {noun,verb,adjective,nouns,verbs,adjectives} [{noun,verb,adjective,nouns,verbs,adjectives} ...]] [-min WORD_MIN_LENGTH]
-                   [-max WORD_MAX_LENGTH] [-r REGEX] [-d DELIMITER]
+usage: wonderwords [-h] [-w] [-f] [-l LIST] [-s {bb,ss,bba,s}] [-v] [-S STARTS_WITH] [-e ENDS_WITH]
+                   [-p {noun,verb,adjective,nouns,verbs,adjectives} [{noun,verb,adjective,nouns,verbs,adjectives} ...]]
+                   [-m WORD_MIN_LENGTH] [-M WORD_MAX_LENGTH] [-r REGEX] [-x] [-d DELIMITER] [-E]
 
-optional arguments:
+Generate random words and sentences from the command line. Here is a full list of available commands. To learn more
+about each command, go to the documentation at https://wonderwords.readthedocs.io
+
+options:
   -h, --help            show this help message and exit
   -w, --word, --random-word
                         generate a random word
-  -f, --filter          filter a list of words matching the criteria specified
-  -l LIST, --list LIST  return a list of words of a certain length
+  -f, --filter          get a list of all known words matching the criteria specified
+  -l LIST, --list LIST  return a list of a certain length of random words
   -s {bb,ss,bba,s}, --sentence {bb,ss,bba,s}
                         return a sentence based on the structure chosen
-  -v, --version         Print the version number and exit
-  -sw STARTS_WITH, --starts-with STARTS_WITH
-                        specify what string the random word(s) should start with
-  -ew ENDS_WITH, --ends-with ENDS_WITH
-                        specify what string the random word(s) should end with
+  -v, --version         print the version number and exit
+  -S STARTS_WITH, --starts-with STARTS_WITH
+                        strings the random word(s) should start with
+  -e ENDS_WITH, --ends-with ENDS_WITH
+                        strings the random word(s) should end with
   -p {noun,verb,adjective,nouns,verbs,adjectives} [{noun,verb,adjective,nouns,verbs,adjectives} ...], --parts-of-speech {noun,verb,adjective,nouns,verbs,adjectives} [{noun,verb,adjective,nouns,verbs,adjectives} ...]
-                        specify to only include certain parts of speech (by default all parts of speech are included)
-  -min WORD_MIN_LENGTH, --word-min-length WORD_MIN_LENGTH
-                        specify the minimum length of the word(s)
-  -max WORD_MAX_LENGTH, --word-max-length WORD_MAX_LENGTH
-                        specify the maximum length of the word(s)
+                        only include certain parts of speech (by default all parts of speech are included)
+  -m WORD_MIN_LENGTH, --word-min-length WORD_MIN_LENGTH
+                        minimum length of the word(s)
+  -M WORD_MAX_LENGTH, --word-max-length WORD_MAX_LENGTH
+                        maximum length of the word(s)
   -r REGEX, --regex REGEX, --re REGEX, --regular-expression REGEX
-                        specify a python-style regular expression that every word must match
+                        a python-style regular expression for the word(s) to match
+  -x, --exclude-with-spaces
+                        exclude open compounds, such as 'contact lens'
   -d DELIMITER, --delimiter DELIMITER
-                        Specify the delimiter to put between a list of words, default is ', '
-
+                        specify the delimiter to put between a list of words, default is ', '
+  -E, --suppress-error-on-less
+                        suppress errors when less words are returned in a list then wanted
 ```
 
 The basic commands are:
@@ -229,7 +279,7 @@ for more details.
 
 # Contributing
 
-All contributions are welcome and we hope Wonderwords will continue growing.
+All contributions are welcome, and we hope Wonderwords will continue growing.
 Start out by reading `CONTRIBUTING.md` for contributing guidelines and how to
 get started.
 
diff --git a/assets/config.yml b/assets/config.yml
deleted file mode 100644
index 82ac3c9..0000000
--- a/assets/config.yml
+++ /dev/null
@@ -1,107 +0,0 @@
-# Specify a command to be executed
-# like `/bin/bash -l`, `ls`, or any other commands
-# the default is bash for Linux
-# or powershell.exe for Windows
-command: null
-
-# Specify the current working directory path
-# the default is the current working directory path
-cwd: /home/maxim/
-
-# Export additional ENV variables
-env:
-  recording: true
-
-# Explicitly set the number of columns
-# or use `auto` to take the current
-# number of columns of your shell
-cols: auto
-
-# Explicitly set the number of rows
-# or use `auto` to take the current
-# number of rows of your shell
-rows: auto
-
-# Amount of times to repeat GIF
-# If value is -1, play once
-# If value is 0, loop indefinitely
-# If value is a positive number, loop n times
-repeat: 0
-
-# Quality
-# 1 - 100
-quality: 100
-
-# Delay between frames in ms
-# If the value is `auto` use the actual recording delays
-frameDelay: auto
-
-# Maximum delay between frames in ms
-# Ignored if the `frameDelay` isn't set to `auto`
-# Set to `auto` to prevent limiting the max idle time
-maxIdleTime: 2000
-
-# The surrounding frame box
-# The `type` can be null, window, floating, or solid`
-# To hide the title use the value null
-# Don't forget to add a backgroundColor style with a null as type
-frameBox:
-  type: floating
-  title: Wonderwords
-  style:
-    border: 0px black solid
-    # boxShadow: none
-    # margin: 0px
-
-# Add a watermark image to the rendered gif
-# You need to specify an absolute path for
-# the image on your machine or a URL, and you can also
-# add your own CSS styles
-watermark:
-  imagePath: null
-  style:
-    position: absolute
-    right: 15px
-    bottom: 15px
-    width: 100px
-    opacity: 0.9
-
-# Cursor style can be one of
-# `block`, `underline`, or `bar`
-cursorStyle: block
-
-# Font family
-# You can use any font that is installed on your machine
-# in CSS-like syntax
-fontFamily: "Monaco, Lucida Console, Ubuntu Mono, Monospace"
-
-# The size of the font
-fontSize: 12
-
-# The height of lines
-lineHeight: 1
-
-# The spacing between letters
-letterSpacing: 0
-
-# Theme
-theme:
-  background: "transparent"
-  foreground: "#afafaf"
-  cursor: "#c7c7c7"
-  black: "#232628"
-  red: "#fc4384"
-  green: "#b3e33b"
-  yellow: "#ffa727"
-  blue: "#75dff2"
-  magenta: "#ae89fe"
-  cyan: "#708387"
-  white: "#d5d5d0"
-  brightBlack: "#626566"
-  brightRed: "#ff7fac"
-  brightGreen: "#c8ed71"
-  brightYellow: "#ebdf86"
-  brightBlue: "#75dff2"
-  brightMagenta: "#ae89fe"
-  brightCyan: "#b1c6ca"
-  brightWhite: "#f9f9f4"
diff --git a/assets/main.gif b/assets/main.gif
deleted file mode 100644
index bf94300..0000000
Binary files a/assets/main.gif and /dev/null differ
diff --git a/assets/main.yml b/assets/main.yml
deleted file mode 100644
index 1a357c6..0000000
--- a/assets/main.yml
+++ /dev/null
@@ -1,607 +0,0 @@
-# The configurations that used for the recording, feel free to edit them
-config:
-
-  # Specify a command to be executed
-  # like `/bin/bash -l`, `ls`, or any other commands
-  # the default is bash for Linux
-  # or powershell.exe for Windows
-  command: bash -l
-
-  # Specify the current working directory path
-  # the default is the current working directory path
-  cwd: /home/maxim
-
-  # Export additional ENV variables
-  env:
-    recording: true
-
-  # Explicitly set the number of columns
-  # or use `auto` to take the current
-  # number of columns of your shell
-  cols: 100
-
-  # Explicitly set the number of rows
-  # or use `auto` to take the current
-  # number of rows of your shell
-  rows: 24
-
-  # Amount of times to repeat GIF
-  # If value is -1, play once
-  # If value is 0, loop indefinitely
-  # If value is a positive number, loop n times
-  repeat: 0
-
-  # Quality
-  # 1 - 100
-  quality: 100
-
-  # Delay between frames in ms
-  # If the value is `auto` use the actual recording delays
-  frameDelay: auto
-
-  # Maximum delay between frames in ms
-  # Ignored if the `frameDelay` isn't set to `auto`
-  # Set to `auto` to prevent limiting the max idle time
-  maxIdleTime: 2000
-
-  # The surrounding frame box
-  # The `type` can be null, window, floating, or solid`
-  # To hide the title use the value null
-  # Don't forget to add a backgroundColor style with a null as type
-  frameBox:
-    type: floating
-    title: Wonderwords
-    style:
-      border: 0px black solid
-      # boxShadow: none
-      # margin: 0px
-
-  # Add a watermark image to the rendered gif
-  # You need to specify an absolute path for
-  # the image on your machine or a URL, and you can also
-  # add your own CSS styles
-  watermark:
-    imagePath: null
-    style:
-      position: absolute
-      right: 15px
-      bottom: 15px
-      width: 100px
-      opacity: 0.9
-
-  # Cursor style can be one of
-  # `block`, `underline`, or `bar`
-  cursorStyle: block
-
-  # Font family
-  # You can use any font that is installed on your machine
-  # in CSS-like syntax
-  fontFamily: "Monaco, Lucida Console, Ubuntu Mono, Monospace"
-
-  # The size of the font
-  fontSize: 12
-
-  # The height of lines
-  lineHeight: 1
-
-  # The spacing between letters
-  letterSpacing: 0
-
-  # Theme
-  theme:
-    background: "transparent"
-    foreground: "#afafaf"
-    cursor: "#c7c7c7"
-    black: "#232628"
-    red: "#fc4384"
-    green: "#b3e33b"
-    yellow: "#ffa727"
-    blue: "#75dff2"
-    magenta: "#ae89fe"
-    cyan: "#708387"
-    white: "#d5d5d0"
-    brightBlack: "#626566"
-    brightRed: "#ff7fac"
-    brightGreen: "#c8ed71"
-    brightYellow: "#ebdf86"
-    brightBlue: "#75dff2"
-    brightMagenta: "#ae89fe"
-    brightCyan: "#b1c6ca"
-    brightWhite: "#f9f9f4"
-
-# Records, feel free to edit them
-records:
-  - delay: 100
-    content: "\e]0;maxim@maxim-laptop:~\a\e]7;file://maxim-laptop/home/maxim\a\e]0;maxim@maxim-laptop: ~\a\e[01;32mmaxim@maxim-laptop\e[00m:\e[01;34m~\e[00m$ "
-  - delay: 300
-    content: '#'
-  - delay: 140
-    content: ' '
-  - delay: 140
-    content: W
-  - delay: 140
-    content: o
-  - delay: 140
-    content: 'n'
-  - delay: 140
-    content: d
-  - delay: 140
-    content: e
-  - delay: 140
-    content: r
-  - delay: 140
-    content: w
-  - delay: 140
-    content: o
-  - delay: 140
-    content: r
-  - delay: 140
-    content: d
-  - delay: 140
-    content: s
-  - delay: 140
-    content: '!'
-  - delay: 140
-    content: "\r\n"
-  - delay: 100
-    content: "\e]0;maxim@maxim-laptop:~\a\e]7;file://maxim-laptop/home/maxim\a\e]0;maxim@maxim-laptop: ~\a\e[01;32mmaxim@maxim-laptop\e[00m:\e[01;34m~\e[00m$ "
-  - delay: 300
-    content: w
-  - delay: 140
-    content: o
-  - delay: 140
-    content: 'n'
-  - delay: 140
-    content: d
-  - delay: 140
-    content: e
-  - delay: 140
-    content: r
-  - delay: 140
-    content: w
-  - delay: 140
-    content: o
-  - delay: 140
-    content: r
-  - delay: 140
-    content: d
-  - delay: 140
-    content: s
-  - delay: 140
-    content: "\r\n"
-  - delay: 200
-    content: "\e[1;38;5;17;47m╭──────────────────────────────────────────────────────────────────────────────────────────────────╮\e[0m\r\n\e[1;38;5;17;47m│\e[0m\e[1;38;5;17;47m                                                                                                  \e[0m\e[1;38;5;17;47m│\e[0m\r\n\e[1;38;5;17;47m│\e[0m\e[1;38;5;17;47m \e[0m\e[1;38;5;17;47m                                      WONDERWORDS 2.0.0a1                                       \e[0m\e[1;38;5;17;47m \e[0m\e[1;38;5;17;47m│\e[0m\r\n\e[1;38;5;17;47m│\e[0m\e[1;38;5;17;47m                                                                                                  \e[0m\e[1;38;5;17;47m│\e[0m\r\n\e[1;38;5;17;47m╰──────────────────────────────────────────────────────────────────────────────────────────────────╯\e[0m\r\n                                                                                                    \r\n \e[1m                                       No commands given \U0001F61E                                       \e[0m \r\n                                                                                                    \r\n"
-  - delay: 140
-    content: "\r\n                                         \e[1;4mAvailable Commands\e[0m                                         \r\n\r\n\e[1;33m • \e[0m\e[97;40mwonderwords -w\e[0m - generate a random word                                                          \r\n\e[1;33m • \e[0m\e[97;40mwonderwords -f\e[0m - get all words matching a certain criteria                                       \r\n\e[1;33m • \e[0m\e[97;40mwonderwords -l AMOUNT\e[0m - get a list of \e[97;40mAMOUNT\e[0m random words                                        \r\n\e[1;33m • \e[0m\e[97;40mwonderwords -s SENT_TYPE\e[0m - generate a random sentence of a certain type                          \r\n\r\nFor a list of all options, type \e[97;40mwonderwords -h\e[0m. To see a detailed and comprehensive explanation of  \r\nthe commands, visit \e]8;id=1599667951.0427635-512407;https://wonderwords.readthedocs.io\e\\\e[94mthe documentation\e[0m\e]8;;\e\\                                                               \r\n"
-  - delay: 100
-    content: "\e]0;maxim@maxim-laptop:~\a\e]7;file://maxim-laptop/home/maxim\a\e]0;maxim@maxim-laptop: ~\a\e[01;32mmaxim@maxim-laptop\e[00m:\e[01;34m~\e[00m$ "
-  - delay: 1000
-    content: '#'
-  - delay: 140
-    content: ' '
-  - delay: 140
-    content: l
-  - delay: 140
-    content: e
-  - delay: 140
-    content: t
-  - delay: 140
-    content: ''''
-  - delay: 140
-    content: s
-  - delay: 140
-    content: ' '
-  - delay: 140
-    content: g
-  - delay: 140
-    content: e
-  - delay: 140
-    content: 'n'
-  - delay: 140
-    content: e
-  - delay: 140
-    content: r
-  - delay: 140
-    content: a
-  - delay: 140
-    content: t
-  - delay: 140
-    content: e
-  - delay: 140
-    content: ' '
-  - delay: 140
-    content: a
-  - delay: 140
-    content: ' '
-  - delay: 140
-    content: r
-  - delay: 140
-    content: a
-  - delay: 140
-    content: 'n'
-  - delay: 140
-    content: d
-  - delay: 140
-    content: o
-  - delay: 140
-    content: m
-  - delay: 140
-    content: ' '
-  - delay: 140
-    content: w
-  - delay: 140
-    content: o
-  - delay: 140
-    content: r
-  - delay: 140
-    content: d
-  - delay: 140
-    content: "\r\n"
-  - delay: 100
-    content: "\e]0;maxim@maxim-laptop:~\a\e]7;file://maxim-laptop/home/maxim\a\e]0;maxim@maxim-laptop: ~\a\e[01;32mmaxim@maxim-laptop\e[00m:\e[01;34m~\e[00m$ "
-  - delay: 300
-    content: w
-  - delay: 140
-    content: o
-  - delay: 140
-    content: 'n'
-  - delay: 140
-    content: d
-  - delay: 140
-    content: e
-  - delay: 140
-    content: r
-  - delay: 140
-    content: w
-  - delay: 140
-    content: o
-  - delay: 140
-    content: r
-  - delay: 140
-    content: d
-  - delay: 140
-    content: s
-  - delay: 140
-    content: ' '
-  - delay: 140
-    content: '-'
-  - delay: 140
-    content: w
-  - delay: 140
-    content: "\r\n"
-  - delay: 140
-    content: "\e[1;37;48;5;17mplastic\e[0m\r\n"
-  - delay: 140
-    content: "\e]0;maxim@maxim-laptop:~\a\e]7;file://maxim-laptop/home/maxim\a\e]0;maxim@maxim-laptop: ~\a\e[01;32mmaxim@maxim-laptop\e[00m:\e[01;34m~\e[00m$ "
-  - delay: 1000
-    content: '#'
-  - delay: 140
-    content: ' '
-  - delay: 140
-    content: h
-  - delay: 140
-    content: o
-  - delay: 140
-    content: w
-  - delay: 140
-    content: ' '
-  - delay: 140
-    content: a
-  - delay: 140
-    content: b
-  - delay: 140
-    content: o
-  - delay: 140
-    content: u
-  - delay: 140
-    content: t
-  - delay: 140
-    content: ' '
-  - delay: 140
-    content: '5'
-  - delay: 140
-    content: ' '
-  - delay: 140
-    content: w
-  - delay: 140
-    content: o
-  - delay: 140
-    content: r
-  - delay: 140
-    content: d
-  - delay: 140
-    content: s
-  - delay: 140
-    content: '?'
-  - delay: 140
-    content: "\r\n"
-  - delay: 100
-    content: "\e]0;maxim@maxim-laptop:~\a\e]7;file://maxim-laptop/home/maxim\a\e]0;maxim@maxim-laptop: ~\a\e[01;32mmaxim@maxim-laptop\e[00m:\e[01;34m~\e[00m$ "
-  - delay: 300
-    content: w
-  - delay: 140
-    content: o
-  - delay: 140
-    content: 'n'
-  - delay: 140
-    content: d
-  - delay: 140
-    content: e
-  - delay: 140
-    content: r
-  - delay: 140
-    content: w
-  - delay: 140
-    content: o
-  - delay: 140
-    content: r
-  - delay: 140
-    content: d
-  - delay: 140
-    content: s
-  - delay: 140
-    content: ' '
-  - delay: 140
-    content: '-'
-  - delay: 140
-    content: l
-  - delay: 140
-    content: ' '
-  - delay: 140
-    content: '5'
-  - delay: 140
-    content: "\r\n"
-  - delay: 140
-    content: "\e[1;37;48;5;17mspark, hashtag, citron, doctrine, promote\e[0m\r\n"
-  - delay: 100
-    content: "\e]0;maxim@maxim-laptop:~\a\e]7;file://maxim-laptop/home/maxim\a\e]0;maxim@maxim-laptop: ~\a\e[01;32mmaxim@maxim-laptop\e[00m:\e[01;34m~\e[00m$ "
-  - delay: 1000
-    content: '#'
-  - delay: 140
-    content: ' '
-  - delay: 140
-    content: l
-  - delay: 140
-    content: e
-  - delay: 140
-    content: t
-  - delay: 140
-    content: ' '
-  - delay: 140
-    content: u
-  - delay: 140
-    content: s
-  - delay: 140
-    content: ' '
-  - delay: 140
-    content: g
-  - delay: 140
-    content: e
-  - delay: 140
-    content: 'n'
-  - delay: 140
-    content: e
-  - delay: 140
-    content: r
-  - delay: 140
-    content: a
-  - delay: 140
-    content: t
-  - delay: 140
-    content: e
-  - delay: 140
-    content: ' '
-  - delay: 140
-    content: a
-  - delay: 140
-    content: ' '
-  - delay: 140
-    content: r
-  - delay: 140
-    content: a
-  - delay: 140
-    content: 'n'
-  - delay: 140
-    content: d
-  - delay: 140
-    content: o
-  - delay: 140
-    content: m
-  - delay: 140
-    content: ' '
-  - delay: 140
-    content: s
-  - delay: 140
-    content: e
-  - delay: 140
-    content: 'n'
-  - delay: 140
-    content: t
-  - delay: 140
-    content: e
-  - delay: 140
-    content: 'n'
-  - delay: 140
-    content: c
-  - delay: 140
-    content: e
-  - delay: 100
-    content: "\r\n\e]0;maxim@maxim-laptop:~\a\e]7;file://maxim-laptop/home/maxim\a\e]0;maxim@maxim-laptop: ~\a\e[01;32mmaxim@maxim-laptop\e[00m:\e[01;34m~\e[00m$ "
-  - delay: 300
-    content: w
-  - delay: 140
-    content: o
-  - delay: 140
-    content: 'n'
-  - delay: 140
-    content: d
-  - delay: 140
-    content: e
-  - delay: 140
-    content: r
-  - delay: 140
-    content: w
-  - delay: 140
-    content: o
-  - delay: 140
-    content: r
-  - delay: 140
-    content: d
-  - delay: 140
-    content: s
-  - delay: 140
-    content: ' '
-  - delay: 140
-    content: '-'
-  - delay: 140
-    content: s
-  - delay: 140
-    content: ' '
-  - delay: 140
-    content: s
-  - delay: 140
-    content: "\r\n"
-  - delay: 100
-    content: "\e[1;37;48;5;22mThe zany casino unites backpack.\e[0m\r\n"
-  - delay: 100
-    content: "\e]0;maxim@maxim-laptop:~\a\e]7;file://maxim-laptop/home/maxim\a\e]0;maxim@maxim-laptop: ~\a\e[01;32mmaxim@maxim-laptop\e[00m:\e[01;34m~\e[00m$ "
-  - delay: 1000
-    content: '#'
-  - delay: 140
-    content: ' '
-  - delay: 140
-    content: c
-  - delay: 140
-    content: o
-  - delay: 140
-    content: o
-  - delay: 140
-    content: l
-  - delay: 140
-    content: ','
-  - delay: 140
-    content: ' '
-  - delay: 140
-    content: h
-  - delay: 140
-    content: o
-  - delay: 140
-    content: w
-  - delay: 140
-    content: ' '
-  - delay: 140
-    content: a
-  - delay: 140
-    content: b
-  - delay: 140
-    content: o
-  - delay: 140
-    content: u
-  - delay: 140
-    content: t
-  - delay: 140
-    content: ' '
-  - delay: 140
-    content: a
-  - delay: 140
-    content: ' '
-  - delay: 140
-    content: w
-  - delay: 140
-    content: o
-  - delay: 140
-    content: r
-  - delay: 140
-    content: d
-  - delay: 140
-    content: ' '
-  - delay: 140
-    content: t
-  - delay: 140
-    content: h
-  - delay: 140
-    content: a
-  - delay: 140
-    content: t
-  - delay: 140
-    content: ' '
-  - delay: 140
-    content: s
-  - delay: 140
-    content: t
-  - delay: 140
-    content: a
-  - delay: 140
-    content: r
-  - delay: 140
-    content: t
-  - delay: 140
-    content: s
-  - delay: 140
-    content: ' '
-  - delay: 140
-    content: w
-  - delay: 140
-    content: i
-  - delay: 140
-    content: t
-  - delay: 140
-    content: h
-  - delay: 140
-    content: ' '
-  - delay: 140
-    content: a
-  - delay: 140
-    content: '?'
-  - delay: 140
-    content: "\r\n"
-  - delay: 100
-    content: "\e]0;maxim@maxim-laptop:~\a\e]7;file://maxim-laptop/home/maxim\a\e]0;maxim@maxim-laptop: ~\a\e[01;32mmaxim@maxim-laptop\e[00m:\e[01;34m~\e[00m$ "
-  - delay: 300
-    content: w
-  - delay: 140
-    content: o
-  - delay: 140
-    content: 'n'
-  - delay: 140
-    content: d
-  - delay: 140
-    content: e
-  - delay: 140
-    content: r
-  - delay: 140
-    content: w
-  - delay: 140
-    content: o
-  - delay: 140
-    content: r
-  - delay: 140
-    content: d
-  - delay: 140
-    content: s
-  - delay: 140
-    content: ' '
-  - delay: 140
-    content: '-'
-  - delay: 140
-    content: w
-  - delay: 140
-    content: ' '
-  - delay: 140
-    content: '-'
-  - delay: 140
-    content: s
-  - delay: 140
-    content: w
-  - delay: 140
-    content: ' '
-  - delay: 140
-    content: a
-  - delay: 140
-    content: "\r\n"
-  - delay: 140
-    content: "\e[1;37;48;5;17mappreciate\e[0m\r\n"
-  - delay: 140
-    content: "\e]0;maxim@maxim-laptop:~\a\e]7;file://maxim-laptop/home/maxim\a\e]0;maxim@maxim-laptop: ~\a\e[01;32mmaxim@maxim-laptop\e[00m:\e[01;34m~\e[00m$ "
-  - delay: 600
-    content: ' '
diff --git a/docs/advanced.rst b/docs/advanced.rst
index 5b18f02..79481a8 100644
--- a/docs/advanced.rst
+++ b/docs/advanced.rst
@@ -26,20 +26,19 @@ of words in those categories.
 
 Here's an example::
 
-  from wonderwords import RandomWord
+    from wonderwords import RandomWord
 
-  fruits = ["apple", "orange", "banana", "strawberry"]
-  generator = RandomWord(fruit=fruits)
+    fruits = ["apple", "orange", "banana", "strawberry"]
+    generator = RandomWord(fruits=fruits)
 
-  print(generator.word()) # ex: apple
+    print(generator.word()) # ex: apple
 
 Let's break this down:
 
 1. First, we import the ``RandomWord`` class from ``wonderwords``.
 2. We then define a list of fruits.
 3. After that, we create a new instance of ``RandomWord``, where we create a
-   category called ``fruit``. Normally, we define category names in the singular
-   form, so ``fruit`` instead of ``fruits``.
+   category called ``fruits``.
 4. We print a random word from all the available categories.
 
 All of the arguments we had when generating random words from the default word
@@ -49,37 +48,37 @@ custom categories.
 
 We can add as many word lists as we want::
 
-  animals = ["cow", "cat", "dog", "elephant"]
-  plants = ["tree", "grass", "sunflower"]
-  generator2 = RandomWord(animal=animals, plant=plants)
+    animals = ["cow", "cat", "dog", "elephant"]
+    plants = ["tree", "grass", "sunflower"]
+    generator2 = RandomWord(animals=animals, plants=plants)
 
-  print(generator2.word()) # ex: grass (all categories are enabled by default)
-  print(generator2.word()) # ex: cat
-  print(generator2.word(include_categories=["animal"])) # ex: dog
+    print(generator2.word()) # ex: grass (all categories are enabled by default)
+    print(generator2.word()) # ex: cat
+    print(generator2.word(include_categories=["animals"])) # ex: dog
 
 As illustrated in the example above, we can include only specific categories
 with ``include_categories``. We have already seen this argument before, when
-specifying parts of speech, such as "noun" and "verb". But now, we can no longer
+specifying parts of speech, such as "nouns" and "verbs". But now, we can no longer
 generate random nouns, verbs, and adjectives. The following code won't work::
 
-  generator2.word(include_categories=["noun"])
-  # ValueError: 'noun' is an invalid category
-  # :(
+    generator2.word(include_categories=["nouns"])
+    # ValueError: 'noun' is an invalid category
+    # :(
 
 This is because when we specify custom categories, the default configuration is
 overwritten. What if we want both a custom category, and one of the default
 categories as well? This can be done with ``Defaults``::
 
-  from wonderwords import RandomWord, Defaults
+    from wonderwords import RandomWord, Defaults
 
-  writing_utensils = ["graphite pencil", "pen", "marker", "colored pencil"]
-  generator = RandomWord(
-      utensil=writing_utensils,
-      adjective=Defaults.ADJECTIVES
-  )
-  print(generator.word()) # ex: angry
-  print(generator.word(include_categories=["utensil"])) # ex: marker
-  print(generator.word(include_categories=["adjective"])) # ex: sparkling
+    writing_utensils = ["graphite pencil", "pen", "marker", "colored pencil"]
+    generator = RandomWord(
+        utensils=writing_utensils,
+        adjectives=Defaults.ADJECTIVES
+    )
+    print(generator.word()) # ex: angry
+    print(generator.word(include_categories=["utensils"])) # ex: marker
+    print(generator.word(include_categories=["adjectives"])) # ex: sparkling
 
 ``Defaults`` is a python object that has a number of constants representing
 various default categories. We can specify one of these categories instead of
@@ -100,24 +99,24 @@ With all of that, let's get back to our random name generator. First, we'll do
 some initial setup. Put the following lines at the top of
 ``random_name_generator.py``::
 
-  from wonderwords import RandomWord
+    from wonderwords import RandomWord
 
-  # Note: here, we put the names in a list, but when you're writing code with
-  # large lists, you typically put them in a file, and read from there.
-  FIRST_NAMES = ["Jane", "Bob", "Anne", "Max", "Jake"]
-  LAST_NAMES = ["Jacobson", "Johnson", "Miller", "Rodriguez", "Davis"]
+    # Note: here, we put the names in a list, but when you're writing code with
+    # large lists, you typically put them in a file, and read from there.
+    FIRST_NAMES = ["Jane", "Bob", "Anne", "Max", "Jake"]
+    LAST_NAMES = ["Jacobson", "Johnson", "Miller", "Rodriguez", "Davis"]
 
 Here we import ``RandomWord`` and create a list of first names, and a list of
 surnames. Now, let's create an instance of the ``RandomWord`` class::
 
-  generator = RandomWord(name=FIRST_NAMES, surname=LAST_NAMES)
+    generator = RandomWord(names=FIRST_NAMES, surnames=LAST_NAMES)
 
 Here we create a random word generator with two categories: ``name`` and
 ``surname``. We pass the lists we defined earlier to the categories. Now it's
 time to write our ``main`` function, where the bulk of our code will reside::
 
-  def main():
-      while True:
+    def main():
+        while True:
           # We put this here, so that the user can chose to generate another
           # word or quit.
           action = input("Generate (enter) or quit (q) ").strip()
@@ -125,8 +124,8 @@ time to write our ``main`` function, where the bulk of our code will reside::
           if action.lower() == "q":
               break
 
-          first_name = generator.word(include_categories=["name"])
-          last_name = generator.word(include_categories=["surname"])
+          first_name = generator.word(include_categories=["names"])
+          last_name = generator.word(include_categories=["surnames"])
           print(first_name, last_name)
       print("Thanks for using the generator!")
 
@@ -141,11 +140,32 @@ specify the categories used. Finally, we print the generated full name.
 
 The only thing left is to call our main function::
 
-  if __name__ == "__main__":
-      main()
+    if __name__ == "__main__":
+        main()
 
 In the code above, we call the ``main`` function as long as we run the code
 directly. If someone imports our code, the ``main`` function won't run.
 
+Filtering Profanity
+^^^^^^^^^^^^^^^^^^^
+
+Wonderwords supports a basic way to filter words that may be unsafe or profane
+for applications using the word list represented by ``Defaults.PROFANITIES``. There
+are two main functions to deal with profanity detection and removal.
+
+The first function, ``is_profanity`` returns ``True`` if the word was found in
+the Wonderwords profanity list. The second, ``filter_profanity``, takes an iterator
+of words and returns a generator with the same words, but with any possible curse
+words filtered out::
+
+    from wonderwords import is_profanity, filter_profanity
+
+    is_profanity("apple") # False
+    is_profanity("piss") # True
+
+    words = ["apple", "PiSS ", " orange"]
+    # Convert the generator into a list
+    list(filter_profanity(words)) # ["apple", " orange"]
+
 That's it! If you've read this far, you have completely mastered Wonderwords.
 Go on, and put your newly learned skills to practice.
diff --git a/docs/api_docs/api.rst b/docs/api_docs/api.rst
new file mode 100644
index 0000000..7d99cf2
--- /dev/null
+++ b/docs/api_docs/api.rst
@@ -0,0 +1,21 @@
+
+API Documentation
+=================
+
+.. autoexception:: wonderwords.NoWordsToChooseFrom
+
+.. autoclass:: wonderwords.Defaults
+    :members:
+    :undoc-members:
+
+.. autoclass:: wonderwords.RandomWord
+    :members:
+    :undoc-members:
+
+.. autoclass:: wonderwords.RandomSentence
+    :members:
+    :undoc-members:
+
+.. autofunction:: wonderwords.is_profanity
+
+.. autofunction:: wonderwords.filter_profanity
diff --git a/docs/api_docs/cli.rst b/docs/api_docs/cli.rst
index 7b94b91..b82cfd8 100644
--- a/docs/api_docs/cli.rst
+++ b/docs/api_docs/cli.rst
@@ -5,83 +5,56 @@ Command Line Interface
 ======================
 
 The Wonderwords command line interface can be accessed with the ``wonderwords``
-command. Usage::
-
-  usage: wonderwords [-h] [-w] [-f] [-l LIST] [-s {bb,ss,bba,s}] [-v] [-sw STARTS_WITH] [-ew ENDS_WITH]
-                     [-p {noun,verb,adjective,nouns,verbs,adjectives} [{noun,verb,adjective,nouns,verbs,adjectives} ...]] [-min WORD_MIN_LENGTH]
-                     [-max WORD_MAX_LENGTH] [-r REGEX] [-d DELIMITER]
-
-  optional arguments:
-    -h, --help            show this help message and exit
-    -w, --word, --random-word
-                          generate a random word
-    -f, --filter          filter a list of words matching the criteria specified
-    -l LIST, --list LIST  return a list of words of a certain length
-    -s {bb,ss,bba,s}, --sentence {bb,ss,bba,s}
-                          return a sentence based on the structure chosen
-    -v, --version         Print the version number and exit
-    -sw STARTS_WITH, --starts-with STARTS_WITH
-                          specify what string the random word(s) should start with
-    -ew ENDS_WITH, --ends-with ENDS_WITH
-                          specify what string the random word(s) should end with
-    -p {noun,verb,adjective,nouns,verbs,adjectives} [{noun,verb,adjective,nouns,verbs,adjectives} ...], --parts-of-speech {noun,verb,adjective,nouns,verbs,adjectives} [{noun,verb,adjective,nouns,verbs,adjectives} ...]
-                          specify to only include certain parts of speech (by default all parts of speech are included)
-    -min WORD_MIN_LENGTH, --word-min-length WORD_MIN_LENGTH
-                          specify the minimum length of the word(s)
-    -max WORD_MAX_LENGTH, --word-max-length WORD_MAX_LENGTH
-                          specify the maximum length of the word(s)
-    -r REGEX, --regex REGEX, --re REGEX, --regular-expression REGEX
-                          specify a python-style regular expression that every word must match
-    -d DELIMITER, --delimiter DELIMITER
-                          Specify the delimiter to put between a list of words, default is ', '
-
-Core commands
--------------
-
-There are a number of core commands that provide basic functionality:
-
-* ``-w`` or ``--random-word``: generate a random word
-* ``-f`` or ``--filter``: return a list of words based on specified criteria
-* ``-l LIST`` or ``--list LIST``: much like filter, except you need to specify
-  an integer of the amount of words you want to get
-* ``-s {bb,ss,bba,s}`` or ``--sentence {bb,ss,bba,s}`` generate a sentence. You
-  must specify the sentence type from the following types:
-
-    * ``bb``: bare bone sentence
-    * ``ss``: simple sentence (bare bone sentence with a direct object)
-    * ``bba``: bare bone sentence with an adjective
-    * ``s``: simple sentence with an adjective
-
-* ``-v`` or ``--version``: return the version and exit
-* ``-h`` or ``--help``: show a list of commands
-
-Other commands
---------------
-
-The following commands apply only to ``random-word``, ``filter`` and ``list``:
-
-* ``-sw`` or ``--starts-with``: the string the word(s) must start with
-* ``-ew`` or ``--ends-with``:  the string the word(s) must end with
-* ``-p`` or ``--parts-of-speech``: only include certain parts of speech, choose
-  one or more from nouns, verbs and adjectives
-* ``-min`` or ``--word-min-length``: the minimum length of each word
-* ``-max`` or ``--word-max-length``: the maximum length of each word
-* ``-r`` or ``--regex`` or ``--re`` or ``--regular-expression``: use a custom
-  python regular expression in order to filter a word. All words that do not
-  fully match the expression will be removed.
-
-The following commands apply only to ``filter`` and ``list``:
-
-* ``-d DELIMITER`` or ``--delimiter DELIMITER``: the delimiter used to separate
-  words. Is ", " by default.
+command. Usage
+
+.. code-block:: text
+
+    usage: wonderwords [-h] [-w] [-f] [-l LIST] [-s {bb,ss,bba,s}] [-v] [-S STARTS_WITH]
+                       [-e ENDS_WITH]
+                       [-p {noun,verb,adjective,nouns,verbs,adjectives} [{noun,verb,adjective,nouns,verbs,adjectives} ...]]
+                       [-m WORD_MIN_LENGTH] [-M WORD_MAX_LENGTH] [-r REGEX] [-x] [-d DELIMITER]
+                       [-E]
+
+    Generate random words and sentences from the command line. Here is a full list of available
+    commands. To learn more about each command, go to the documentation at
+    https://wonderwords.readthedocs.io
+
+    options:
+      -h, --help            show this help message and exit
+      -w, --word, --random-word
+                            generate a random word
+      -f, --filter          get a list of all known words matching the criteria specified
+      -l LIST, --list LIST  return a list of a certain length of random words
+      -s {bb,ss,bba,s}, --sentence {bb,ss,bba,s}
+                            return a sentence based on the structure chosen
+      -v, --version         print the version number and exit
+      -S STARTS_WITH, --starts-with STARTS_WITH
+                            strings the random word(s) should start with
+      -e ENDS_WITH, --ends-with ENDS_WITH
+                            strings the random word(s) should end with
+      -p {noun,verb,adjective,nouns,verbs,adjectives} [{noun,verb,adjective,nouns,verbs,adjectives} ...], --parts-of-speech {noun,verb,adjective,nouns,verbs,adjectives} [{noun,verb,adjective,nouns,verbs,adjectives} ...]
+                            only include certain parts of speech (by default all parts of speech
+                            are included)
+      -m WORD_MIN_LENGTH, --word-min-length WORD_MIN_LENGTH
+                            minimum length of the word(s)
+      -M WORD_MAX_LENGTH, --word-max-length WORD_MAX_LENGTH
+                            maximum length of the word(s)
+      -r REGEX, --regex REGEX, --re REGEX, --regular-expression REGEX
+                            a python-style regular expression for the word(s) to match
+      -x, --exclude-with-spaces
+                            exclude open compounds, such as 'contact lens'
+      -d DELIMITER, --delimiter DELIMITER
+                            specify the delimiter to put between a list of words, default is ', '
+      -E, --suppress-error-on-less
+                            suppress errors when less words are returned in a list then wanted
 
 Examples
 --------
 
 ::
 
-  $ wonderwords -w -sw ma -max 5 # choose a random word which starts with "ma" and is a max length of 5
-  $ wonderwords -f -sw a -ew k -p nouns # choose all nouns that start with a, and ends with k
-  $ wonderwords -l 5 -sw n -d " & " # random a list of 5 nouns separated by " & " that start with "n"
+  $ wonderwords -w -S ma -M 5 # choose a random word which starts with "ma" and is a max length of 5
+  $ wonderwords -f -S a -e k -p nouns # choose all nouns that start with a, and ends with k
+  $ wonderwords -l 5 -S n -d " & " # random a list of 5 nouns separated by " & " that start with "n"
   $ wonderwords -s bb # generate a random bare-bone sentence
   $ wonderwords -v # print the version
diff --git a/docs/api_docs/index.rst b/docs/api_docs/index.rst
index 83aa57b..f9e69e9 100644
--- a/docs/api_docs/index.rst
+++ b/docs/api_docs/index.rst
@@ -11,6 +11,5 @@ start with :ref:`the quickstart <quickstart>`.
 .. toctree::
   :maxdepth: 2
 
-  random_word
-  random_sentence
+  api
   cli
diff --git a/docs/api_docs/random_sentence.rst b/docs/api_docs/random_sentence.rst
deleted file mode 100644
index b1db6aa..0000000
--- a/docs/api_docs/random_sentence.rst
+++ /dev/null
@@ -1,8 +0,0 @@
-
-wonderwords.random_sentence
-===========================
-
-.. automodule:: wonderwords.random_sentence
-    :members:
-    :show-inheritance:
-    :exclude-members:
diff --git a/docs/api_docs/random_word.rst b/docs/api_docs/random_word.rst
deleted file mode 100644
index 9734586..0000000
--- a/docs/api_docs/random_word.rst
+++ /dev/null
@@ -1,8 +0,0 @@
-
-wonderwords.random_word
-=======================
-
-.. automodule:: wonderwords.random_word
-    :members:
-    :show-inheritance:
-    :exclude-members: read_words
diff --git a/docs/conf.py b/docs/conf.py
index dc0017e..c8abf05 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -18,8 +18,8 @@
 # -- Project information -----------------------------------------------------
 
 project = "Wonderwords"
-copyright = "2020, Maxim R (mrmaxguns)"
-author = "Maxim R (mrmaxguns)"
+copyright = "2020, Maxim Rebguns (mrmaxguns)"
+author = "Maxim Rebguns (mrmaxguns)"
 
 
 # -- General configuration ---------------------------------------------------
diff --git a/docs/index.rst b/docs/index.rst
index f917567..36526ae 100644
--- a/docs/index.rst
+++ b/docs/index.rst
@@ -4,29 +4,30 @@ Wonderwords Official Documentation
 ==================================
 
 Wonderwords is a python package with a command line interface with the purpose
-of generating random words and sentences. Wonderwords is **free** and **open
-source**, meaning anyone can contribute to its repository.
+of generating random words and sentences. Wonderwords is free software under
+the MIT license. Contributions to the library are welcome.
+
+While Wonderwords supports English by default, custom word lists can be
+used to generate and filter words in other languages as well.
 
 Here is a simple example::
 
-  >>> from wonderwords import RandomWord
-  >>>
-  >>> generator = RandomWord()
-  >>> generator.word()
-  'stomach'
+    >>> from wonderwords import RandomWord
+    >>> generator = RandomWord()
+    >>> generator.word()
+    'stomach'
 
 The random words can also be further customized::
 
-  >>> generator.word(starts_with="ap", include_categories=["adjective"])
-  'apathetic'
+    >>> generator.word(starts_with="ap", include_categories=["adjectives"])
+    'apathetic'
 
-With Wonderwords you can also generate lists of words and sentences, though
-the sentence feature is in its early development. To install, head over to the
-:ref:`install <install_uninstall_upgrade>` page. The :ref:`quickstart <quickstart>` is a great
-place to get started with Wonderwords.
+With Wonderwords you can also generate lists of words and sentences.
+To install, head over to the :ref:`install <install_uninstall_upgrade>` page.
+The :ref:`quickstart <quickstart>` is a great place to get started with Wonderwords.
 
-A full reference of all modules (the API documentation) can be found
-:ref:`here <docs>`.
+A full reference of all modules can be found at the :ref:`API documentation <docs>`
+page.
 
 Documentation
 -------------
diff --git a/docs/install.rst b/docs/install.rst
index 6c960bd..c3d6a0b 100644
--- a/docs/install.rst
+++ b/docs/install.rst
@@ -9,7 +9,7 @@ Prerequisites
 
 Before proceeding, make sure you have the following items installed:
 
-* `Python <https://python.org>`_ version 3.6 or greater
+* `Python <https://python.org>`_ version 3.8 or greater
 * A python package manager, such as `pip <https://pip.pypa.io/en/stable/installing/>`_
 
 Install
@@ -20,29 +20,37 @@ install the package ``wonderwords``:
 
 .. code-block:: bash
 
-  $ pip install wonderwords
+    $ pip install wonderwords
 
 Once installed, verify that it was correctly installed by going to a python
 console and typing::
 
-  >>> import wonderwords
+    >>> import wonderwords
 
 If you get a ``ModuleNotFoundError``, python cannot find Wonderwords. If you
 have trouble with installation, create a new issue at the Wonderwords
 `GitHub page <https://github.com/mrmaxguns/wonderwordsmodule>`_.
 
+To install the command-line interface, additional dependencies are required
+by Wonderwords. To do so, install the ``cli`` dependency category. With pip,
+you can do so with:
+
+.. code-block:: bash
+
+    $ pip install wonderwords[cli]
+
 The command line interface can be accessed with the ``wonderwords`` command.
 Verify the command line interface by typing:
 
 .. code-block:: bash
 
-  $ wonderwords -v
+    $ wonderwords -v
 
 You should get an output similar to the following:
 
 .. raw:: html
 
-  <pre><span style="background-color:#D3D7CF"><font color="#00005F">Running wonderwords version </font></span><span style="background-color:#D3D7CF"><font color="#729FCF"><b>2.0</b></font></span><span style="background-color:#D3D7CF"><font color="#00005F">.0a1</font></span></pre>
+    <pre>Wonderwords v3.0.0</pre>
 
 Upgrade
 -------
@@ -51,7 +59,7 @@ To upgrade Wonderwords to the latest stable version, use:
 
 .. code-block:: bash
 
-  $ pip install --upgrade wonderwords
+    $ pip install --upgrade wonderwords
 
 Uninstall
 ---------
@@ -60,4 +68,4 @@ To uninstall with pip, use:
 
 .. code-block:: bash
 
-  $ pip uninstall wonderwords
+    $ pip uninstall wonderwords
diff --git a/docs/quickstart.rst b/docs/quickstart.rst
index 27eb5e1..9bca0d6 100644
--- a/docs/quickstart.rst
+++ b/docs/quickstart.rst
@@ -4,36 +4,38 @@
 Quickstart
 ==========
 
-Wonderwords is a lightweight python tool that can be used to
+Wonderwords is a lightweight Python tool that can be used to
 generate random words and sentences. In this tutorial you will learn the basics
 of Wonderwords and the command line interface. This tutorial is meant for those
 who have never used Wonderwords or those who want to learn more about it.
-For a full reference of all commands, visit the API documentation.
+For a full reference of all commands, visit the :ref:`API documentation <docs>`.
 
 .. note::
 
-  This tutorial assumes you have already installed Wonderwords. If this is not
-  the case, head over to the :ref:`install_uninstall_upgrade` page before proceeding.
+    This tutorial assumes you have already installed Wonderwords. If this is not
+    the case, head over to the :ref:`install_uninstall_upgrade` page before
+    proceeding.
 
 The ``RandomWord`` class
 ------------------------
 
 One of the core Wonderwords classes is the ``RandomWord`` class. This class
-encapsulates operations dealing with individual words. One method of this
-class is the ``word`` method, which can be used to generate individual random
-words::
+deals with generating individual random words. The ``word`` method can be used
+to generate individual random words::
 
-  >>> from wonderwords import RandomWord
-  >>>
-  >>> w = RandomWord()
-  >>> w.word()
-  'sordid'
+    >>> from wonderwords import RandomWord
+    >>> w = RandomWord()
+    >>> w.word()
+    'sordid'
+
+Calling the word method returned a string containing a random word from
+Wonderwords' default word lists. When using Wonderwords, it is helpful to create
+an instance of the ``RandomWord`` class and reuse it to generate words.
 
-Calling the word class returned a string containing a word. When using
-Wonderwords, it is helpful to create an instance of the ``RandomWord`` class
-in the top-level module of your project and import it when necessary.
-Wonderwords loads word lists only once. If you create a second `RandomWord`
-instance, the word lists won't be loaded twice.
+Wonderwords loads default word lists only once. If you create a second
+``RandomWord`` instance, the word lists won't be loaded twice. There is no
+performance or memory cost associated with creating multiple ``RandomWord``
+instances.
 
 The ``word`` Method
 ^^^^^^^^^^^^^^^^^^^
@@ -43,63 +45,69 @@ Here is where the ``starts_with`` and ``ends_with`` arguments come into play.
 For example, to retrieve a word that starts with ``"n"`` and ends with
 ``"es"``, we can do the following::
 
-  >>> w.word(starts_with="n", ends_with="es")
-  'noodles'
+    >>> w.word(starts_with="n", ends_with="es")
+    'noodles'
 
 You don't have to use both arguments. You can specify either one individually
 like so::
 
-  >>> w.word(starts_with="can")
-  'cannon'
+    >>> w.word(starts_with="can")
+    'cannon'
 
 Sometimes, however, we may try to look for a pattern that doesn't exist. In that
 case a ``NoWordsToChoseFrom`` exception is raised::
 
-  >>> w.word(starts_with="ja", ends_with="ka")
-  NoWordsToChoseFrom: There aren't enough words to choose from. Cannot generate 1 word(s)
+    >>> w.word(starts_with="ja", ends_with="ka")
+    NoWordsToChoseFrom: There aren't enough words to choose from. Cannot generate 1 word(s)
 
 We can also narrow down a word by part of speech. By default, nouns, verbs and
-adjectives are all enabled. If you want to generate a word by only a certain
+adjectives are all enabled. If you want to generate a of a certain
 part of speech, you can use the ``include_categories`` parameter::
 
-  >>> w.word(include_categories=["adjective"])
-  'tough'
-  >>> w.word(include_categories=["noun", "verb"])
-  'cinder'
+    >>> w.word(include_categories=["adjectives"])
+    'tough'
+    >>> w.word(include_categories=["nouns", "verbs"])
+    'cinder'
 
 We can also filter words by length using the ``word_min_length`` and
 ``word_max_length`` parameters::
 
-  >>> w.word(word_min_length=5)
-  'documentary'
-  >>> w.word(word_max_length=3)
-  'dog'
-  >>> w.word(word_min_length=9, word_max_length=10)
-  'velodrome'
+    >>> w.word(word_min_length=5)
+    'documentary'
+    >>> w.word(word_max_length=3)
+    'dog'
+    >>> w.word(word_min_length=9, word_max_length=10)
+    'velodrome'
 
-Finally, we can filter words by a custom python
+We can additionally filter words by a custom python
 `regular expression <https://docs.python.org/3/library/re.html>`_::
 
-  >>> w.word(regex="..")
-  'TV'
-  >>> w.word(regex=".*a")
-  'terracotta'
+    >>> w.word(regex="..")
+    'TV'
+    >>> w.word(regex=".*a")
+    'terracotta'
+
+Finally, we can choose to ignore open compounds (words with spaces in between,
+such as 'dump truck') if this feature is required:
 
-Remember that we can combine multiple filters together, like so::
+    >>> w.word(exclude_with_spaces=True)
+    'partrimony'
 
-  >>> w.word(
-  ...     word_min_length=4,
-  ...     starts_with="k",
-  ...     include_categories=["verb"]
-  ... )
-  'keep'
+Filters are not exclusive and can be combined, like so::
+
+    >>> w.word(
+    ...     word_min_length=4,
+    ...     starts_with="k",
+    ...     include_categories=["verb"]
+    ... )
+    'keep'
 
 The ``filter`` method
 ^^^^^^^^^^^^^^^^^^^^^
 
 As you saw above, the word class allows the filtering of many words. What if we
 want to get a list of all words that match a certain filter? The ``filter``
-method allows us to get all words matching a certain criteria::
+method allows us to get all words matching the given criteria::
 
   >>> w.filter(word_min_length=4, starts_with="loc")
   ['locality',
@@ -122,29 +130,29 @@ The ``random_words`` method
 The ``random_words`` method acts just like the ``filter`` method, except with
 two differences:
 
-  * You can limit the amount of words fitting the criteria
-  * If there aren't enough words to reach the limit, a ``NoWordsToChoseFrom``
-    exception is raised **unless** ``return_less_if_necessary`` is set to
-    ``True``.
+* You can limit the amount of words fitting the criteria
+* The returned words are randomly chosen and ordered
+* If there aren't enough words to reach the limit, a ``NoWordsToChooseFrom``
+  exception is raised **unless** ``return_less_if_necessary`` is set to
+  ``True``.
 
-This method is useful if you want to get a list of words::
+This method is useful if you want to get a random list of words::
 
-  >>> w.random_words(3)
-  ['prince', 'handover', 'cell']
-  >>> w.random_words(4, word_min_length=5, starts_with="a")
-  ['abrogation', 'animal', 'appropriation', 'angry']
-  >>> w.random_words(3, word_min_length=5, starts_with="alg") # The exception is
-  ...                                                         # raised as 3 words cannot be generated
-  NoWordsToChoseFrom: There aren't enough words to choose from. Cannot generate 3 word(s)
-  >>> w.random_words(3, word_min_length=5, starts_with="alg", return_less_if_necessary=True)
-  ['algebra', 'algorithm']
+    >>> w.random_words(3)
+    ['prince', 'handover', 'cell']
+    >>> w.random_words(4, word_min_length=5, starts_with="a")
+    ['abrogation', 'animal', 'appropriation', 'angry']
+    >>> w.random_words(3, word_min_length=5, starts_with="alg") # The exception is
+    ...                                                         # raised as 3 words cannot be generated
+    NoWordsToChooseFrom: There aren't enough words to choose from. Cannot generate 3 word(s)
+    >>> w.random_words(3, word_min_length=5, starts_with="alg", return_less_if_necessary=True)
+    ['algebra', 'algorithm']
 
 The ``RandomSentence`` class
 ----------------------------
 
-Wonderwords makes generation of structured sentences made of random words easy.
-The ``RandomSentence`` class houses many of these features. You should keep
-an instance of this class at the top-level of your project just like the
+Wonderwords makes generation of structured sentences made of random with the
+``RandomSentence`` class. It is a generator class similar to the
 ``RandomWord`` class::
 
   >>> from wonderwords import RandomSentence
@@ -157,7 +165,7 @@ Creating sentences with the RandomSentence class
 The RandomSentence class provides multiple methods to generate random sentences,
 for example::
 
-  >>> s.bare_bone_sentence() # generate a bare-bone sentence (The [subject] [predicate])
+  >>> s.bare_bone_sentence() # generate a bare-bone sentence ([article] [subject] [predicate])
   'The hut frames.'
   >>> s.simple_sentence() # generate a simple sentence
   'A reprocessing formulates enrollment.'
@@ -182,57 +190,59 @@ the command ``wonderwords``::
 
 .. raw:: html
 
-  <pre><span style="background-color:#D3D7CF"><font color="#00005F"><b>╭─────────────────────────────────────────────────────────────────────────────╮</b></font></span>
-  <span style="background-color:#D3D7CF"><font color="#00005F"><b>│                                                                             │</b></font></span>
-  <span style="background-color:#D3D7CF"><font color="#00005F"><b>│                             WONDERWORDS 2.0.0a1                             │</b></font></span>
-  <span style="background-color:#D3D7CF"><font color="#00005F"><b>│                                                                             │</b></font></span>
-  <span style="background-color:#D3D7CF"><font color="#00005F"><b>╰─────────────────────────────────────────────────────────────────────────────╯</b></font></span>
-
-   <b>                            No commands given 😞                             </b>
+    <pre>╔═══════════════════════════════════════════════════════════════╗
+    ║                  <b>Wonderwords  [your version]</b>                  ║
+    ╚═══════════════════════════════════════════════════════════════╝
 
 
-                                <u style="text-decoration-style:single"><b>Available Commands</b></u>
+                           <u style="text-decoration-style:single"><b>Available Commands</b></u>
 
-  <font color="#FCE94F"><b> • </b></font><span style="background-color:#2E3436"><font color="#EEEEEC">wonderwords -w</font></span> - generate a random word
-  <font color="#FCE94F"><b> • </b></font><span style="background-color:#2E3436"><font color="#EEEEEC">wonderwords -f</font></span> - get all words matching a certain criteria
-  <font color="#FCE94F"><b> • </b></font><span style="background-color:#2E3436"><font color="#EEEEEC">wonderwords -l AMOUNT</font></span> - get a list of <span style="background-color:#2E3436"><font color="#EEEEEC">AMOUNT</font></span> random words
-  <font color="#FCE94F"><b> • </b></font><span style="background-color:#2E3436"><font color="#EEEEEC">wonderwords -s SENT_TYPE</font></span> - generate a random sentence of a certain type
+    <font color="#C4A000"><b> • </b></font><span style="background-color:#2E3436"><font color="#EEEEEC">wonderwords -w</font></span> - generate a random word
+    <font color="#C4A000"><b> • </b></font><span style="background-color:#2E3436"><font color="#EEEEEC">wonderwords -f</font></span> - get all words matching certain criteria
+    <font color="#C4A000"><b> • </b></font><span style="background-color:#2E3436"><font color="#EEEEEC">wonderwords -l AMOUNT</font></span> - get a list of <span style="background-color:#2E3436"><font color="#EEEEEC">AMOUNT</font></span> random words
+    <font color="#C4A000"><b> • </b></font><span style="background-color:#2E3436"><font color="#EEEEEC">wonderwords -s SENT_TYPE</font></span> - generate a random sentence of a
+    <font color="#C4A000"><b>   </b></font>certain type
 
-  For a list of all options, type <span style="background-color:#2E3436"><font color="#EEEEEC">wonderwords -h</font></span>. To see a detailed and
-  comprehensive explanation of the commands, visit <font color="#729FCF">the documentation</font>             </pre>
+    For a list of all options, use the <span style="background-color:#2E3436"><font color="#EEEEEC">--help</font></span> option. To see a
+    detailed and comprehensive explanation of the commands, visit <font color="#729FCF">the</font>
+    <font color="#729FCF">documentation</font>.
+    </pre>
 
-When typing the ``wonderwords`` command, you are greeted with a main page with
-basic information, such as basic commands and the ``wonderwords`` version.
+When typing the ``wonderwords`` command, you are greeted with a welcome message that
+lists basic commands and the ``wonderwords`` version.
 To get a full list of commands, type ``wonderwords -h`` or
 ``wonderwords --help``.
 
+Depending on how you installed it, you may need to run wonderwords as
+``python -m wonderwords`` instead of just ``wonderwords``.
+
 Generating random words
 ^^^^^^^^^^^^^^^^^^^^^^^
 
 To generate a random word, use the ``-w`` or ``--word`` flag. A random word will
 be printed to the console::
 
-  $ wonderwords -w
+    $ wonderwords -w
 
 .. raw:: html
 
-  <pre><span style="background-color:#00005F"><font color="#EEEEEC"><b>poison</b></font></span></pre>
+    <pre><font color="#06989A">participate</font></pre>
 
 All of the filters that you have learned above have their own commands, too::
 
-  $ wonderwords -w -sw a -ew e # -sw: starts with, -ew ends with; word that starts with a and ends with e
+  $ wonderwords -w -S a -e e # -S: starts with, -e ends with; word that starts with a and ends with e
   $ wonderwords -w -p nouns verbs # -p: parts of speech; select only nouns and verbs
-  $ wonderwords -w -min 3 -max 5 # -min: minimum length, -max maximum length; minimum length 3 and maximum length 5
+  $ wonderwords -w -m 3 -M 5 # -m: minimum length, -M maximum length; minimum length 3 and maximum length 5
 
 Generating filters and lists
 ^^^^^^^^^^^^^^^^^^^^^^^^^^^^
 
 You can also generate filters with the ``-f`` flag and lists with the ``-l``
-flag. All modifiers such as ``-sw`` and ``-min`` can also be used. Additionally,
+flag. All modifiers such as ``-S`` and ``-m`` can also be used. Additionally,
 the ``-d`` flag can set a delimiter between words::
 
-  $ wonderwords -f -min 3 # get all words with a minimum length of 3
-  $ wonderwords -l 5 -sw ap # get 5 words that start with "ap"
+  $ wonderwords -f -m 3 # get all words with a minimum length of 3
+  $ wonderwords -l 5 -S ap # get 5 random words that start with "ap"
   $ wonderwords -l 3 -d " | " # get 3 random words separated with " | "
 
 Generating random sentences
@@ -241,10 +251,10 @@ Generating random sentences
 The ``-s`` flag followed by a sentence type can generate a random sentence.
 The options of type are:
 
-  * ``bb``: bare-bone sentence
-  * ``ss``: simple sentence
-  * ``bba``: bare-bone sentence with adjective
-  * ``s``: a simple sentence plus and adjective
+* ``bb``: bare-bone sentence
+* ``ss``: simple sentence
+* ``bba``: bare-bone sentence with adjective
+* ``s``: a simple sentence plus and adjective
 
 For example::
 
@@ -258,9 +268,9 @@ The quickstart tutorial has come to an end. In this tutorial, you learned the
 basics of Wonderwords. More specifically, you learned about:
 
 * The ``RandomWord`` class
-    * The ``word`` method
-    * The ``filter`` method
-    * The ``random_words`` method
+  * The ``word`` method
+  * The ``filter`` method
+  * The ``random_words`` method
 * The ``RandomSentence`` class and some of its methods
 * How to use the Wonderwords command line interface
 
@@ -268,8 +278,8 @@ What's next?
 ^^^^^^^^^^^^
 
 After you have gotten comfortable using wonderwords, check out the
-:ref:`advanced` tutorial. You can use the API reference for help on specific
-classes, and functions. If you want to contribute, please read the contribution
-guidelines. If you have any problems, bugs, or feature requests, please open up
-an issue on the
+:ref:`advanced` tutorial. You can use the :ref:`API documentation <docs>` for help
+on specific classes, and functions. If you want to contribute, please read the
+contribution guidelines. If you have any problems, bugs, or feature requests,
+please open up an issue on the
 `Wonderwords GitHub page <https://github.com/mrmaxguns/wonderwordsmodule/>`_.
diff --git a/docs/requirements.txt b/docs/requirements.txt
index 787317a..aae9e6b 100644
--- a/docs/requirements.txt
+++ b/docs/requirements.txt
@@ -1,1 +1,38 @@
-sphinx-copybutton
+alabaster==0.7.16
+Babel==2.14.0
+black==24.3.0
+certifi==2024.2.2
+charset-normalizer==3.3.2
+click==8.1.7
+docutils==0.20.1
+exceptiongroup==1.2.0
+idna==3.7
+imagesize==1.4.1
+iniconfig==2.0.0
+Jinja2==3.1.3
+markdown-it-py==3.0.0
+MarkupSafe==2.1.5
+mdurl==0.1.2
+mypy==1.9.0
+mypy-extensions==1.0.0
+packaging==24.0
+pathspec==0.12.1
+platformdirs==4.2.0
+pluggy==1.4.0
+Pygments==2.17.2
+pytest==8.1.1
+requests==2.31.0
+rich==13.7.1
+setuptools-scm==8.0.4
+snowballstemmer==2.2.0
+Sphinx==7.2.6
+sphinx-copybutton==0.5.2
+sphinxcontrib-applehelp==1.0.8
+sphinxcontrib-devhelp==1.0.6
+sphinxcontrib-htmlhelp==2.0.5
+sphinxcontrib-jsmath==1.0.1
+sphinxcontrib-qthelp==1.0.7
+sphinxcontrib-serializinghtml==1.1.10
+tomli==2.0.1
+typing_extensions==4.11.0
+urllib3==2.2.1
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 0000000..6ba2caa
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,61 @@
+[build-system]
+requires = ["setuptools>=64", "setuptools_scm>=8"]
+build-backend = "setuptools.build_meta"
+
+[tool.setuptools.packages.find]
+where = ["wonderwords"]
+
+[tool.setuptools_scm]
+write_to = "wonderwords/_version.py"
+
+[project]
+name = "wonderwords"
+#version = "2.3.0"
+requires-python = ">=3.8"
+authors = [
+    { name="Maxim Rebguns", email="mrmaxguns@gmail.com" },
+]
+license = {file = "LICENSE"}
+keywords = ["random", "English"]
+description = "Generate random english words and phrases."
+readme = "PYPI.md"
+dependencies = []
+classifiers = [
+    "Development Status :: 4 - Stable",
+    "License :: OSI Approved :: MIT License",
+    "Operating System :: OS Independent",
+
+    "Natural Language :: English",
+    "Topic :: Text Processing :: Linguistic",
+
+    "Programming Language :: Python :: 3",
+    "Programming Language :: Python :: 3.8",
+    "Programming Language :: Python :: 3.9",
+    "Programming Language :: Python :: 3.10",
+    "Programming Language :: Python :: 3.11",
+    "Programming Language :: Python :: 3.12",
+]
+dynamic = ["version"]
+
+[project.optional-dependencies]
+dev = [
+    "pytest",
+    "black",
+    "mypy",
+    "flake8",
+    "setuptools",
+    "setuptools-scm",
+    "sphinx",
+    "sphinx-copybutton",
+]
+cli = [
+    "rich",
+]
+
+[project.scripts]
+wonderwords = "wonderwords.cmdline_parser:main"
+
+[project.urls]
+Homepage = "https://github.com/mrmaxguns/wonderwordsmodule"
+Issues = "https://github.com/mrmaxguns/wonderwordsmodule/issues"
+Documentation = "https://wonderwords.readthedocs.io"
diff --git a/requirements.txt b/requirements.txt
index 769d33e..4a5ba7b 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,6 +1,18 @@
-colorama==0.4.4
-commonmark==0.9.1
-importlib-resources==5.1.0
-Pygments==2.7.4
-rich==9.10.0
-typing-extensions==3.7.4.3
+black==24.3.0
+click==8.1.7
+exceptiongroup==1.2.0
+iniconfig==2.0.0
+markdown-it-py==3.0.0
+mdurl==0.1.2
+mypy==1.9.0
+mypy-extensions==1.0.0
+packaging==24.0
+pathspec==0.12.1
+platformdirs==4.2.0
+pluggy==1.4.0
+Pygments==2.17.2
+pytest==8.1.1
+rich==13.7.1
+setuptools-scm==8.0.4
+tomli==2.0.1
+typing_extensions==4.11.0
diff --git a/setup.py b/setup.py
deleted file mode 100644
index 43211c3..0000000
--- a/setup.py
+++ /dev/null
@@ -1,34 +0,0 @@
-import setuptools
-
-with open("PYPI.md", "r") as fh:
-    long_description = fh.read()
-
-setuptools.setup(
-    name="wonderwords",
-    version="2.2.0",
-    author="Maxim Rebguns",
-    author_email="mrmaxguns@gmail.com",
-    include_package_data=True,
-    description=(
-        "A python package for random words and sentences in the english"
-        " language"
-    ),
-    long_description=long_description,
-    long_description_content_type="text/markdown",
-    url="https://github.com/mrmaxguns/wonderwordsmodule",
-    packages=setuptools.find_packages(),
-    install_requires=[
-        'importlib_resources == 5.1.0; python_version < "3.7"',
-    ],
-    extras_require={"cli": ["rich == 9.10.0"]},
-    package_data={"wonderwords": ["assets/*.txt"]},
-    classifiers=[
-        "Programming Language :: Python :: 3",
-        "License :: OSI Approved :: MIT License",
-        "Operating System :: OS Independent",
-    ],
-    python_requires=">=3.6",
-    entry_points={
-        "console_scripts": ["wonderwords = wonderwords.cmdline_parser:main"]
-    },
-)
diff --git a/wonderwords/__init__.py b/wonderwords/__init__.py
index 2620169..4ec0e2b 100644
--- a/wonderwords/__init__.py
+++ b/wonderwords/__init__.py
@@ -1,12 +1,28 @@
 # flake8: noqa
-from .random_word import RandomWord, NoWordsToChoseFrom, Defaults
+from .random_word import (
+    WordList,
+    NoWordsToChooseFrom,
+    Defaults,
+    RandomWord,
+    is_profanity,
+    filter_profanity,
+)
 from .random_sentence import RandomSentence
 
-__author__ = "Maxim R."
-__copyright__ = "Copyright 2020, Wonderwords"
-__credits__ = ["Maxim R."]
+# Misspelling (kept for backwards compatibility)
+NoWordsToChoseFrom = NoWordsToChooseFrom
+
+__author__ = "Maxim Rebguns"
+__copyright__ = "Copyright 2024, Maxim Rebguns"
+__credits__ = ["Maxim Rebguns"]
 __license__ = "MIT"
-__version__ = "2.2.0"
-__maintainer__ = "Maxim R."
+__maintainer__ = "Maxim Rebguns"
 __email__ = "mrmaxguns@gmail.com"
 __status__ = "Production"
+
+try:
+    from ._version import version as __version__  # type: ignore
+    from ._version import version_tuple  # type: ignore
+except ImportError:
+    __version__ = "unknown version"
+    version_tuple = (0, 0, "unknown version")
diff --git a/wonderwords/_trie.py b/wonderwords/_trie.py
new file mode 100644
index 0000000..6904d97
--- /dev/null
+++ b/wonderwords/_trie.py
@@ -0,0 +1,74 @@
+"""An incomplete trie implementation used
+to efficiently find words with a given prefix
+or suffix.
+"""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+from typing import Dict, Set, Optional, Iterable
+
+
+@dataclass
+class TrieNode:
+    end_of_word: bool
+    children: Dict[str, TrieNode]
+
+
+class Trie:
+    def __init__(self, words: Optional[Iterable[str]] = None):
+        self.root: TrieNode = self._new_node()
+
+        if words is not None:
+            for word in words:
+                self.insert(word)
+
+    def insert(self, word: str) -> None:
+        """Insert a word into the trie."""
+        current_node = self.root
+        for letter in word:
+            if letter in current_node.children:
+                current_node = current_node.children[letter]
+            else:
+                current_node.children[letter] = self._new_node()
+                current_node = current_node.children[letter]
+        current_node.end_of_word = True
+
+    def query_characters(self, characters: str) -> Optional[TrieNode]:
+        """Find the trie node matching as many ``characters`` as possible."""
+        current_node = self.root
+        for character in characters:
+            if character in current_node.children:
+                current_node = current_node.children[character]
+            else:
+                return None
+        return current_node
+
+    def get_words_that_start_with(self, characters: str) -> Set[str]:
+        """Get all words in the trie that start with ``characters``."""
+        trunk = self.query_characters(characters)
+        if trunk is None:
+            return set()
+        return self.get_words_from_branch(trunk, characters)
+
+    def get_words_from_branch(self, branch: TrieNode, word_fragment: str) -> Set[str]:
+        """Get all words that start with ``word_fragment`` starting from the node ``branch``."""
+        words = set()
+
+        if branch.end_of_word:
+            words.add(word_fragment)
+
+        for character, data in branch.children.items():
+            if data.end_of_word:
+                words.add(word_fragment + character)
+
+            if data.children:
+                words = words | self.get_words_from_branch(
+                    data, word_fragment + character
+                )
+
+        return words
+
+    def _new_node(self) -> TrieNode:
+        """Create a new empty trie node."""
+        return TrieNode(end_of_word=False, children={})
diff --git a/wonderwords/assets/nounlist.txt b/wonderwords/assets/nounlist.txt
index 7f19e7a..09c8f3e 100644
--- a/wonderwords/assets/nounlist.txt
+++ b/wonderwords/assets/nounlist.txt
@@ -730,7 +730,6 @@ broad
 broadcast
 broccoli
 brochure
-brocolli
 broiler
 broker
 bronchitis
@@ -4644,7 +4643,6 @@ practice
 practitioner
 prairie
 praise
-pray
 prayer
 precedence
 precedent
diff --git a/wonderwords/assets/profanitylist.txt b/wonderwords/assets/profanitylist.txt
index 5a0241e..e2e9c22 100644
--- a/wonderwords/assets/profanitylist.txt
+++ b/wonderwords/assets/profanitylist.txt
@@ -1,86 +1,200 @@
+2 girls 1 cup
+2g1c
 4r5e
 5h1t
 5hit
 a55
-abortion
+a_s_s
+acrotomophilia
+alabama hot pocket
+alaskan pipeline
 anal
+anilingus
 anus
+apeshit
 ar5e
 arrse
 arse
+arsehole
 ass
 ass-fucker
+ass-hat
+ass-pirate
+assbag
+assbandit
+assbanger
+assbite
+assclown
+asscock
+asscracker
 asses
+assface
 assfucker
 assfukka
+assgoblin
+asshat
+asshead
 asshole
 assholes
+asshopper
+assjacker
+asslick
+asslicker
+assmonkey
+assmunch
+assmuncher
+asspirate
+assshole
+asssucker
+asswad
 asswhole
-a_s_s
+asswipe
+auto erotic
+autoerotic
 b!tch
 b00bs
 b17ch
 b1tch
+babeland
+baby batter
+baby juice
+ball gag
+ball gravy
+ball kicking
+ball licking
+ball sack
+ball sucking
 ballbag
 balls
 ballsack
+bampot
+bangbros
+bareback
+barely legal
+barenaked
 bastard
+bastardo
+bastinado
+bbw
+bdsm
+beaner
+beaners
 beastial
 beastiality
+beastility
+beaver cleaver
+beaver lips
 bellend
 bestial
 bestiality
 bi+ch
 biatch
+big black
+big breasts
+big knockers
+big tits
+bimbos
+birdlock
 bitch
 bitcher
 bitchers
 bitches
 bitchin
 bitching
+black cock
+blonde action
+blonde on blonde action
 bloody
 blow job
+blow your load
 blowjob
 blowjobs
+blue waffle
+blumpkin
 boiolas
 bollock
+bollocks
 bollok
+bollox
+bondage
 boner
 boob
+boobie
 boobs
 booobs
 boooobs
 booooobs
 booooooobs
+booty call
 breasts
+brown showers
+brunette action
 buceta
 bugger
+bukkake
+bulldyke
+bullet vibe
+bullshit
 bum
+bung hole
+bunghole
 bunny fucker
+busty
 butt
+butt-pirate
+buttcheeks
 butthole
 buttmunch
 buttplug
 c0ck
 c0cksucker
+camel toe
+camgirl
+camslut
+camwhore
 carpet muncher
+carpetmuncher
 cawk
+chinc
 chink
+choad
+chocolate rosebuds
+chode
 cipa
+circlejerk
 cl1t
+cleveland steamer
 clit
+clitface
 clitoris
 clits
+clover clamps
+clusterfuck
 cnut
 cock
 cock-sucker
+cockbite
+cockburger
 cockface
 cockhead
+cockjockey
+cockknoker
+cockmaster
+cockmongler
+cockmongruel
+cockmonkey
 cockmunch
 cockmuncher
+cocknose
+cocknugget
 cocks
+cockshit
+cocksmith
+cocksmoker
+cocksuck
 cocksuck
 cocksucked
+cocksucked
 cocksucker
 cocksucking
 cocksucks
@@ -89,21 +203,42 @@ cocksukka
 cok
 cokmuncher
 coksucka
+coochie
+coochy
 coon
+coons
+cooter
+coprolagnia
+coprophilia
+cornhole
 cox
 crap
+creampie
 cum
+cumbubble
+cumdumpster
+cumguzzler
+cumjockey
 cummer
 cumming
 cums
 cumshot
+cumslut
+cumtart
 cunilingus
 cunillingus
+cunnie
 cunnilingus
 cunt
+cuntface
+cunthole
+cuntlick
 cuntlick
 cuntlicker
+cuntlicker
+cuntlicking
 cuntlicking
+cuntrag
 cunts
 cyalis
 cyberfuc
@@ -113,22 +248,71 @@ cyberfucker
 cyberfuckers
 cyberfucking
 d1ck
+dammit
 damn
+darkie
+date rape
+daterape
+deep throat
+deepthroat
+dendrophilia
 dick
+dickbag
+dickbeater
+dickface
 dickhead
+dickhole
+dickjuice
+dickmilk
+dickmonger
+dickslap
+dicksucker
+dickwad
+dickweasel
+dickweed
+dickwod
+dike
 dildo
 dildos
+dingleberries
+dingleberry
 dink
 dinks
+dipshit
 dirsa
+dirty pillows
+dirty sanchez
 dlck
+dog style
 dog-fucker
+doggie style
+doggiestyle
 doggin
 dogging
+doggy style
+doggystyle
+dolcett
+domination
+dominatrix
+dommes
+donkey punch
 donkeyribber
+doochbag
+dookie
 doosh
+double dong
+double penetration
+douche
+douchebag
+dp action
+dry hump
 duche
+dumbshit
+dumshit
+dvda
 dyke
+eat my ass
+ecchi
 ejaculate
 ejaculated
 ejaculates
@@ -136,36 +320,58 @@ ejaculating
 ejaculatings
 ejaculation
 ejakulate
+erotic
+erotism
+escort
+eunuch
 f u c k
 f u c k e r
 f4nny
+f_u_c_k
 fag
+fagbag
+fagg
 fagging
+faggit
 faggitt
 faggot
 faggs
 fagot
 fagots
 fags
+fagtard
 fanny
 fannyflaps
 fannyfucker
 fanyy
+fart
+farted
+farting
+farty
 fatass
 fcuk
 fcuker
 fcuking
+fecal
 feck
 fecker
+felatio
+felch
 felching
 fellate
 fellatio
+feltch
+female squirting
+femdom
+figging
+fingerbang
 fingerfuck
 fingerfucked
 fingerfucker
 fingerfuckers
 fingerfucking
 fingerfucks
+fingering
 fistfuck
 fistfucked
 fistfucker
@@ -173,10 +379,16 @@ fistfuckers
 fistfucking
 fistfuckings
 fistfucks
+fisting
+flamer
 flange
 fook
 fooker
+foot fetish
+footjob
+frotting
 fuck
+fuck buttons
 fucka
 fucked
 fucker
@@ -189,6 +401,7 @@ fuckings
 fuckingshitmotherfucker
 fuckme
 fucks
+fucktards
 fuckwhit
 fuckwit
 fudge packer
@@ -200,42 +413,106 @@ fukkin
 fuks
 fukwhit
 fukwit
+futanari
 fux
 fux0r
-f_u_c_k
+g-spot
+gang bang
 gangbang
 gangbanged
+gangbanged
 gangbangs
+gay sex
+gayass
+gaybob
+gaydo
 gaylord
 gaysex
+gaytard
+gaywad
+genitals
+giant cock
+girl on
+girl on top
+girls gone wild
+goatcx
 goatse
-God
+god damn
 god-dam
 god-damned
 goddamn
 goddamned
+gokkun
+golden shower
+goo girl
+gooch
+goodpoop
+gook
+goregasm
+gringo
+grope
+group sex
+guido
+guro
+hand job
+handjob
+hard core
+hardcore
 hardcoresex
+heeb
 hell
+hentai
 heshe
+ho
 hoar
 hoare
+hoe
 hoer
 homo
+homoerotic
+honkey
+honky
+hooker
 hore
 horniest
 horny
+hot carl
+hot chick
 hotsex
-idiot
+how to kill
+how to murder
+huge fat
+humping
+incest
+intercourse
+jack off
 jack-off
+jackass
 jackoff
+jail bait
+jailbait
 jap
+jelly donut
+jerk off
 jerk-off
+jigaboo
+jiggaboo
+jiggerboo
 jism
 jiz
+jiz
+jizm
 jizm
 jizz
+juggs
 kawk
+kike
+kinbaku
+kinkster
+kinky
+kiunt
 knob
+knobbing
 knobead
 knobed
 knobend
@@ -245,15 +522,27 @@ knobjokey
 kock
 kondum
 kondums
+kooch
+kootch
 kum
+kumer
 kummer
 kumming
 kums
 kunilingus
+kunt
+kyke
 l3i+ch
 l3itch
 labia
+leather restraint
+leather straight jacket
+lemon party
+lesbo
+lezzie
 lmfao
+lolita
+lovemaking
 lust
 lusting
 m0f0
@@ -261,6 +550,8 @@ m0fo
 m45terbate
 ma5terb8
 ma5terbate
+make me come
+male squirting
 masochist
 master-bate
 masterb8
@@ -270,6 +561,10 @@ masterbate
 masterbation
 masterbations
 masturbate
+menage a trois
+milf
+minge
+missionary position
 mo-fo
 mof0
 mofo
@@ -294,7 +589,12 @@ motherfucking
 motherfuckings
 motherfuckka
 motherfucks
+mound of venus
+mr hands
 muff
+muff diver
+muffdiver
+muffdiving
 mutha
 muthafecker
 muthafuckker
@@ -302,7 +602,12 @@ muther
 mutherfucker
 n1gga
 n1gger
+nambla
+nawashi
 nazi
+negro
+neonazi
+nig nog
 nigg3r
 nigg4h
 nigga
@@ -311,22 +616,47 @@ niggas
 niggaz
 nigger
 niggers
+niglet
+nimphomania
+nipple
+nipples
 nob
 nob jokey
 nobhead
 nobjocky
 nobjokey
+nsfw images
+nude
+nudity
 numbnuts
 nutsack
+nympho
+nymphomania
+octopussy
+omorashi
+one cup two girls
+one guy one jar
+orgasim
 orgasim
 orgasims
 orgasm
 orgasms
+orgy
 p0rn
+paedophile
+paki
+panooch
+panties
+panty
 pawn
 pecker
+peckerhead
+pedobear
+pedophile
+pegging
 penis
 penisfucker
+phone sex
 phonesex
 phuck
 phuk
@@ -336,45 +666,105 @@ phukked
 phukking
 phuks
 phuq
+piece of shit
 pigfucker
 pimpis
+pis
+pises
+pisin
+pising
+pisof
 piss
+piss pig
 pissed
 pisser
 pissers
 pisses
+pissflap
 pissflaps
 pissin
+pissin
 pissing
 pissoff
+pissoff
+pisspig
+playboy
+pleasure chest
+pole smoker
+polesmoker
+pollock
+ponyplay
+poo
+poof
+poon
+poonani
+poonany
+poontang
 poop
+poop chute
+poopchute
 porn
 porno
 pornography
 pornos
 prick
 pricks
+prince albert piercing
 pron
+pthc
 pube
+pubes
+punanny
+punany
+punta
+pusies
 pusse
 pussi
 pussies
 pussy
+pussylicking
 pussys
+pusy
+puto
+queaf
+queef
+queerbait
+queerhole
+quim
+raghead
+raging boner
+rape
+raping
+rapist
 rectum
+renob
 retard
+reverse cowgirl
 rimjaw
+rimjob
 rimming
+rosy palm
+rosy palm and her 5 sisters
+ruski
+rusty trombone
 s hit
+s&m
 s.o.b.
+s_h_i_t
+sadism
 sadist
+santorum
+scat
 schlong
+scissoring
 screwing
 scroat
 scrote
 scrotum
 semen
 sex
+sexo
+sexy
 sh!+
 sh!t
 sh1t
@@ -382,41 +772,120 @@ shag
 shagger
 shaggin
 shagging
+shaved beaver
+shaved pussy
 shemale
 shi+
+shibari
 shit
+shit-ass
+shit-bag
+shit-bagger
+shit-brain
+shit-breath
+shit-cunt
+shit-dick
+shit-eating
+shit-face
+shit-faced
+shit-fit
+shit-head
+shit-heel
+shit-hole
+shit-house
+shit-load
+shit-pot
+shit-spitter
+shit-stain
+shitass
+shitbag
+shitbagger
+shitblimp
+shitbrain
+shitbreath
+shitcunt
 shitdick
 shite
+shiteating
 shited
 shitey
+shitface
+shitfaced
+shitfit
 shitfuck
 shitfull
 shithead
+shitheel
+shithole
+shithouse
 shiting
 shitings
+shitload
+shitpot
 shits
+shitspitter
+shitstain
 shitted
 shitter
 shitters
+shittiest
 shitting
 shittings
 shitty
+shitty
+shity
+shiz
+shiznit
+shota
+shrimping
 skank
+skeet
+slanteye
 slut
+slutbag
 sluts
+smeg
 smegma
 smut
 snatch
+snowballing
+sodomize
+sodomy
 son-of-a-bitch
 spac
+spic
+spick
+splooge
+splooge moose
+spooge
+spread legs
 spunk
-s_h_i_t
+strap on
+strapon
+strappado
+strip club
+style doggy
+suck
+sucks
+suicide girls
+sultry women
+swastika
+swinger
 t1tt1e5
 t1tties
+tainted love
+tard
+taste my
+tea bagging
 teets
 teez
 testical
 testicle
+threesome
+throating
+thundercunt
+tied up
+tight white
 tit
 titfuck
 tits
@@ -424,30 +893,66 @@ titt
 tittie5
 tittiefucker
 titties
+titty
 tittyfuck
 tittywank
 titwank
+tongue in a
+topless
 tosser
+towelhead
+tranny
+tribadism
+tub girl
+tubgirl
 turd
+tushy
 tw4t
 twat
 twathead
+twatlips
 twatty
+twink
+twinkie
+two girls one cup
 twunt
 twunter
+undressing
+upskirt
+urethra play
+urophilia
 v14gra
 v1gra
+va-j-j
+vag
 vagina
+venus mound
 viagra
+vibrator
+violet wand
+vjayjay
+vorarephilia
+voyeur
 vulva
 w00se
 wang
 wank
 wanker
 wanky
+wet dream
+wetback
+white power
 whoar
 whore
 willies
 willy
+wrapping men
+wrinkled starfish
 xrated
+xx
 xxx
+yaoi
+yellow showers
+yiffy
+zoophilia
+🖕
\ No newline at end of file
diff --git a/wonderwords/cmdline.py b/wonderwords/cmdline.py
index 1820a2b..fa110ef 100644
--- a/wonderwords/cmdline.py
+++ b/wonderwords/cmdline.py
@@ -1,82 +1,51 @@
-from rich.console import Console
-from rich.panel import Panel
-from rich.text import Text
-from rich.emoji import Emoji
-from rich.padding import Padding
+from rich import print
 from rich.markdown import Markdown
 
 from . import __version__
 
 
-console = Console()
-
-AVAILABLE_COMMANDS = """## Available Commands
+WELCOME_MESSAGE = f"""# Wonderwords {__version__}
+## Available Commands
 
 * `wonderwords -w` - generate a random word
-* `wonderwords -f` - get all words matching a certain criteria
+* `wonderwords -f` - get all words matching certain criteria
 * `wonderwords -l AMOUNT` - get a list of `AMOUNT` random words
 * `wonderwords -s SENT_TYPE` - generate a random sentence of a certain type
 
-For a list of all options, type `wonderwords -h`. To see a detailed and
+For a list of all options, use the `--help` option. To see a detailed and
 comprehensive explanation of the commands, visit
-[the documentation](https://wonderwords.readthedocs.io)
+[the documentation](https://wonderwords.readthedocs.io).
 """
 
 
-class WonderwordsCommandLine:
-    def print_title(self):
-        title = Panel(
-            Text(f"WONDERWORDS {__version__}", justify="center"),
-            padding=1,
-            style="bold navy_blue on white",
-        )
-        console.print(title)
-
-    def print_commands(self):
-        commands = Markdown(AVAILABLE_COMMANDS)
-        console.print(commands)
-
-    def version(self):
-        console.print(
-            f"Running wonderwords version {__version__}", style="navy_blue on"
-            "white"
-        )
-
-    def intro(self):
-        self.print_title()
-        info_text = Text(
-            f"No commands given {Emoji('disappointed_face')}",
-            justify="center",
-            style="bold",
-        )
-        console.print(Padding(info_text, pad=1))
-        self.print_commands()
-
-    def word(self, word):
-        word_text = Text(word, style="bold white on navy_blue")
-        console.print(word_text)
-
-    def words(self, words, delimiter):
-        word_text = Text(
-            delimiter.join(words), style="bold white on navy_blue"
-        )
-        console.print(word_text)
-
-    def sentence(self, sent):
-        sent_text = Text(sent, style="bold white on dark_green")
-        console.print(sent_text)
-
-    def no_word(self):
-        console.print(
-            "A word with the parameters specified does not exist! :anguished:",
-            style="white on red",
-        )
-
-    def no_words(self):
-        console.print(
-            (
-                "There weren't enough words that matched your request. All"
-                " words available are listed below :anguished: "
-            ),
-            style="white on red",
-        )
+def display_version() -> None:
+    print(f"Wonderwords {__version__}")
+
+
+def display_word(word: str) -> None:
+    print(f"[cyan]{word}[/cyan]")
+
+
+def display_list(words: list, delimiter=",") -> None:
+    delimiter_colorized = f"[grey50]{delimiter}[/grey50]"
+    print(delimiter_colorized.join([f"[cyan]{word}[/cyan]" for word in words]))
+
+
+def display_word_not_found(one_word=True) -> None:
+    print(
+        f"[red]:exclamation: No word{'' if one_word else 's'} matching the criteria specified could be found.[/red]"
+    )
+
+
+def display_not_enough_words() -> None:
+    print(
+        f"[red]:exclamation: Couldn't find enough words matching the criteria specified.[/red]"
+    )
+
+
+def display_sentence(sentence: str) -> None:
+    print(f"[cyan]{sentence}[/cyan]")
+
+
+def display_hello() -> None:
+    print(Markdown(WELCOME_MESSAGE))
diff --git a/wonderwords/cmdline_parser.py b/wonderwords/cmdline_parser.py
index 9b023b2..bf19406 100644
--- a/wonderwords/cmdline_parser.py
+++ b/wonderwords/cmdline_parser.py
@@ -1,8 +1,9 @@
 import argparse
+import sys
 
-from .random_word import RandomWord, NoWordsToChoseFrom
+from .random_word import RandomWord, NoWordsToChooseFrom
 from .random_sentence import RandomSentence
-from .cmdline import WonderwordsCommandLine
+from . import cmdline
 
 
 def main():
@@ -20,6 +21,8 @@ def main():
     #
     # Base commands
     #
+
+    # Will be changed to a single positional MODE argument in version 3
     parser.add_argument(
         "-w",
         "--word",
@@ -32,7 +35,7 @@ def main():
         "-f",
         "--filter",
         action="store_true",
-        help="filter a list of words matching the criteria specified",
+        help="get a list of all known words matching the criteria specified",
     )
 
     parser.add_argument(
@@ -40,7 +43,7 @@ def main():
         "--list",
         action="store",
         type=int,
-        help="return a list of words of a certain length",
+        help="return a list of a certain length of random words",
     )
 
     parser.add_argument(
@@ -58,31 +61,28 @@ def main():
     )
 
     parser.add_argument(
-        "-v",
-        "--version",
-        action="store_true",
-        help="Print the version number and exit"
+        "-v", "--version", action="store_true", help="print the version number and exit"
     )
 
     #
     # Settings and modifiers
     #
     parser.add_argument(
-        "-sw",
+        "-S",
         "--starts-with",
         action="store",
         default="",
         type=str,
-        help="specify what string the random word(s) should start with",
+        help="strings the random word(s) should start with",
     )
 
     parser.add_argument(
-        "-ew",
+        "-e",
         "--ends-with",
         action="store",
         default="",
         type=str,
-        help="specify what string the random word(s) should end with",
+        help="strings the random word(s) should end with",
     )
 
     parser.add_argument(
@@ -94,25 +94,25 @@ def main():
         # The plural forms will be removed in version 3
         choices=["noun", "verb", "adjective", "nouns", "verbs", "adjectives"],
         help=(
-            "specify to only include certain parts of speech (by default all"
+            "only include certain parts of speech (by default all"
             " parts of speech are included)"
         ),
     )
 
     parser.add_argument(
-        "-min",
+        "-m",
         "--word-min-length",
         action="store",
         type=int,
-        help="specify the minimum length of the word(s)",
+        help="minimum length of the word(s)",
     )
 
     parser.add_argument(
-        "-max",
+        "-M",
         "--word-max-length",
         action="store",
         type=int,
-        help="specify the maximum length of the word(s)",
+        help="maximum length of the word(s)",
     )
 
     parser.add_argument(
@@ -122,10 +122,15 @@ def main():
         "--regular-expression",
         action="store",
         type=str,
-        help=(
-            "specify a python-style regular expression that every word must"
-            " match"
-        ),
+        help=("a python-style regular expression for the word(s) to match"),
+    )
+
+    parser.add_argument(
+        "-x",
+        "--exclude-with-spaces",
+        action="store_true",
+        default=False,
+        help="exclude open compounds, such as 'contact lens'",
     )
 
     parser.add_argument(
@@ -133,110 +138,89 @@ def main():
         "--delimiter",
         default=", ",
         type=str,
-        help=(
-            "Specify the delimiter to put between a list of words, default is"
-            " ', '"
-        ),
+        help="specify the delimiter to put between a list of words, default is ', '",
+    )
+
+    parser.add_argument(
+        "-E",
+        "--suppress-error-on-less",
+        action="store_true",
+        default=False,
+        help="suppress errors when less words are returned in a list then wanted",
     )
 
     args = parser.parse_args()
     mode = get_mode(args)
-    handle_mode(mode, args)
+    sys.exit(run_wonderwords(mode, args))
 
 
 def get_mode(arguments):
     if arguments.version:
-        MODE = "version"
+        return "version"
     elif arguments.word:
-        MODE = "word"
+        return "word"
     elif arguments.filter:
-        MODE = "filter"
+        return "filter"
     elif arguments.list is not None:
-        MODE = "list"
+        return "list"
     elif arguments.sentence is not None:
-        MODE = "sentence"
+        return "sentence"
     else:
-        MODE = None
-
-    return MODE
-
+        return None
 
-def handle_mode(mode, arguments):  # noqa: C901
-    command_line = WonderwordsCommandLine()
 
+def run_wonderwords(mode, arguments):  # noqa: C901
     if mode == "version":
-        command_line.version()
-        quit()
-
-    if mode == "word" or mode == "filter" or mode == "list":
-        word_parser = RandomWord()
-
-    if mode == "sentence":
-        sent_parser = RandomSentence()
+        cmdline.display_version()
+        return 0
+
+    kwargs = {
+        "starts_with": arguments.starts_with,
+        "ends_with": arguments.ends_with,
+        "include_categories": arguments.parts_of_speech,
+        "word_min_length": arguments.word_min_length,
+        "word_max_length": arguments.word_max_length,
+        "regex": arguments.regex,
+        "exclude_with_spaces": arguments.exclude_with_spaces,
+    }
 
     if mode == "word":
         try:
-            word = word_parser.word(
-                starts_with=arguments.starts_with,
-                ends_with=arguments.ends_with,
-                include_categories=arguments.parts_of_speech,
-                word_min_length=arguments.word_min_length,
-                word_max_length=arguments.word_max_length,
-                regex=arguments.regex,
-            )
-        except NoWordsToChoseFrom:
-            command_line.no_word()
-            quit()
-
-        command_line.word(word)
+            cmdline.display_word(RandomWord().word(**kwargs))
+        except NoWordsToChooseFrom:
+            cmdline.display_word_not_found(one_word=True)
+            return 1
     elif mode == "filter":
-        words = word_parser.filter(
-            starts_with=arguments.starts_with,
-            ends_with=arguments.ends_with,
-            include_categories=arguments.parts_of_speech,
-            word_min_length=arguments.word_min_length,
-            word_max_length=arguments.word_max_length,
-            regex=arguments.regex,
-        )
-
-        command_line.words(words, delimiter=arguments.delimiter)
-
-    elif mode == "list":
-        try:
-            words = word_parser.random_words(
-                amount=arguments.list,
-                starts_with=arguments.starts_with,
-                ends_with=arguments.ends_with,
-                include_categories=arguments.parts_of_speech,
-                word_min_length=arguments.word_min_length,
-                word_max_length=arguments.word_max_length,
-                regex=arguments.regex,
-            )
-        except NoWordsToChoseFrom:
-            command_line.no_words()
-            words = word_parser.random_words(
-                amount=arguments.list,
-                starts_with=arguments.starts_with,
-                ends_with=arguments.ends_with,
-                include_categories=arguments.parts_of_speech,
-                word_min_length=arguments.word_min_length,
-                word_max_length=arguments.word_max_length,
-                return_less_if_necessary=True,
-                regex=arguments.regex,
+        words = RandomWord().filter(**kwargs)
+        if words:
+            cmdline.display_list(
+                RandomWord().filter(**kwargs), delimiter=arguments.delimiter
             )
-
-        command_line.words(words, delimiter=arguments.delimiter)
+        else:
+            cmdline.display_word_not_found(one_word=False)
+    elif mode == "list":
+        words = RandomWord().random_words(
+            amount=arguments.list, return_less_if_necessary=True, **kwargs
+        )
+        if words is not None:
+            cmdline.display_list(words, delimiter=arguments.delimiter)
+        if not arguments.suppress_error_on_less and len(words) < arguments.list:
+            cmdline.display_not_enough_words()
+            return 1
     elif mode == "sentence":
+        generator = RandomSentence()
         if arguments.sentence == "bb":
-            command_line.sentence(sent_parser.bare_bone_sentence())
+            cmdline.display_sentence(generator.bare_bone_sentence())
         elif arguments.sentence == "ss":
-            command_line.sentence(sent_parser.simple_sentence())
+            cmdline.display_sentence(generator.simple_sentence())
         elif arguments.sentence == "bba":
-            command_line.sentence(sent_parser.bare_bone_with_adjective())
+            cmdline.display_sentence(generator.bare_bone_with_adjective())
         else:
-            command_line.sentence(sent_parser.sentence())
+            cmdline.display_sentence(generator.sentence())
     else:
-        command_line.intro()
+        cmdline.display_hello()
+
+    return 0
 
 
 if __name__ == "__main__":
diff --git a/wonderwords/random_sentence.py b/wonderwords/random_sentence.py
index bbc65bf..5590c5f 100644
--- a/wonderwords/random_sentence.py
+++ b/wonderwords/random_sentence.py
@@ -11,14 +11,13 @@
 VOWELS = ["a", "e", "i", "o", "u"]
 
 
-def _present_tense(verb):
-    """Convert a verb in the form of "to ____" to the present tense 3rd person
+def _present_tense(verb: str) -> str:
+    """Convert a verb from the infinitive to the present tense 3rd person
     form"""
-    verb = verb.strip()
-    lowercase_verb = verb.lower()
-    if lowercase_verb.endswith(("ss", "ch", "x", "tch", "sh", "zz")):
+    verb = verb.strip().lower()
+    if verb.endswith(("ss", "ch", "x", "tch", "sh", "zz")):
         verb = verb + "es"
-    elif lowercase_verb.endswith("y") and not verb.endswith(
+    elif verb.endswith("y") and not verb.endswith(
         tuple([vowel + "y" for vowel in VOWELS])
     ):
         verb = verb[:-1] + "ies"
@@ -27,8 +26,8 @@ def _present_tense(verb):
     return verb
 
 
-def _with_article(word):
-    article, = random.choices(["the", "a", ""], weights=[5, 3, 2])
+def _with_article(word: str) -> str:
+    (article,) = random.choices(["the", "a", ""], weights=[5, 3, 2])
     if article == "a" and word[0] in VOWELS:
         article = "an"
     if article:
@@ -69,7 +68,7 @@ def __init__(
         self.gen = RandomWord(noun=noun, verb=verb, adjective=adjective)
 
     # Randomly generate bare bone sentences
-    def bare_bone_sentence(self):
+    def bare_bone_sentence(self) -> str:
         """Generate a bare-bone sentence in the form of
         ``[(article)] [subject (noun)] [predicate (verb)].``. For example:
         ``The cat runs.``.
@@ -87,7 +86,7 @@ def bare_bone_sentence(self):
 
         return f"{the_noun.capitalize()} {the_verb}."
 
-    def simple_sentence(self):
+    def simple_sentence(self) -> str:
         """Generate a simple sentence in the form of
         ``[(article)] [subject (noun)] [predicate (verb)] [direct object (noun)].``.
         For example: ``The cake plays golf``.
@@ -105,7 +104,7 @@ def simple_sentence(self):
 
         return f"{the_bare_bone_sentence} {the_direct_object}."
 
-    def bare_bone_with_adjective(self):
+    def bare_bone_with_adjective(self) -> str:
         """Generate a bare-bone sentence with an adjective in the form of:
         ``[(article)] [(adjective)] [subject (noun)] [predicate (verb)].``. For
         example: ``The skinny cat reads.``
@@ -124,7 +123,7 @@ def bare_bone_with_adjective(self):
 
         return f"{the_adjective.capitalize()} {the_noun} {the_verb}."
 
-    def sentence(self):
+    def sentence(self) -> str:
         """Generate a simple sentence with an adjective in the form of:
         ``[(article)] [(adjective)] [subject (noun)] [predicate (verb)]
         [direct object (noun)].``. For example:
diff --git a/wonderwords/random_word.py b/wonderwords/random_word.py
index 52884fe..6f99e9f 100644
--- a/wonderwords/random_word.py
+++ b/wonderwords/random_word.py
@@ -3,22 +3,34 @@
 generation of single random words.
 """
 
-import random
+from random import Random
 import re
 import enum
-from typing import Optional, List
+from types import ModuleType
+from typing import (
+    Union,
+    Optional,
+    List,
+    Dict,
+    Any,
+    Type,
+    TextIO,
+    Tuple,
+    IO,
+    Iterable,
+    Set,
+    Iterator,
+)
 
 from . import assets
+from . import _trie
 
-try:
-    import importlib.resources as pkg_resources
-except ImportError:
-    # Try backported to PY<37 `importlib_resources`.
-    import importlib_resources as pkg_resources
 
+WordList = List[str]
 
-class NoWordsToChoseFrom(Exception):
-    """NoWordsToChoseFrom is raised when there is an attempt to access more
+
+class NoWordsToChooseFrom(Exception):
+    """NoWordsToChooseFrom is raised when there is an attempt to access more
     words than exist. This exception may be raised if the amount of random
     words to generate is larger than the amount of words that exist.
     """
@@ -51,7 +63,34 @@ class Defaults(enum.Enum):
     PROFANITIES = "profanitylist.txt"
 
 
-def _load_default_categories(default_categories):
+def _obtain_resource(
+    package: Union[str, ModuleType], resource: str
+) -> Union[IO[str], TextIO]:
+    """Load a file object from a package, ensuring compatibility with supported Python versions."""
+    try:
+        # Introduced in Python 3.9
+        from importlib.resources import files  # type: ignore
+
+        return files(package).joinpath(resource).open("r")
+    except ImportError:
+        # Required for Python 3.8, but emits a DeprecationWarning on Python 3.11
+        from importlib.resources import open_text
+
+        return open_text(package, resource)
+
+
+def _get_words_from_text_file(word_file: str) -> WordList:
+    """Read a file found in static/ where each line has a word, and return
+    all words as a list
+    """
+    with _obtain_resource(assets, word_file) as f:
+        words = f.readlines()
+    return [word.rstrip() for word in words]
+
+
+def _load_default_categories(
+    default_categories: Type[Defaults],
+) -> Dict[Defaults, WordList]:
     """Load all the default word lists"""
     out = {}
     for category in default_categories:
@@ -59,15 +98,37 @@ def _load_default_categories(default_categories):
     return out
 
 
-def _get_words_from_text_file(word_file):
-    """Read a file found in static/ where each line has a word, and return
-    all words as a list
+# A dictionary where each key representing a category like 'nouns' corresponds to a list of words.
+_DEFAULT_CATEGORIES: Dict[Defaults, WordList] = _load_default_categories(Defaults)
+
+
+def is_profanity(word: str) -> bool:
+    """See if a word matches one of the words in the profanity list.
+
+    :param word: The word to check
+    :return: Whether the word was found in the profanity list
     """
-    words = pkg_resources.open_text(assets, word_file).readlines()
-    return [word.rstrip() for word in words]
+    return word.lower().strip() in _DEFAULT_CATEGORIES[Defaults.PROFANITIES]
+
+
+def filter_profanity(words: Iterable[str]) -> Iterator[str]:
+    """Attempt to filter out profane words from a list. This should be done in all user-facing applications if random
+    words are generated to avoid anything that could possibly be offensive. Curse word filtering is currently not done
+    by default on the :py:class:`RandomWord` class.
 
+    Example::
+
+        >>> from wonderwords import filter_profanity
+        >>> list(filter_profanity(["hello", "aSS", "world"]))
+        ["hello", "world"]
 
-_default_categories = _load_default_categories(Defaults)
+    :param words: Iterable of words to filter
+    :return: An iterable of the filtered result
+    """
+    return filter(
+        lambda w: not is_profanity(w),
+        words,
+    )
 
 
 class RandomWord:
@@ -92,18 +153,38 @@ class RandomWord:
        version ``3``. Please note that the ``parts_of_speech`` attribute will
        soon be deprecated, along with other method-specific features.
 
-    :param \*\*kwargs: keyword arguments where each key is a category of words
+    :param enhanced_prefixes: whether to internally use a trie data
+        structure to speed up ``starts_with`` and ``ends_with``. If enabled,
+        the class takes longer to instantiate, but calls to the generation
+        functions will be significantly (up to 4x) faster when using the
+        ``starts_with`` and ``ends_with`` arguments. Defaults to True.
+    :type enhanced_prefixes: bool, optional
+    :param rng: an instance of a ``random.Random`` used for randomization
+    :type rng: random.Random, optional
+    :param kwargs: keyword arguments where each key is a category of words
         and value is a list of words in that category. You can also use a
-        default list of words by using the `Default` enum instead.
-    :type nouns: list, optional
+        default list of words by using a value from the `Default` enum instead.
+    :type kwargs: list, optional
 
     """
 
-    def __init__(self, **kwargs):
+    def __init__(
+        self, enhanced_prefixes: bool = True, rng=None, **kwargs: Union[WordList, Defaults]
+    ):
+        # A dictionary where lists of words organized into named categories
+        self._categories: Dict[str, WordList]
+        # If enhanced prefixes are enabled, these tries represent all the words known to the generator in forward and
+        # reverse. If disabled, this is just None.
+        self._tries: Union[Tuple[_trie.Trie, _trie.Trie], None]
+        # Random number generator.
+        self._generator: Random = rng or Random()
+        # Kept for backwards compatibility. Same as self._categories
+        self.parts_of_speech: Dict[str, WordList]
+
         if kwargs:
-            self._categories = self._custom_categories(kwargs)
+            self._categories = self._get_word_lists_by_category(kwargs)
         else:
-            self._categories = self._custom_categories(
+            self._categories = self._get_word_lists_by_category(
                 {
                     "noun": Defaults.NOUNS,
                     "verb": Defaults.VERBS,
@@ -116,21 +197,35 @@ def __init__(self, **kwargs):
                     "adjectives": Defaults.ADJECTIVES,
                 }
             )
-        # Kept for backwards compatibility
+
+        if enhanced_prefixes:
+            # Two tries. One trie data structure is generated from
+            # the words, while the second one is generated from the
+            # words in reverse to deal with starts_with and ends_with
+            # respectively.
+            self._tries = (_trie.Trie(), _trie.Trie())
+            for _, category in self._categories.items():
+                for word in category:
+                    self._tries[0].insert(word)
+                    self._tries[1].insert(word[::-1])
+        else:
+            self._tries = None
+
         self.parts_of_speech = self._categories
 
     def filter(  # noqa: C901
         self,
         starts_with: str = "",
         ends_with: str = "",
-        include_categories: Optional[List[str]] = None,
-        include_parts_of_speech: Optional[List[str]] = None,
+        include_categories: Optional[Iterable[str]] = None,
+        include_parts_of_speech: Optional[Iterable[str]] = None,
         word_min_length: Optional[int] = None,
         word_max_length: Optional[int] = None,
         regex: Optional[str] = None,
-    ):
-        """Return all existing words that match the criteria specified by the
-        arguments.
+        exclude_with_spaces: bool = False,
+    ) -> WordList:
+        """Return a sorted list of all existing words that match the criteria
+        specified by the arguments.
 
         Example::
 
@@ -163,6 +258,8 @@ def filter(  # noqa: C901
         :param regex: a custom regular expression which each word must fully
             match (re.fullmatch). Defaults to None.
         :type regex: str, optional
+        :param exclude_with_spaces: exclude words that may have spaces in them
+        :type exclude_with_spaces: bool, optional
 
         :return: a list of unique words that match each of the criteria
             specified
@@ -177,57 +274,83 @@ def filter(  # noqa: C901
             if include_parts_of_speech:
                 include_categories = include_parts_of_speech
             else:
-                include_categories = self._categories.keys()
+                include_categories = list(self._categories.keys())
+
+        # Filter by part of speech and length. Both of these things
+        # are done at once since categories are specifically ordered
+        # in order to make filtering by length an efficient process.
+        # See issue #14 for details.
+        words = set()
 
-        # filter parts of speech
-        words = []
         for category in include_categories:
             try:
-                words.extend(self._categories[category])
+                words_in_category = self._categories[category]
             except KeyError:
-                raise ValueError(
-                    f"'{category}' is an invalid category"
-                ) from None
-
-        # starts/ends
-        if starts_with != "" or ends_with != "":
-            for word in words[:]:
-                if not word.startswith(starts_with):
-                    words.remove(word)
-                elif not word.endswith(ends_with):
-                    words.remove(word)
-
-        # length
-        if word_min_length is not None or word_max_length is not None:
-            for word in words[:]:
-                if word_min_length is not None and len(word) < word_min_length:
-                    words.remove(word)
-                elif (
-                    word_max_length is not None
-                    and len(word) > word_max_length
-                ):
-                    words.remove(word)
-
-        # regex
+                raise ValueError(f"'{category}' is an invalid category") from None
+
+            words_to_add = self._get_words_of_length(
+                words_in_category, word_min_length, word_max_length
+            )
+            words.update(words_to_add)
+
+        if self._tries is not None:
+            if starts_with:
+                words = words & self._tries[0].get_words_that_start_with(starts_with)
+            if ends_with:
+                # Since the ends_with trie is in reverse, the
+                # ends_with variable must also be reversed.
+                # Example (apple):
+                # - Backwards: elppa
+                # - ends_with: el
+                # Currently this is very clunky, since all words
+                # that match then need to be reversed to their
+                # original forms. Currently, I have no idea how
+                # to improve this. But, even with the extra overhead
+                # of iteration, this system still significantly
+                # shortens the amount of time to filter the words.
+                ends_with = ends_with[::-1]
+                words = words & set(
+                    [
+                        i[::-1]
+                        for i in self._tries[1].get_words_that_start_with(ends_with)
+                    ]
+                )
+
+        # Long operations that require looping over every word
+        # (O(n)). Since they are so time-consuming, the arguments
+        # passed to the function are first checked if the user
+        # actually specified any time-consuming arguments. If they
+        # are, all long filters are checked at once for every word.
+        long_operations: Dict[str, Any] = {}
+
         if regex is not None:
-            words = [
-                word for word in words if re.fullmatch(regex, word) is not None
-            ]
+            long_operations["regex"] = re.compile(regex)
+        if exclude_with_spaces:
+            long_operations["exclude_with_spaces"] = None
+        if self._tries is None:
+            if starts_with:
+                long_operations["starts_with"] = starts_with
+            if ends_with:
+                long_operations["ends_with"] = ends_with
+
+        if long_operations:
+            words -= self._perform_long_operations(words, long_operations)
 
-        return list(set(words))
+        return sorted(list(words))
 
     def random_words(
         self,
         amount: int = 1,
         starts_with: str = "",
         ends_with: str = "",
-        include_categories: Optional[List[str]] = None,
-        include_parts_of_speech: Optional[List[str]] = None,
+        include_categories: Optional[Iterable[str]] = None,
+        include_parts_of_speech: Optional[Iterable[str]] = None,
         word_min_length: Optional[int] = None,
         word_max_length: Optional[int] = None,
         regex: Optional[str] = None,
         return_less_if_necessary: bool = False,
-    ):
+        exclude_with_spaces: bool = False,
+    ) -> WordList:
         """Generate a list of n random words specified by the ``amount``
         parameter and fit the criteria specified.
 
@@ -272,8 +395,10 @@ def random_words(
             NoWordsToChoseFrom exception, return all words that did statisfy
             the original query.
         :type return_less_if_necessary: bool, optional
+        :param exclude_with_spaces: exclude words that may have spaces in them
+        :type exclude_with_spaces: bool, optional
 
-        :raises NoWordsToChoseFrom: if there are less words to choose from than
+        :raises NoWordsToChoseFrom: if there are fewer words to choose from than
             the amount that was requested, a NoWordsToChoseFrom exception is
             raised, **unless** return_less_if_necessary is set to True.
 
@@ -288,20 +413,22 @@ def random_words(
             word_min_length=word_min_length,
             word_max_length=word_max_length,
             regex=regex,
+            exclude_with_spaces=exclude_with_spaces,
         )
 
-        if not return_less_if_necessary and len(choose_from) < amount:
-            raise NoWordsToChoseFrom(
-                "There aren't enough words to choose from. Cannot generate "
-                f"{str(amount)} word(s)"
-            )
-        elif return_less_if_necessary:
-            random.shuffle(choose_from)
-            return choose_from
+        if len(choose_from) < amount:
+            if return_less_if_necessary:
+                self._generator.shuffle(choose_from)
+                return choose_from
+            else:
+                raise NoWordsToChooseFrom(
+                    "There aren't enough words to choose from. Cannot generate "
+                    f"{str(amount)} word(s)"
+                )
 
         words = []
         for _ in range(amount):
-            new_word = random.choice(choose_from)
+            new_word = self._generator.choice(choose_from)
             choose_from.remove(new_word)
             words.append(new_word)
 
@@ -311,12 +438,13 @@ def word(
         self,
         starts_with: str = "",
         ends_with: str = "",
-        include_categories: Optional[List[str]] = None,
-        include_parts_of_speech: Optional[List[str]] = None,
+        include_categories: Optional[Iterable[str]] = None,
+        include_parts_of_speech: Optional[Iterable[str]] = None,
         word_min_length: Optional[int] = None,
         word_max_length: Optional[int] = None,
         regex: Optional[str] = None,
-    ):
+        exclude_with_spaces: bool = False,
+    ) -> str:
         """Returns a random word that fits the criteria specified by the
         arguments.
 
@@ -347,6 +475,8 @@ def word(
         :param regex: a custom regular expression which each word must fully
             match (re.fullmatch). Defaults to None.
         :type regex: str, optional
+        :param exclude_with_spaces: exclude words that may have spaces in them
+        :type exclude_with_spaces: bool, optional
 
         :raises NoWordsToChoseFrom: if a word fitting the criteria doesn't
             exist
@@ -363,19 +493,21 @@ def word(
             word_min_length=word_min_length,
             word_max_length=word_max_length,
             regex=regex,
+            exclude_with_spaces=exclude_with_spaces,
         )[0]
 
     @staticmethod
-    def read_words(word_file):
+    def read_words(word_file: str) -> WordList:
         """Will soon be deprecated. This method isn't meant to be public, but
         will remain for backwards compatibility. Developers: use
         _get_words_from_text_file internally instead.
         """
         return _get_words_from_text_file(word_file)
 
-    def _validate_lengths(self, word_min_length, word_max_length):
-        """Validate the values and types of word_min_length and word_max_length
-        """
+    def _validate_lengths(
+        self, word_min_length: Any, word_max_length: Any
+    ) -> Tuple[Union[int, None], Union[int, None]]:
+        """Validate the values and types of word_min_length and word_max_length"""
         if not isinstance(word_min_length, (int, type(None))):
             raise TypeError("word_min_length must be type int or None")
 
@@ -383,7 +515,7 @@ def _validate_lengths(self, word_min_length, word_max_length):
             raise TypeError("word_max_length must be type int or None")
 
         if word_min_length is not None and word_max_length is not None:
-            if word_min_length > word_max_length and word_max_length != 0:
+            if word_min_length > word_max_length != 0:
                 raise ValueError(
                     "word_min_length cannot be greater than word_max_length"
                 )
@@ -394,14 +526,83 @@ def _validate_lengths(self, word_min_length, word_max_length):
         if word_max_length is not None and word_max_length < 0:
             word_max_length = None
 
-        return (word_min_length, word_max_length)
+        return word_min_length, word_max_length
 
-    def _custom_categories(self, custom_categories):
-        """Add custom categries of words"""
+    def _get_word_lists_by_category(
+        self, custom_categories: Dict[str, Any]
+    ) -> Dict[str, WordList]:
+        """Add custom categories of words"""
         out = {}
         for name, words in custom_categories.items():
             if isinstance(words, Defaults):
-                out[name] = _default_categories[words]
+                word_list = _DEFAULT_CATEGORIES[words]
             else:
-                out[name] = words
+                word_list = words
+
+            # All the words in each category are sorted. This is so
+            # that they can be bisected by length later on for more
+            # efficient word length retrieval. See issue #14 for
+            # details.
+            word_list.sort(key=len)
+            out[name] = word_list
+
         return out
+
+    def _get_words_of_length(
+        self,
+        word_list: WordList,
+        min_length: Optional[int] = None,
+        max_length: Optional[int] = None,
+    ) -> WordList:
+        """Given ``word_list``, get all words that are at least
+        ``min_length`` long and at most ``max_length`` long.
+        """
+
+        if min_length is None:
+            left_index = 0
+        else:
+            left_index = self._bisect_by_length(word_list, min_length)
+
+        if max_length is None:
+            right_index = None
+        else:
+            right_index = self._bisect_by_length(word_list, max_length + 1)
+
+        return word_list[left_index:right_index]
+
+    def _bisect_by_length(self, words: WordList, target_length: int) -> int:
+        """Given a list of sorted words by length, get the index of the
+        first word that's of the ``target_length``.
+        """
+
+        left = 0
+        right = len(words) - 1
+
+        while left <= right:
+            middle = left + (right - left) // 2
+            if len(words[middle]) < target_length:
+                left = middle + 1
+            else:
+                right = middle - 1
+
+        return left
+
+    def _perform_long_operations(
+        self, words: Set[str], long_operations: Dict[str, Any]
+    ) -> Set[str]:
+        """Return a set of words that do not meet the criteria specified by the long operations."""
+        remove_words = set()
+        for word in words:
+            if "regex" in long_operations:
+                if not long_operations["regex"].fullmatch(word):
+                    remove_words.add(word)
+            if "exclude_with_spaces" in long_operations:
+                if " " in word:
+                    remove_words.add(word)
+            if "starts_with" in long_operations:
+                if not word.startswith(long_operations["starts_with"]):
+                    remove_words.add(word)
+            if "ends_with" in long_operations:
+                if not word.endswith(long_operations["ends_with"]):
+                    remove_words.add(word)
+        return remove_words


Test patch:
diff --git a/tests/test_random_word.py b/tests/test_random_word.py
index f5a01cd..a52e072 100644
--- a/tests/test_random_word.py
+++ b/tests/test_random_word.py
@@ -1,47 +1,72 @@
-from wonderwords import RandomWord, Defaults, NoWordsToChoseFrom
+from wonderwords import RandomWord, Defaults, NoWordsToChooseFrom, is_profanity, filter_profanity
+from random import Random
 
 import pytest
 
 
-class TestRandomWord:
-    rw = RandomWord()
+class TestIsProfanity:
+    def test_no_profanity(self):
+        assert not is_profanity("apple")
+
+    def test_profanity(self):
+        assert is_profanity("  piSS ")
+
+
+class TestFilterProfanity:
+    def test_no_profanity(self):
+        words = ["hello", "world", "assassin", "CraZy"]
+        output = list(filter_profanity(words))
+        assert output == words
+
+    def test_profanity(self):
+        words = {"hello", "a55", "world", " aSS  ", "hi", "pIsS "}
+        output = set(filter_profanity(words))
+        assert output == {"hello", "world", "hi"}
+
 
-    def test_filter_default(self):
+@pytest.mark.parametrize("gen", [RandomWord(enhanced_prefixes=True), RandomWord(enhanced_prefixes=False)])
+class TestRandomWord:
+    def test_filter_default(self, gen):
         """Test the filter method with default parameters"""
-        data = self.rw.filter()
-        assert isinstance(data, list) and len(data) == 8167
+        data = gen.filter()
+        assert isinstance(data, list) and len(data) == 8166
+
+    def test_filter_sorting(self, gen):
+        """Test that the filter method returns a sorted list."""
+        gen = RandomWord(words=["november", "uniform", "alpha", "delta", "bravo"])
+        assert gen.filter() == ["alpha", "bravo", "delta", "november", "uniform"]
 
-    def test_filter_starts_with(self):
+    def test_filter_starts_with(self, gen):
         """Test the filter method with a custom starts_with parameter"""
-        data = self.rw.filter(starts_with="mana")
+        data = gen.filter(starts_with="mana")
         assert sorted(data) == sorted(
             ["manage", "manager", "management", "manacle", "manatee"]
         )
 
-    def test_filter_ends_with(self):
+    def test_filter_ends_with(self, gen):
         """Test the filter method with a custom ends_with parameter"""
-        data = self.rw.filter(ends_with="ala")
+        data = gen.filter(ends_with="ala")
         assert sorted(data) == sorted(["impala", "koala"])
 
-    def test_filter_include_categories(self):
+    def test_filter_include_categories(self, gen):
         """Test the filter method with a custom include_parts_of_speech
         parameter
         """
-        data = self.rw.filter(include_categories=["adjective"])
+        data = gen.filter(include_categories=["adjective"])
         assert len(data) == 910
 
-    def test_filter_include_categories_invalid(self):
+    def test_filter_include_categories_invalid(self, gen):
         """Test the filter method with a custom include_parts_of_speech
         parameter that is invalid
         """
         with pytest.raises(ValueError):
-            self.rw.filter(
+            gen.filter(
                 include_categories=["I am not a part of speech"]
             )
 
-    def test_filter_word_min_length(self):
+    def test_filter_word_min_length(self, gen):
         """Test the filter method with a custom word_min_length parameter"""
-        data = self.rw.filter(word_min_length=17)
+        data = gen.filter(word_min_length=17)
         assert sorted(data) == sorted(
             [
                 "rehospitalisation",
@@ -58,64 +83,69 @@ def test_filter_word_min_length(self):
             ]
         )
 
-    def test_filter_word_min_length_invalid(self):
+    def test_filter_word_min_length_invalid(self, gen):
         """Test the filter method with a custom word_min_length parameter which
         is not of the correct type (int)"""
         with pytest.raises(TypeError):
-            self.rw.filter(word_min_length="INVALID")
+            gen.filter(word_min_length="INVALID")
 
-    def test_filter_word_max_length(self):
+    def test_filter_word_max_length(self, gen):
         """Test the filter method with a custom word_max_length parameter"""
-        data = self.rw.filter(word_max_length=2)
+        data = gen.filter(word_max_length=2)
         assert sorted(data) == sorted(
             ["be", "ox", "go", "do", "ad", "TV", "id", "CD"]
         )
 
-    def test_filter_word_max_length_invalid(self):
+    def test_filter_word_max_length_invalid(self, gen):
         """Test the filter method with a custom word_max_length parameter which
         is not of the correct type (int)"""
         with pytest.raises(TypeError):
-            self.rw.filter(word_max_length=b"Invalid bytes :(")
+            gen.filter(word_max_length=b"Invalid bytes :(")
 
-    def test_filter_word_lengths_invalid(self):
+    def test_filter_word_lengths_invalid(self, gen):
         """Test the filter method where word_max_length is less than
         word_min_length (invalid)
         """
         with pytest.raises(ValueError):
-            self.rw.filter(word_min_length=20, word_max_length=5)
+            gen.filter(word_min_length=20, word_max_length=5)
 
-    def test_filter_word_lengths_negative(self):
+    def test_filter_word_lengths_negative(self, gen):
         """Test the filter method where word_min_length and word_max_length
         are invalid
         """
-        data = self.rw.filter(word_min_length=-31, word_max_length=-10)
+        data = gen.filter(word_min_length=-31, word_max_length=-10)
         assert isinstance(data, list)
 
-    def test_filter_regex(self):
+    def test_filter_regex(self, gen):
         """Test the regex functionality of the filter method"""
-        data = self.rw.filter(regex=".*ea")
+        data = gen.filter(regex=".*ea")
         assert sorted(data) == sorted(
             ["guinea", "sea", "tea", "idea", "plea", "area", "pea"]
         )
 
-    def test_random_words(self):
+    def test_exclude_with_spaces(self, gen):
+        """Test exclude_with_spaces parameter."""
+        rw = RandomWord(words=["apple", "contact lens", "car", "dump truck"])
+        assert sorted(rw.filter(exclude_with_spaces=True)) == ["apple", "car"]
+
+    def test_random_words(self, gen):
         """Test the random_words method"""
-        data = self.rw.random_words(4)
+        data = gen.random_words(4)
         assert isinstance(data, list) and len(data) == 4
 
-    def test_random_words_not_enough(self):
+    def test_random_words_not_enough(self, gen):
         """Test the random_words method in which the query returns less results
         than expected
         """
-        with pytest.raises(NoWordsToChoseFrom):
-            self.rw.random_words(20, starts_with="ag")
+        with pytest.raises(NoWordsToChooseFrom):
+            gen.random_words(20, starts_with="ag")
 
-    def test_random_words_not_enough_with_return_less_if_necessary(self):
+    def test_random_words_not_enough_with_return_less_if_necessary(self, gen):
         """Test the random_words method in which the query returns less results
         than expected but with the parameter return_less_if_necessary set to
         True
         """
-        data = self.rw.random_words(
+        data = gen.random_words(
             20,
             starts_with="ag",
             return_less_if_necessary=True
@@ -139,12 +169,18 @@ def test_random_words_not_enough_with_return_less_if_necessary(self):
             ]
         )
 
-    def test_word(self):
+    def test_random_words_enough_with_return_less_if_necessary(self, gen):
+        """Test to see if setting return_less_if_necessary doesn't do anything if there are enough words."""
+        data_1 = gen.random_words(5, starts_with="mo", return_less_if_necessary=True)
+        data_2 = gen.random_words(5, starts_with="mo", return_less_if_necessary=False)
+        assert len(data_1) == len(data_2) == 5
+
+    def test_word(self, gen):
         """Test the word method"""
-        data = self.rw.word()
+        data = gen.word()
         assert isinstance(data, str)
 
-    def test_custom_category_without_defaults(self):
+    def test_custom_category_without_defaults(self, gen):
         """Test a custom category without using default values"""
         names = ["Bob", "Billy", "Anne", "Max"]
         foods = ["soup", "noodles", "cake", "rice"]
@@ -158,7 +194,20 @@ def test_custom_category_without_defaults(self):
             and (gen.word(include_categories=["food"]) in foods)
         )
 
-    def test_custom_category_with_defaults(self):
+    def test_custom_category_with_defaults(self, gen):
         """Test a custom category using default values"""
         gen = RandomWord(my_verb=Defaults.VERBS)
         assert gen.word(starts_with="ab") == "abide"
+
+    def test_custom_category_with_mixing(self, gen):
+        """Test custom categories mixed with default categories."""
+        proper_nouns = ["Austin", "Seattle", "New York"]
+        gen = RandomWord(proper_nouns=proper_nouns, common_nouns=Defaults.NOUNS)
+        assert set(gen.filter(regex="[Ss]eat.*")) == {"Seattle", "seat"}
+
+    def test_custom_generator_determinism(self, gen):
+        """Test a custom random generator and ensure that seeded output is deterministic."""
+        gen = RandomWord(rng=Random(10))
+        # Should return the same thing in all supported versions of CPython. Will need to update this test if the random
+        # implementation is ever changed.
+        assert gen.word() == "mortal"