Repository: facelessuser/wcmatch

Issue:
Maybe add a compile function
Wcmatch caches patterns, but for instance, it doesn't cache pattern expansion. So if a pattern uses something like `{1..100}`, you'd get 100 patterns cached, so the expansion would still happen every time, but the parsing of each pattern would not. We currently do this because we don't want someone filling memory with massive cache entries. So, there is always a little overhead when calling a `fnmatch.match` or a `glob.globmatch` calls, more so if you are using braces or splitting with `|`

It may be nice though, to pre-compile a match that can be used over and over again. Once compiled, you could call it with very little overhead. Something like:

```py
matcher = glob.compile('**/*.txt', flags=glob.GLOBSTAR)
matcher.match('folder.file.txt')
```

Discussion:
facelessuser: @gir-bot lgtm

Patch:
diff --git a/README.md b/README.md
index 247d8e7..5a9225b 100644
--- a/README.md
+++ b/README.md
@@ -61,7 +61,7 @@ https://facelessuser.github.io/wcmatch/
 MIT
 
 
-[github-ci-image]: https://github.com/facelessuser/wcmatch/workflows/build/badge.svg?branch=main&event=push
+[github-ci-image]: https://github.com/facelessuser/wcmatch/workflows/build/badge.svg
 [github-ci-link]: https://github.com/facelessuser/wcmatch/actions?query=workflow%3Abuild+branch%3Amain
 [codecov-image]: https://img.shields.io/codecov/c/github/facelessuser/wcmatch/main.svg?logo=codecov&logoColor=aaaaaa&labelColor=333333
 [codecov-link]: https://codecov.io/github/facelessuser/wcmatch
diff --git a/docs/src/dictionary/en-custom.txt b/docs/src/dictionary/en-custom.txt
index 60a5f58..534af6d 100644
--- a/docs/src/dictionary/en-custom.txt
+++ b/docs/src/dictionary/en-custom.txt
@@ -17,6 +17,7 @@ MkDocs
 NONINFRINGEMENT
 POSIX
 Pathlib
+Precompiling
 Preprocess
 PyPI
 Setuptools
@@ -58,6 +59,8 @@ paren
 pathlike
 posix
 pre
+precompile
+precompiled
 prepend
 prepends
 preprocessing
diff --git a/docs/src/markdown/about/changelog.md b/docs/src/markdown/about/changelog.md
index d13a97c..8e20c5b 100644
--- a/docs/src/markdown/about/changelog.md
+++ b/docs/src/markdown/about/changelog.md
@@ -1,5 +1,10 @@
 # Changelog
 
+## 10.1
+
+-   **NEW**: Add `wcmatch.glob.compile(pattern)` and `wcmatch.fnmatch.compile(pattern)` to allow for precompiled matcher
+    objects that can be reused.
+
 ## 10.0
 
 -   **NEW**: Added `GLOBSTARLONG` which adds support for the Zsh style `***` which acts like `**` with `GLOBSTAR` but
@@ -80,7 +85,7 @@
 -   **NEW**: `fnmatch` now has `escape` available via its API. The `fnmatch` variant uses filename logic instead of path
     logic.
 -   **NEW**: Deprecate `raw_escape` in `glob` as it is very niche and the same can be accomplished simply by using
-    `#!py3 codecs.decode(string, 'unicode_escape')` and then using `escape`.
+    `codecs.decode(string, 'unicode_escape')` and then using `escape`.
 -   **FIX**: Use `os.fspath` to convert path-like objects to string/bytes, whatever the return from `__fspath__` is what
     Wildcard Match will accept. Don't try to convert paths via `__str__` or `__bytes__` as not all path-like objects may
     implement both.
