From 572d21c4bb350d58323776647c0d3b075019f19c Mon Sep 17 00:00:00 2001
From: "carpentry-heartbeat[bot]"
Date: Sun, 9 Aug 2026 21:37:44 +0200
Subject: [PATCH 1/4] Add Path.matches? and Path.matching for glob matching
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
`path` could split, join, normalize and relativize, but a caller holding a
list of paths had no way to ask which of them are `.carp` files under `test/`
without shelling out to `find`. Glob matching is the standard lexical
companion to the operations already here — Go's `filepath.Match`, Python's
`fnmatch` and Rust's `glob` all have it.
`matches?` takes the path first, like every other function in this module,
and is a total `Bool`: an unterminated `[` and a trailing `\` are matched as
literal characters rather than reported as errors, the former following
glibc's `fnmatch`. `**` is a segment wildcard only when it is a whole
segment; anywhere else it collapses into a plain `*`.
Pattern and path are both decoded to `(Array Char)` with `String.chars`
before matching, so `?` and character classes count codepoints rather than
bytes, and no index can walk off the end of a buffer the way a computed
`String.byte-slice` could.
Stars use the standard linear backtracking loop — remember the last star and
the position it matched from, retry one character further on a mismatch — at
both the character and the segment level, so `*a*a*a*a*a*a*b` against a long
run of `a`s stays quadratic rather than exponential.
Verified against glibc `fnmatch(3)`: over all 11110 patterns of length up to
four drawn from `[ ] ! ^ - \ * ? a b`, matched against ten paths, the only
divergences are the 15 documented trailing-backslash cases. The segment-level
`**` handling was checked the same way against a naive exponential reference
over 32768 pattern/path pairs, with no divergence.
---
README.md | 34 +++++++++
docs/Path.html | 68 ++++++++++++++++++
path.carp | 187 ++++++++++++++++++++++++++++++++++++++++++++++++-
test/path.carp | 147 +++++++++++++++++++++++++++++++++++++-
4 files changed, 433 insertions(+), 3 deletions(-)
diff --git a/README.md b/README.md
index 22e589c..97f8b86 100644
--- a/README.md
+++ b/README.md
@@ -19,6 +19,40 @@ relative to a base the same way. It also has some functions to work with the
It assumes either Windows or POSIX-style separators.
+### Glob matching
+
+`matches?` checks a path against a glob pattern, and `matching` keeps the paths
+in an array that match one, in order. Both are lexical as well and never touch
+the filesystem, and both take the path first, like everything else here — note
+that this is the opposite of `Pattern.matches?` in core.
+
+```clojure
+(Path.matches? "src/main.carp" "src/*.carp") ; => true
+(Path.matching &[@"a.carp" @"b.c"] "*.carp") ; => [@"a.carp"]
+(Path.matching &[@"a.carp" @"t/b.carp"] "**/*.carp") ; => [@"a.carp" @"t/b.carp"]
+```
+
+| Form | Meaning |
+|----------|------------------------------------------------------|
+| `?` | exactly one character, never a separator |
+| `*` | zero or more characters, never a separator |
+| `**` | zero or more whole segments, as an entire segment |
+| `[abc]` | one of the characters in the class |
+| `[a-z]` | one character from the range |
+| `[!abc]` | one character not in the class (`[^abc]` also works) |
+| `\*` | a literal `*`, on POSIX |
+
+`**` is only a segment wildcard when it is the whole segment, so `**/*.carp`
+matches both `a.carp` and `x/y/a.carp`, while `a**b` is just `a*b`. On POSIX a
+`\` escapes the next pattern character; on Windows `\` is a separator, so
+escaping is disabled there. A trailing `\` and an unterminated `[` are matched
+as literal characters, which is why `matches?` is a plain `Bool` and not a
+`Result`.
+
+Leading dots are not special: `*` matches `.hidden`. Matching happens on the
+path exactly as given, with no normalization, so run it through `normalize`
+first if `.`, `..` or repeated separators should not get in the way.
+
Look at [the documentation](https://carpentry.dev/path) for more information.
diff --git a/docs/Path.html b/docs/Path.html
index e016ca0..2ab11cc 100644
--- a/docs/Path.html
+++ b/docs/Path.html
@@ -308,6 +308,74 @@
+
+
+
+ matches?
+
+
+
+ defn
+
+
+ (Fn [(Ref String a), (Ref String b)] Bool)
+
+
+ (matches? p pattern)
+
+
+
checks whether the path p matches the glob pattern.
+
The path comes first, like everywhere else in this module — note that this is
+the opposite of Pattern.matches? in core:
+
(matches? "src/main.carp" "src/*.carp") ; => true
+
+
The pattern language is:
+
? exactly one character, never a separator
+* zero or more characters, never a separator
+** zero or more whole segments, as an entire segment
+[abc] one of the characters in the class
+[a-z] one character from the range
+[!abc] one character not in the class ([^abc] works too)
+\* a literal *, on POSIX (see below)
+
+
** is only a segment wildcard when it is the whole segment, so **/*.carp
+matches both a.carp and x/y/a.carp, while a**b is just a*b. Inside a
+class, a ] directly after the [ (or after the !/^) is a literal ], and
+a - first or last is a literal -.
+
On POSIX a \ escapes the next pattern character. On Windows \ is a
+separator, so escaping is disabled there. A trailing \ and an unterminated
+[ are matched as literal characters, which is why this is a total function and
+not a Result.
+
Leading dots are not special: * matches .hidden. Matching happens on the
+path exactly as given, with no normalization and no collapsing of repeated
+separators — run it through normalize first if you want that.
+
+
+
+
+
+
+ matching
+
+
+
+ defn
+
+
+ (Fn [(Ref (Array String) a), (Ref String b)] (Array String))
+
+
+ (matching ps pattern)
+
+
+
keeps the paths in ps that match the glob pattern,
+preserving their order.
+
It is matches? over an array, which is usually what you want:
+
(matching &[@"a.carp" @"a.c"] "*.carp") ; => [@"a.carp"]
+
+
+
+