diff --git a/docs/src/markdown/about/release.md b/docs/src/markdown/about/release.md
index 5937a44..2af5783 100644
--- a/docs/src/markdown/about/release.md
+++ b/docs/src/markdown/about/release.md
@@ -13,7 +13,7 @@ The [`WcMatch`](../wcmatch.md#wcmatch) class `on_init` hook was cleaned up. Prio
 Moving forward, the `WcMatch` class will restrict all parameters to `**kwargs`. If you are using the `on_init` hook,
 you will simply need to change your override to accept arguments as `**kwargs`:
 
-```py3
+```py
 # Excplicitly named
 def on_init(self, key1=value, key2=value):
 
@@ -23,7 +23,7 @@ def on_init(self, **kwargs):
 
 Lastly, only pass your custom variables in as keyword arguments:
 
-```py3
+```py
 CustomWcmatch('.', '*.md|*.txt', flags=wcmatch.RECURSIVE, custom_key=value)
 ```
 
diff --git a/docs/src/markdown/fnmatch.md b/docs/src/markdown/fnmatch.md
index f707a9f..ae1731e 100644
--- a/docs/src/markdown/fnmatch.md
+++ b/docs/src/markdown/fnmatch.md
@@ -1,6 +1,6 @@
 # `wcmatch.fnmatch`
 
-```py3
+```py
 from wcmatch import fnmatch
 ```
 
@@ -13,7 +13,7 @@ default. See [flags](#flags) for more information.
 
 /// tip | Backslashes
 When using backslashes, it is helpful to use raw strings. In a raw string, a single backslash is used to escape a
-character `#!py3 r'\?'`.  If you want to represent a literal backslash, you must use two: `#!py3 r'some\\path'`.
+character `#!py r'\?'`.  If you want to represent a literal backslash, you must use two: `#!py r'some\\path'`.
 ///
 
 Pattern           | Meaning
@@ -51,11 +51,30 @@ be no limit.
 The imposed pattern limit and corresponding `limit` option was introduced in 6.0.
 ///
 
+## Precompiling
+
+While patterns are often cached, auto expanding patterns, such as `'file{a, b, c}'` will have each individual
+permutation cached (up to the cache limit), but not the entire pattern. This is to prevent the cache from exploding with
+really large patterns such as `{1..100}`. Essentially, individual patterns are cached, but not the expansion of a
+pattern into many patterns.
+
+If it is planned to reuse a pattern and the performance hit of recompiling is not desired, you can precompile a matcher
+object via [`fnmatch.compile`](#compile) which returns a [`WcMatcher`](#wcmatcher) object.
+
+```py
+>>> import wcmatch.fnmatch as fnmatch
+>>> m = fnmatch.compile('*.md')
+>>> m.match('README.md')
+True
+>>> m.filter(['test.txt', 'file.md', 'README.md'])
+['file.md', 'README.md']
+```
+
 ## API
 
 #### `fnmatch.fnmatch` {: #fnmatch}
 
-```py3
+```py
 def fnmatch(filename, patterns, *, flags=0, limit=1000, exclude=None)
 ```
 
@@ -128,7 +147,7 @@ True
 
 #### `fnmatch.filter` {: #filter}
 
-```py3
+```py
 def filter(filenames, patterns, *, flags=0, limit=1000, exclude=None):
 ```
 
@@ -151,9 +170,60 @@ pattern or a list of patterns.It returns a list of all files that matched the pa
 `exclude` parameter was added.
 ///
 
+#### `fnmatch.compile` {: #compile}
+
+```py
+def compile(patterns, *, flags=0, limit=1000, exclude=None):
+```
+
+The `compile` function takes a file pattern (or list of patterns) and flags. It also allows configuring the [max pattern
+limit](#multi-pattern-limits). Exclusion patterns can be specified via the `exclude` parameter which takes a pattern or
+a list of patterns. It returns a [`WcMatcher`](#wcmatcher) object which can match or filter file paths depending on
+which method is called.
+
+```pycon3
+>>> import wcmatch.fnmatch as fnmatch
+>>> m = fnmatch.compile('*.md')
+>>> m.match('README.md')
+True
+>>> m.filter(['test.txt', 'file.md', 'README.md'])
+['file.md', 'README.md']
+```
+
+#### `fnmatch.WcMatcher` {: #wcmatcher}
+
+The `WcMatcher` class is returned when a pattern is precompiled with [`compile`](#compile). It has two methods: `match`
+and `filter`.
+
+```py
+def match(self, filename):
+```
+
+This `match` method allows for matching against a precompiled pattern.
+
+```pycon3
+>>> import wcmatch.fnmatch as fnmatch
+>>> m = fnmatch.compile('*.md')
+>>> m.match('README.md')
+True
+```
+
+```py
+def filter(self, filenames):
+```
+
+The `filter` method allows for filtering paths against a precompiled pattern.
+
+```pycon3
+>>> import wcmatch.fnmatch as fnmatch
+>>> m = fnmatch.compile('*.md')
+>>> m.filter(['test.txt', 'file.md', 'README.md'])
+['file.md', 'README.md']
+```
+
 #### `fnmatch.translate` {: #translate}
 
-```py3
+```py
 def translate(patterns, *, flags=0, limit=1000, exclude=None):
 ```
 
@@ -173,8 +243,8 @@ matches at least one inclusion pattern and matches **none** of the exclusion pat
 
 When using [`EXTMATCH`](#extmatch) patterns, patterns will be returned with capturing groups around the groups:
 
-While in regex patterns like `#!py3 r'(a)+'` would capture only the last character, even though multiple where matched,
-we wrap the entire group to be captured: `#!py3 '+(a)'` --> `#!py3 r'((a)+)'`.
+While in regex patterns like `#!py r'(a)+'` would capture only the last character, even though multiple where matched,
+we wrap the entire group to be captured: `#!py '+(a)'` --> `#!py r'((a)+)'`.
 
 ```pycon3
 >>> from wcmatch import fnmatch
@@ -199,7 +269,7 @@ Translate patterns now provide capturing groups for [`EXTMATCH`](#extmatch) grou
 
 #### `fnmatch.escape` {: #escape}
 
-```py3
+```py
 def escape(pattern):
 ```
 
@@ -220,7 +290,7 @@ An `escape` variant for `fnmatch` was made available in 8.1.
 
 ### `fnmatch.is_magic` {: #is_magic}
 
-```py3
+```py
 def is_magic(pattern, *, flags=0):
     """Check if the pattern is likely to be magic."""
 ```
@@ -267,8 +337,8 @@ Added `is_magic` in 8.1.
 
 #### `fnmatch.RAWCHARS, fnmatch.R` {: #rawchars}
 
-`RAWCHARS` causes string character syntax to be parsed in raw strings: `#!py3 r'\u0040'` --> `#!py3 r'@'`. This will
-handle standard string escapes and Unicode including `#!py3 r'\N{CHAR NAME}'`.
+`RAWCHARS` causes string character syntax to be parsed in raw strings: `#!py r'\u0040'` --> `#!py r'@'`. This will
+handle standard string escapes and Unicode including `#!py r'\N{CHAR NAME}'`.
 
 #### `fnmatch.NEGATE, fnmatch.N` {: #negate}
 
diff --git a/docs/src/markdown/glob.md b/docs/src/markdown/glob.md
index b26ad64..ead93f8 100644
--- a/docs/src/markdown/glob.md
+++ b/docs/src/markdown/glob.md
@@ -1,6 +1,6 @@
 # `wcmatch.glob`
 
-```py3
+```py
 from wcmatch import glob
 ```
 
@@ -13,7 +13,7 @@ what [`glob`](#glob) globs :slight_smile:.
 
 /// tip
 When using backslashes, it is helpful to use raw strings. In a raw string, a single backslash is used to escape a
-character `#!py3 r'\?'`.  If you want to represent a literal backslash, you must use two: `#!py3 r'some\\path'`.
+character `#!py r'\?'`.  If you want to represent a literal backslash, you must use two: `#!py r'some\\path'`.
 ///  
 
 Pattern           | Meaning
@@ -184,11 +184,30 @@ no limit.
 The imposed pattern limit and corresponding `limit` option was introduced in 6.0.
 ///
 
+## Precompiling
+
+While patterns are often cached, auto expanding patterns, such as `'file{a, b, c}'` will have each individual
+permutation cached (up to the cache limit), but not the entire pattern. This is to prevent the cache from exploding with
+really large patterns such as `{1..100}`. Essentially, individual patterns are cached, but not the expansion of a
+pattern into many patterns.
+
+If it is planned to reuse a pattern and the performance hit of recompiling is not desired, you can precompile a matcher
+object via [`glob.compile`](#compile) which returns a [`WcMatcher`](#wcmatcher) object.
+
+```py
+>>> import wcmatch.glob as glob
+>>> m = glob.compile('**/*.py', flags=glob.GLOBSTAR)
+>>> m.match('wcmatch/__init__.py')
+True
+>>> m.filter(['wcmatch/__init__.py', 'wcmatch/glob.py', 'README.md'])
+['wcmatch/__init__.py', 'wcmatch/glob.py']
+```
+
 ## API
 
 #### `glob.glob` {: #glob}
 
-```py3
+```py
 def glob(patterns, *, flags=0, root_dir=None, dir_fd=None, limit=1000, exclude=None):
 ```
 
@@ -286,7 +305,7 @@ users with `~user`.
 ```
 
 By default, `glob` uses the current working directory to evaluate relative patterns. Normally you'd have to use
-`#!py3 os.chdir('/new/path')` to evaluate patterns relative to a different path. By setting `root_dir` parameter you can
+`#!py os.chdir('/new/path')` to evaluate patterns relative to a different path. By setting `root_dir` parameter you can
 change the root path without using `os.chdir`.
 
 ```pycon3
@@ -309,10 +328,10 @@ Additionally, you can use `dir_fd` and specify a root directory with a directory
 
 /// warning | Support for Directory Descriptors
 Directory descriptors may not be supported on all systems. You can check whether or not `dir_fd` is supported for a
-your platform referencing the attribute `#!py3 glob.SUPPORT_DIR_FD` which will be `#!py3 True` if it is supported.
+your platform referencing the attribute `#!py glob.SUPPORT_DIR_FD` which will be `#!py True` if it is supported.
 
-Additionally, the `#!py3 os.O_DIRECTORY` may not be defined on some systems. You can likely just use
-`#!py3 os.O_RDONLY`.
+Additionally, the `#!py os.O_DIRECTORY` may not be defined on some systems. You can likely just use
+`#!py os.O_RDONLY`.
 ///
 
 /// new | New 5.1
@@ -333,7 +352,7 @@ Additionally, the `#!py3 os.O_DIRECTORY` may not be defined on some systems. You
 
 #### `glob.iglob` {: #iglob}
 
-```py3
+```py
 def iglob(patterns, *, flags=0, root_dir=None, dir_fd=None, limit=1000, exclude=None):
 ```
 
@@ -363,7 +382,7 @@ def iglob(patterns, *, flags=0, root_dir=None, dir_fd=None, limit=1000, exclude=
 
 #### `glob.globmatch` {: #globmatch}
 
-```py3
+```py
 def globmatch(filename, patterns, *, flags=0, root_dir=None, dir_fd=None, limit=1000, exclude=None):
 ```
 
@@ -482,10 +501,10 @@ True
 
 /// warning | Support for Directory Descriptors
 Directory descriptors may not be supported on all systems. You can check whether or not `dir_fd` is supported for a
-your platform referencing the attribute `#!py3 glob.SUPPORT_DIR_FD` which will be `#!py3 True` if it is supported.
+your platform referencing the attribute `#!py glob.SUPPORT_DIR_FD` which will be `#!py True` if it is supported.
 
-Additionally, the `#!py3 os.O_DIRECTORY` may not be defined on some systems. You can likely just use
-`#!py3 os.O_RDONLY`.
+Additionally, the `#!py os.O_DIRECTORY` may not be defined on some systems. You can likely just use
+`#!py os.O_RDONLY`.
 ///
 
 /// new | New 5.1
@@ -507,7 +526,7 @@ Additionally, the `#!py3 os.O_DIRECTORY` may not be defined on some systems. You
 
 #### `glob.globfilter` {: #globfilter}
 
-```py3
+```py
 def globfilter(filenames, patterns, *, flags=0, root_dir=None, dir_fd=None, limit=1000, method=None):
 ```
 
@@ -551,7 +570,7 @@ be traversed by `GLOBSTAR`. See [`globmatch`](#globmatch) for examples.
 
 #### `glob.translate` {: #translate}
 
-```py3
+```py
 def translate(patterns, *, flags=0, limit=1000, exclude=None):
 ```
 
@@ -571,8 +590,8 @@ matches at least one inclusion pattern and matches **none** of the exclusion pat
 
 When using [`EXTGLOB`](#extglob) patterns, patterns will be returned with capturing groups around the groups:
 
-While in regex patterns like `#!py3 r'(a)+'` would capture only the last character, even though multiple where matched,
-we wrap the entire group to be captured: `#!py3 '+(a)'` --> `#!py3 r'((a)+)'`.
+While in regex patterns like `#!py r'(a)+'` would capture only the last character, even though multiple where matched,
+we wrap the entire group to be captured: `#!py '+(a)'` --> `#!py r'((a)+)'`.
 
 ```pycon3
 >>> from wcmatch import glob
@@ -595,9 +614,60 @@ Translate patterns now provide capturing groups for [`EXTGLOB`](#extglob) groups
 `exclude` parameter was added.
 ///
 
+#### `glob.compile` {: #compile}
+
+```py
+def compile(patterns, *, flags=0, limit=1000, exclude=None):
+```
+
+The `compile` function takes a file pattern (or list of patterns) and flags. It also allows configuring the [max pattern
+limit](#multi-pattern-limits). Exclusion patterns can be specified via the `exclude` parameter which takes a pattern or
+a list of patterns. It returns a [`WcMatcher`](#wcmatcher) object which can match or filter file paths depending on
+which method is called. 
+
+```pycon3
+>>> import wcmatch.glob as glob
+>>> m = glob.compile('**/*.py', flags=glob.GLOBSTAR)
+>>> m.match('wcmatch/__init__.py')
+True
+>>> m.filter(['wcmatch/__init__.py', 'wcmatch/glob.py', 'README.md'])
+['wcmatch/__init__.py', 'wcmatch/glob.py']
+```
+
+#### `glob.WcMatcher` {: #wcmatcher}
+
+The `WcMatcher` class is returned when a pattern is precompiled with [`compile`](#compile). It has two methods: `match`
+and `filter`.
+
+```py
+def match(self, filename, *, root_dir=None, dir_fd=None):
+```
+
+This `match` method allows for matching against a precompiled pattern.
+
+```pycon3
+>>> import wcmatch.glob as glob
+>>> m = glob.compile('**/*.py', flags=glob.GLOBSTAR)
+>>> m.match('wcmatch/__init__.py')
+True
+```
+
+```py
+def filter(self, filenames, *, root_dir=None, dir_fd=None):
+```
+
+The `filter` method allows for filtering paths against a precompiled pattern.
+
+```pycon3
+>>> import wcmatch.glob as glob
+>>> m = glob.compile('**/*.py', flags=glob.GLOBSTAR)
+>>> m.filter(['wcmatch/__init__.py', 'wcmatch/glob.py', 'README.md'])
+['wcmatch/__init__.py', 'wcmatch/glob.py']
+```
+
 #### `glob.escape` {: #escape}
 
-```py3
+```py
 def escape(pattern, unix=None):
 ```
 
@@ -615,7 +685,7 @@ True
 
 `escape` can also handle Windows style paths with `/` or `\` path separators. It is usually recommended to use `/` as
 Windows backslashes are only supported via a special escape, but `\` will be expanded to an escaped backslash
-(represented in a raw string as `#!py3 r'\\'` or a normal string as `#!py3 '\\\\'`).
+(represented in a raw string as `#!py r'\\'` or a normal string as `#!py '\\\\'`).
 
 ```pycon3
 >>> from wmcatch import glob
@@ -663,7 +733,7 @@ drives manually in their match patterns as well.
 
 ### `glob.is_magic` {: #is_magic}
 
-```py3
+```py
 def is_magic(pattern, *, flags=0):
     """Check if the pattern is likely to be magic."""
 ```
@@ -729,8 +799,8 @@ insensitively, but the rest of the path will have case sensitive logic applied.
 
 #### `glob.RAWCHARS, glob.R` {: #rawchars}
 
-`RAWCHARS` causes string character syntax to be parsed in raw strings: `#!py3 r'\u0040'` --> `#!py3 r'@'`. This will
-handle standard string escapes and Unicode including `#!py3 r'\N{CHAR NAME}'`.
+`RAWCHARS` causes string character syntax to be parsed in raw strings: `#!py r'\u0040'` --> `#!py r'@'`. This will
+handle standard string escapes and Unicode including `#!py r'\N{CHAR NAME}'`.
 
 #### `glob.NEGATE, glob.N` {: #negate}
 
diff --git a/docs/src/markdown/pathlib.md b/docs/src/markdown/pathlib.md
index c3e9725..6b3873a 100644
--- a/docs/src/markdown/pathlib.md
+++ b/docs/src/markdown/pathlib.md
@@ -1,6 +1,6 @@
 # `wcmatch.pathlib`
 
-```py3
+```py
 from wcmatch import pathlib
 ```
 
@@ -243,7 +243,7 @@ PosixPath('/usr/local/bin')
 
 #### `PurePath.match` {: #match}
 
-```py3
+```py
 def match(self, patterns, *, flags=0, limit=1000, exclude=None):
 ```
 
@@ -282,7 +282,7 @@ True
 
 #### `PurePath.globmatch` {: #globmatch}
 
-```py3
+```py
 def globmatch(self, patterns, *, flags=0, limit=1000, exclude=None):
 ```
 
@@ -321,7 +321,7 @@ True
 /// new | new 10.0
 ///
 
-```py3
+```py
 def full_match(self, patterns, *, flags=0, limit=1000, exclude=None):
 ```
 
@@ -331,7 +331,7 @@ what our existing `PurePath.globmatch` has been doing prior to Python 3.13. We'v
 
 #### `Path.glob` {: #glob}
 
-```py3
+```py
 def glob(self, patterns, *, flags=0, limit=1000, exclude=None):
 ```
 
@@ -366,7 +366,7 @@ working directory.
 
 #### `Path.rglob` {: #rglob}
 
-```py3
+```py
 def rglob(self, patterns, *, flags=0, path_limit=1000, exclude=None):
 ```
 
@@ -414,8 +414,8 @@ insensitively, but the rest of the path will have case sensitive logic applied.
 
 #### `glob.RAWCHARS, glob.R` {: #rawchars}
 
-`RAWCHARS` causes string character syntax to be parsed in raw strings: `#!py3 r'\u0040'` --> `#!py3 r'@'`. This will
-handle standard string escapes and Unicode including `#!py3 r'\N{CHAR NAME}'`.
+`RAWCHARS` causes string character syntax to be parsed in raw strings: `#!py r'\u0040'` --> `#!py r'@'`. This will
+handle standard string escapes and Unicode including `#!py r'\N{CHAR NAME}'`.
 
 #### `pathlib.NEGATE, pathlib.N` {: #negate}
 
diff --git a/docs/src/markdown/wcmatch.md b/docs/src/markdown/wcmatch.md
index 7e1e2a9..f0127ee 100644
--- a/docs/src/markdown/wcmatch.md
+++ b/docs/src/markdown/wcmatch.md
@@ -1,6 +1,6 @@
 # `wcmatch.wcmatch`
 
-```py3
+```py
 from wcmatch import wcmatch
 ```
 
@@ -16,7 +16,7 @@ interface.
 
 ## `wcmatch.WcMatch` {: #wcmatch}
 
-```py3
+```py
 class WcMatch:
     """Finds files by wildcard."""
 
@@ -32,10 +32,10 @@ is matched, skipped, or when there is an error. There are also hooks where you c
 Parameter         | Default       | Description
 ----------------- | ------------- | -----------
 `root_dir`        |               | The root directory to search.
-`file_pattern`    | `#!py3 ''`    | One or more patterns separated by `|`. You can define exceptions by starting a pattern with `!` (or `-` if [`MINUSNEGATE`](#minusnegate) is set). The default is an empty string, but if an empty string is used, all files will be matched.
-`exclude_pattern` | `#!py3 ''`    | Zero or more folder exclude patterns separated by `|`. You can define exceptions by starting a pattern with `!` (or `-` if [`MINUSNEGATE`](#minusnegate) is set).
-`flags`           | `#!py3 0`     | Flags to alter behavior of folder and file matching. See [Flags](#flags) for more info.
-`limit`           | `#!py3 1000`  | Allows configuring the [max pattern limit](#multi-pattern-limits).
+`file_pattern`    | `#!py ''`    | One or more patterns separated by `|`. You can define exceptions by starting a pattern with `!` (or `-` if [`MINUSNEGATE`](#minusnegate) is set). The default is an empty string, but if an empty string is used, all files will be matched.
+`exclude_pattern` | `#!py ''`    | Zero or more folder exclude patterns separated by `|`. You can define exceptions by starting a pattern with `!` (or `-` if [`MINUSNEGATE`](#minusnegate) is set).
+`flags`           | `#!py 0`     | Flags to alter behavior of folder and file matching. See [Flags](#flags) for more info.
+`limit`           | `#!py 1000`  | Allows configuring the [max pattern limit](#multi-pattern-limits).
 
 /// note
 Dots are not treated special in `wcmatch`. When the `HIDDEN` flag is not included, all hidden files (system and dot
@@ -210,7 +210,7 @@ Returns the number of skipped files. Files in skipped folders are not included i
 
 #### `WcMatch.on_init` {: #on_init}
 
-```py3
+```py
    def on_init(self, **kwargs):
         """Handle custom init."""
 ```
@@ -225,7 +225,7 @@ Starting in 8.0, `on_init` only accepts keyword arguments as now `WcMatch` requi
 
 #### `WcMatch.on_validate_directory` {: #on_validate_directory}
 
-```py3
+```py
     def on_validate_directory(self, base, name):
         """Validate folder override."""
 
@@ -237,7 +237,7 @@ overridden to provide additional validation if required.
 
 #### `WcMatch.on_validate_file` {: #on_validate_file}
 
-```py3
+```py
     def on_validate_file(self, base, name):
         """Validate file override."""
 
@@ -249,7 +249,7 @@ provide additional validation if required.
 
 #### `WcMatch.on_skip` {: #on_skip}
 
-```py3
+```py
     def on_skip(self, base, name):
         """On skip."""
 
@@ -263,7 +263,7 @@ a special type for skip returns so that you can identify them when they are retu
 
 #### `WcMatch.on_error` {: #on_error}
 
-```py3
+```py
     def on_error(self, base, name):
         """On error."""
 
@@ -276,7 +276,7 @@ returns so that you can identify them when they are returned via [`match`](#matc
 
 #### `WcMatch.on_match` {: #on_match}
 
-```py3
+```py
     def on_match(self, base, name):
         """On match."""
 
@@ -290,7 +290,7 @@ file meta data. `on_match` must return something, and all results will be return
 
 #### `WcMatch.on_reset` {: #on_reset}
 
-```py3
+```py
     def on_reset(self):
         """On reset."""
         pass
@@ -325,8 +325,8 @@ crawl.
 
 #### `wcmatch.RAWCHARS, wcmatch.R` {: #rawchars}
 
-`RAWCHARS` causes string character syntax to be parsed in raw strings: `#!py3 r'\u0040'` --> `#!py3 r'@'`. This will
-handle standard string escapes and Unicode (including `#!py3 r'\N{CHAR NAME}'`).
+`RAWCHARS` causes string character syntax to be parsed in raw strings: `#!py r'\u0040'` --> `#!py r'@'`. This will
+handle standard string escapes and Unicode (including `#!py r'\N{CHAR NAME}'`).
 
 #### `wcmatch.EXTMATCH, wcmatch.E` {: #extmatch}
 
diff --git a/wcmatch/__meta__.py b/wcmatch/__meta__.py
index d867034..4ff6b09 100644
--- a/wcmatch/__meta__.py
+++ b/wcmatch/__meta__.py
@@ -193,5 +193,5 @@ def parse_version(ver: str) -> Version:
     return Version(major, minor, micro, release, pre, post, dev)
 
 
-__version_info__ = Version(10, 0, 0, "final")
+__version_info__ = Version(10, 1, 0, "final")
 __version__ = __version_info__._get_canonical()
diff --git a/wcmatch/_wcmatch.py b/wcmatch/_wcmatch.py
index 36fc1ae..dfb779c 100644
--- a/wcmatch/_wcmatch.py
+++ b/wcmatch/_wcmatch.py
@@ -5,7 +5,7 @@ import os
 import stat
 import copyreg
 from . import util
-from typing import Pattern, AnyStr, Generic, Any
+from typing import Pattern, AnyStr, Generic, Iterable, Any
 
 # `O_DIRECTORY` may not always be defined
 DIR_FLAGS = os.O_RDONLY | getattr(os, 'O_DIRECTORY', 0)
@@ -320,21 +320,58 @@ class WcRegexp(util.Immutable, Generic[AnyStr]):
             self._follow != other._follow
         )
 
-    def match(self, filename: AnyStr, root_dir: AnyStr | None = None, dir_fd: int | None = None) -> bool:
-        """Match filename."""
+    def match(
+        self,
+        filename: AnyStr | os.PathLike[AnyStr],
+        root_dir: AnyStr | os.PathLike[AnyStr] | None = None,
+        dir_fd: int | None = None
+    ) -> bool:
+        """Filter filenames."""
+
+        if not filename:
+            return False
 
         return _Match(
-            filename,
+            os.fspath(filename),
             self._include,
             self._exclude,
             self._real,
             self._path,
             self._follow
         ).match(
-            root_dir=root_dir,
+            root_dir=os.fspath(root_dir) if root_dir is not None else None,
             dir_fd=dir_fd
         )
 
+    def filter(
+        self,
+        filenames: Iterable[AnyStr | os.PathLike[AnyStr]],
+        root_dir: AnyStr | os.PathLike[AnyStr] | None = None,
+        dir_fd: int | None = None
+    ) ->  list[AnyStr | os.PathLike[AnyStr]]:
+        """Filter filenames."""
+
+        if not filenames:
+            return []
+
+        rdir = os.fspath(root_dir) if root_dir is not None else None
+        matches = [
+            filename
+            for filename in filenames
+            if _Match(
+                os.fspath(filename),
+                self._include,
+                self._exclude,
+                self._real,
+                self._path,
+                self._follow
+            ).match(
+                root_dir=rdir,
+                dir_fd=dir_fd
+            )
+        ]
+        return matches
+
 
 def _pickle(p):  # type: ignore[no-untyped-def]
     return WcRegexp, (p._include, p._exclude, p._real, p._path, p._follow)
diff --git a/wcmatch/fnmatch.py b/wcmatch/fnmatch.py
index 6d1c853..ac7531c 100644
--- a/wcmatch/fnmatch.py
+++ b/wcmatch/fnmatch.py
@@ -1,18 +1,21 @@
+# noqa: A005
 """
 Wild Card Match.
 
 A custom implementation of `fnmatch`.
 """
 from __future__ import annotations
+from wcmatch._wcmatch import WcRegexp
 from . import _wcparse
-from typing import AnyStr, Iterable, Sequence
+from typing import AnyStr, Iterable, Sequence, Generic
 
 __all__ = (
     "CASE", "EXTMATCH", "IGNORECASE", "RAWCHARS",
     "NEGATE", "MINUSNEGATE", "DOTMATCH", "BRACE", "SPLIT",
     "NEGATEALL", "FORCEWIN", "FORCEUNIX",
     "C", "I", "R", "N", "M", "D", "E", "S", "B", "A", "W", "U",
-    "translate", "fnmatch", "filter", "escape", "is_magic"
+    "translate", "fnmatch", "filter", "escape", "is_magic", "compile",
+    "WcMatcher"
 )
 
 C = CASE = _wcparse.CASE
@@ -44,6 +47,36 @@ FLAG_MASK = (
 )
 
 
+class WcMatcher(Generic[AnyStr]):
+    """Pre-compiled matcher object."""
+
+    def __init__(self, matcher: WcRegexp[AnyStr]) -> None:
+        """Initialize."""
+
+        self.matcher = matcher  # type: WcRegexp[AnyStr]
+
+    def match(self, filename: AnyStr) -> bool:
+        """Match filename."""
+
+        return self.matcher.match(filename)
+
+    def filter(self, filenames: Iterable[AnyStr]) -> list[AnyStr]:
+        """Match filename."""
+
+        return self.matcher.filter(filenames)  # type: ignore[return-value]
+
+
+def compile(  # noqa: A001
+    patterns: AnyStr | Sequence[AnyStr],
+    flags: int = 0,
+    limit: int = _wcparse.PATTERN_LIMIT,
+    exclude: AnyStr | Sequence[AnyStr] | None = None
+) -> WcMatcher[AnyStr]:
+    """Pre-compile a matcher object."""
+
+    return WcMatcher(_wcparse.compile(patterns, _flag_transform(flags), limit, exclude=exclude))
+
+
 def _flag_transform(flags: int) -> int:
     """Transform flags to glob defaults."""
 
@@ -63,8 +96,7 @@ def translate(
 ) -> tuple[list[AnyStr], list[AnyStr]]:
     """Translate `fnmatch` pattern."""
 
-    flags = _flag_transform(flags)
-    return _wcparse.translate(patterns, flags, limit, exclude=exclude)
+    return _wcparse.translate(patterns, _flag_transform(flags), limit, exclude=exclude)
 
 
 def fnmatch(
@@ -82,8 +114,7 @@ def fnmatch(
     but if `case_sensitive` is set, respect that instead.
     """
 
-    flags = _flag_transform(flags)
-    return bool(_wcparse.compile(patterns, flags, limit, exclude=exclude).match(filename))
+    return _wcparse.compile(patterns, _flag_transform(flags), limit, exclude=exclude).match(filename)
 
 
 def filter(  # noqa A001
@@ -96,15 +127,7 @@ def filter(  # noqa A001
 ) -> list[AnyStr]:
     """Filter names using pattern."""
 
-    matches = []
-
-    flags = _flag_transform(flags)
-    obj = _wcparse.compile(patterns, flags, limit, exclude=exclude)
-
-    for filename in filenames:
-        if obj.match(filename):
-            matches.append(filename)  # noqa: PERF401
-    return matches
+    return _wcparse.compile(patterns, _flag_transform(flags), limit, exclude=exclude).filter(filenames)  # type: ignore[return-value]
 
 
 def escape(pattern: AnyStr) -> AnyStr:
diff --git a/wcmatch/glob.py b/wcmatch/glob.py
index 13edaef..8707133 100644
--- a/wcmatch/glob.py
+++ b/wcmatch/glob.py
@@ -1,3 +1,4 @@
+# noqa: A005
 """
 Wild Card Match.
 
@@ -13,6 +14,7 @@ import bracex
 from . import _wcparse
 from . import _wcmatch
 from . import util
+from ._wcmatch import WcRegexp
 from typing import Iterator, Iterable, AnyStr, Generic, Pattern, Callable, Any, Sequence
 
 __all__ = (
@@ -21,7 +23,8 @@ __all__ = (
     "REALPATH", "FOLLOW", "MATCHBASE", "MARK", "NEGATEALL", "NODIR", "FORCEWIN", "FORCEUNIX", "GLOBTILDE",
     "NODOTDIR", "SCANDOTDIR", "SUPPORT_DIR_FD", "GLOBSTARLONG",
     "C", "I", "R", "D", "E", "G", "N", "M", "B", "P", "L", "S", "X", 'K', "O", "A", "W", "U", "T", "Q", "Z", "SD", "GL",
-    "iglob", "glob", "globmatch", "globfilter", "escape", "is_magic"
+    "iglob", "glob", "globmatch", "globfilter", "escape", "is_magic", "compile",
+    "Glob", "WcMatcher"
 )
 
 # We don't use `util.platform` only because we mock it in tests,
@@ -388,6 +391,7 @@ class Glob(Generic[AnyStr]):
     def __init__(
         self,
         pattern: AnyStr | Sequence[AnyStr],
+        *,
         flags: int = 0,
         root_dir: AnyStr | os.PathLike[AnyStr] | None = None,
         dir_fd: int | None = None,
@@ -403,6 +407,10 @@ class Glob(Generic[AnyStr]):
             flags = _wcparse.no_negate_flags(flags)
 
         self.pattern = []  # type: list[list[_GlobPart]]
+
+        if not isinstance(pattern, (str, bytes)) and not pattern:
+            return
+
         self.npatterns = []  # type: list[Pattern[AnyStr]]
         self.seen = set()  # type: set[AnyStr]
         self.dir_fd = dir_fd if SUPPORT_DIR_FD else None  # type: int | None
@@ -597,15 +605,15 @@ class Glob(Generic[AnyStr]):
         """Check if file exists."""
 
         if not self.dir_fd:
-            return os.path.lexists(self.prepend_base(path))
+            return os.path.lexists(self._prepend_base(path))
         try:
-            os.lstat(self.prepend_base(path), dir_fd=self.dir_fd)
+            os.lstat(self._prepend_base(path), dir_fd=self.dir_fd)
         except (OSError, ValueError):  # pragma: no cover
             return False
         else:
             return True
 
-    def prepend_base(self, path: AnyStr) -> AnyStr:
+    def _prepend_base(self, path: AnyStr) -> AnyStr:
         """Join path to base if pattern is not absolute."""
 
         if self.is_abs_pattern:
@@ -777,7 +785,7 @@ class Glob(Generic[AnyStr]):
             results = [(curdir, True)]
         return results
 
-    def is_unique(self, path: AnyStr) -> bool:
+    def _is_unique(self, path: AnyStr) -> bool:
         """Test if path is unique."""
 
         if self.nounique:
@@ -795,11 +803,11 @@ class Glob(Generic[AnyStr]):
         path = self.re_pathlib_norm.sub(self.empty, path)
         return path[:-1] if len(path) > 1 and path[-1:] in self.seps else path
 
-    def format_path(self, path: AnyStr, is_dir: bool, dir_only: bool) -> Iterator[AnyStr]:
+    def _format_path(self, path: AnyStr, is_dir: bool, dir_only: bool) -> Iterator[AnyStr]:
         """Format path."""
 
         path = os.path.join(path, self.empty) if dir_only or (self.mark and is_dir) else path
-        if self.is_unique(self._pathlib_norm(path) if self.pathlib else path):
+        if self._is_unique(self._pathlib_norm(path) if self.pathlib else path):
             yield path
 
     def glob(self) -> Iterator[AnyStr]:
@@ -820,7 +828,7 @@ class Glob(Generic[AnyStr]):
                     curdir = this[0]
 
                     # Abort if we cannot find the drive, or if current directory is empty
-                    if not curdir or (self.is_abs_pattern and not self._lexists(self.prepend_base(curdir))):
+                    if not curdir or (self.is_abs_pattern and not self._lexists(self._prepend_base(curdir))):
                         continue
 
                     # Make sure case matches, but running case insensitive
@@ -838,21 +846,21 @@ class Glob(Generic[AnyStr]):
                                 this = rest.pop(0)
                                 for match, is_dir in self._glob(start, this, rest):
                                     if not self._is_excluded(match, is_dir):
-                                        yield from self.format_path(match, is_dir, dir_only)
+                                        yield from self._format_path(match, is_dir, dir_only)
                             elif not self._is_excluded(start, is_dir):
-                                yield from self.format_path(start, is_dir, dir_only)
+                                yield from self._format_path(start, is_dir, dir_only)
                     else:
                         # Return the file(s) and finish.
                         for match, is_dir in results:
                             if self._lexists(match) and not self._is_excluded(match, is_dir):
-                                yield from self.format_path(match, is_dir, dir_only)
+                                yield from self._format_path(match, is_dir, dir_only)
                 else:
                     # Path starts with a magic pattern, let's get globbing
                     rest = pattern[:]
                     this = rest.pop(0)
                     for match, is_dir in self._glob(curdir if not curdir == self.current else self.empty, this, rest):
                         if not self._is_excluded(match, is_dir):
-                            yield from self.format_path(match, is_dir, dir_only)
+                            yield from self._format_path(match, is_dir, dir_only)
 
 
 def iglob(
@@ -866,10 +874,14 @@ def iglob(
 ) -> Iterator[AnyStr]:
     """Glob."""
 
-    if not isinstance(patterns, (str, bytes)) and not patterns:
-        return
-
-    yield from Glob(patterns, flags, root_dir, dir_fd, limit, exclude).glob()
+    yield from Glob(
+        patterns,
+        flags=flags,
+        root_dir=root_dir,
+        dir_fd=dir_fd,
+        limit=limit,
+        exclude=exclude
+    ).glob()
 
 
 def glob(
@@ -886,6 +898,49 @@ def glob(
     return list(iglob(patterns, flags=flags, root_dir=root_dir, dir_fd=dir_fd, limit=limit, exclude=exclude))
 
 
+class WcMatcher(Generic[AnyStr]):
+    """Pre-compiled matcher object."""
+
+    def __init__(self, matcher: WcRegexp[AnyStr]) -> None:
+        """Initialize."""
+
+        self.matcher = matcher  # type: WcRegexp[AnyStr]
+
+    def match(
+        self,
+        filename: AnyStr | os.PathLike[AnyStr],
+        *,
+        root_dir: AnyStr | os.PathLike[AnyStr] | None = None,
+        dir_fd: int | None = None
+    ) -> bool:
+        """Match filename."""
+
+        return self.matcher.match(filename, root_dir, dir_fd)
+
+    def filter(
+        self,
+        filenames: Iterable[AnyStr | os.PathLike[AnyStr]],
+        *,
+        root_dir: AnyStr | os.PathLike[AnyStr] | None = None,
+        dir_fd: int | None = None
+    ) -> list[AnyStr | os.PathLike[AnyStr]]:
+        """Match filename."""
+
+        return self.matcher.filter(filenames, root_dir, dir_fd)
+
+
+def compile(  # noqa: A001
+    patterns: AnyStr | Sequence[AnyStr],
+    *,
+    flags: int = 0,
+    limit: int = _wcparse.PATTERN_LIMIT,
+    exclude: AnyStr | Sequence[AnyStr] | None = None
+) -> WcMatcher[AnyStr]:
+    """Pre-compile a matcher object."""
+
+    return WcMatcher(_wcparse.compile(patterns, _flag_transform(flags), limit, exclude=exclude))
+
+
 def translate(
     patterns: AnyStr | Sequence[AnyStr],
     *,
@@ -895,8 +950,7 @@ def translate(
 ) -> tuple[list[AnyStr], list[AnyStr]]:
     """Translate glob pattern."""
 
-    flags = _flag_transform(flags)
-    return _wcparse.translate(patterns, flags, limit, exclude)
+    return _wcparse.translate(patterns, _flag_transform(flags), limit, exclude)
 
 
 def globmatch(
@@ -916,15 +970,7 @@ def globmatch(
     but if `case_sensitive` is set, respect that instead.
     """
 
-    # Shortcut out if we have no patterns
-    if not patterns:
-        return False
-
-    rdir = os.fspath(root_dir) if root_dir is not None else root_dir
-    flags = _flag_transform(flags)
-    fname = os.fspath(filename)
-
-    return bool(_wcparse.compile(patterns, flags, limit, exclude).match(fname, rdir, dir_fd))
+    return _wcparse.compile(patterns, _flag_transform(flags), limit, exclude).match(filename, root_dir, dir_fd)
 
 
 def globfilter(
@@ -939,21 +985,7 @@ def globfilter(
 ) -> list[AnyStr | os.PathLike[AnyStr]]:
     """Filter names using pattern."""
 
-    # Shortcut out if we have no patterns
-    if not patterns:
-        return []
-
-    rdir = os.fspath(root_dir) if root_dir is not None else root_dir
-
-    matches = []  # type: list[AnyStr | os.PathLike[AnyStr]]
-    flags = _flag_transform(flags)
-    obj = _wcparse.compile(patterns, flags, limit, exclude)
-
-    for filename in filenames:
-        temp = os.fspath(filename)
-        if obj.match(temp, rdir, dir_fd):
-            matches.append(filename)
-    return matches
+    return _wcparse.compile(patterns, _flag_transform(flags), limit, exclude).filter(filenames, root_dir, dir_fd)
 
 
 def escape(pattern: AnyStr, unix: bool | None = None) -> AnyStr:
diff --git a/wcmatch/pathlib.py b/wcmatch/pathlib.py
index 237613e..79c517f 100644
--- a/wcmatch/pathlib.py
+++ b/wcmatch/pathlib.py
@@ -1,3 +1,4 @@
+# noqa: A005
 """Pathlib implementation that uses our own glob."""
 from __future__ import annotations
 import pathlib
diff --git a/wcmatch/posix.py b/wcmatch/posix.py
index dac4b26..53acd2d 100644
--- a/wcmatch/posix.py
+++ b/wcmatch/posix.py
@@ -1,3 +1,4 @@
+# noqa: A005
 """Posix Properties."""
 from __future__ import annotations
 


Test patch:
diff --git a/tests/test_fnmatch.py b/tests/test_fnmatch.py
index a13313b..aa802df 100644
--- a/tests/test_fnmatch.py
+++ b/tests/test_fnmatch.py
@@ -776,3 +776,31 @@ class TestTypes(unittest.TestCase):
         """Test `list` matching."""
 
         self.assertTrue(fnmatch.fnmatch('a', ['a']))
+
+
+class TestPrecompile(unittest.TestCase):
+    """Test precompiled match objects."""
+
+    def test_precompiled_match(self):
+        """Test precompiled matching."""
+
+        m = fnmatch.compile('*file')
+        self.assertTrue(m.match('testfile'))
+
+    def test_precompiled_match_empty(self):
+        """Test precompiled matching with empty input."""
+
+        m = fnmatch.compile('*file')
+        self.assertFalse(m.match(''))
+
+    def test_precompiled_filter(self):
+        """Test precompiled filtering."""
+
+        m = fnmatch.compile('*file')
+        self.assertEqual(m.filter(['testfile', 'test_2_file', 'nope']), ['testfile', 'test_2_file'])
+
+    def test_precompiled_filter_empty(self):
+        """Test precompiled filtering with empty input."""
+
+        m = fnmatch.compile('*file')
+        self.assertEqual(m.filter([]), [])
diff --git a/tests/test_glob.py b/tests/test_glob.py
index 0ca62ef..c265e6d 100644
--- a/tests/test_glob.py
+++ b/tests/test_glob.py
@@ -531,17 +531,13 @@ class Testglob(_TestGlob):
         Options(default_negate='sym3/EF'),
         [
             ('**', 'EF'),
-            [
-            ] if not can_symlink() else [
-            ],
+            [],
             glob.N | glob.L
         ],
 
         [
             ('**', 'EF'),
-            [
-            ] if not can_symlink() else [
-            ],
+            [],
             glob.N
         ],
 
@@ -953,9 +949,7 @@ class Testglob(_TestGlob):
         Options(just_negative=True, default_negate='**'),
         [
             ('a*', '**'),
-            [
-            ] if not can_symlink() else [
-            ],
+            [],
             glob.N
         ],
         Options(just_negative=False, cwd_temp=False, absolute=False),
diff --git a/tests/test_globmatch.py b/tests/test_globmatch.py
index e16c906..181bfe4 100644
--- a/tests/test_globmatch.py
+++ b/tests/test_globmatch.py
@@ -9,6 +9,7 @@ import wcmatch.glob as glob
 import wcmatch._wcparse as _wcparse
 import wcmatch.util as util
 import shutil
+import pathlib
 
 # Below is general helper stuff that Python uses in `unittests`.  As these
 # not meant for users, and could change without notice, include them
@@ -1921,3 +1922,40 @@ class TestExcludes(unittest.TestCase):
         """Test exclusion with filter."""
 
         self.assertEqual(glob.globfilter(['path/name', 'path/test'], '*/*', exclude='path/test'), ['path/name'])
+
+
+class TestPrecompile(unittest.TestCase):
+    """Test precompiled match objects."""
+
+    def test_precompiled_match(self):
+        """Test precompiled matching."""
+
+        m = glob.compile('**/file', flags=glob.GLOBSTAR)
+        self.assertTrue(m.match('some/path/file'))
+
+    def test_precompiled_filter(self):
+        """Test precompiled filtering."""
+
+        pattern = '**/file'
+        m = glob.compile(pattern, flags=glob.GLOBSTAR)
+        self.assertEqual(
+            m.filter(['test/file', 'file', 'nope']),
+            glob.globfilter(['test/file', 'file', 'nope'], pattern, flags=glob.GLOBSTAR)
+        )
+
+    def test_precompiled_match_pathlib(self):
+        """Test precompiled matching."""
+
+        m = glob.compile('**/file', flags=glob.GLOBSTAR)
+        self.assertTrue(m.match(pathlib.PurePath('some/path/file')))
+
+    def test_precompiled_filter_pathlib(self):
+        """Test precompiled filtering."""
+
+        pattern = '**/file'
+        m = glob.compile(pattern, flags=glob.GLOBSTAR)
+        paths = [pathlib.PurePath('test/file'), pathlib.PurePath('file'), pathlib.PurePath('nope')]
+        self.assertEqual(
+            m.filter(paths),
+            glob.globfilter(paths, pattern, flags=glob.GLOBSTAR)
+        )