From 3367d1c4ee3d5d779989fda3b3149d566f2d93da Mon Sep 17 00:00:00 2001 From: "carpentry-heartbeat[bot]" Date: Sun, 12 Jul 2026 18:02:49 +0200 Subject: [PATCH 1/3] Add Path.normalize for lexical path normalization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Path could split, join, and merge paths but had no way to clean one up: resolve . and .. segments or collapse repeated separators. normalize does this lexically (like Go's path.Clean), without touching the filesystem — it drops . components, resolves each .. against the preceding component, keeps a leading .. in relative paths (unresolvable without a base), and never lets .. escape the root of an absolute path. Empty/fully-cancelling relative paths become '.', the root stays '/'. Adds 10 tests covering repeated separators, . and .. resolution, absolute-root clamping, leading-.. preservation, and the empty/root/trailing-separator edges. --- README.md | 6 ++++-- path.carp | 41 +++++++++++++++++++++++++++++++++++++++++ test/path.carp | 31 +++++++++++++++++++++++++++++++ 3 files changed, 76 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index ea40b37..0d54a8d 100644 --- a/README.md +++ b/README.md @@ -11,8 +11,10 @@ is a simple file path library for Carp. ### Usage The `Path` module mostly operates on `String` arguments. It allows you to -split, join, and merge paths and extensions in a lot of different ways. It also -has some functions to work with the `PATH` environment variable. +split, join, merge, and normalize paths and extensions in a lot of different +ways. `normalize` resolves `.`/`..` segments and collapses repeated separators +lexically (without touching the filesystem). It also has some functions to work +with the `PATH` environment variable. It assumes either Windows or POSIX-style separators. diff --git a/path.carp b/path.carp index a6fa099..761d170 100644 --- a/path.carp +++ b/path.carp @@ -89,6 +89,47 @@ Otherwise returns `Just` of the last component.") but-last (Array.prefix &split (dec (Array.length &split)))] (String.join sep-string &but-last))) + (doc normalize "normalizes the path `p` lexically, without touching the +filesystem. + +It collapses repeated separators, drops `.` components, and resolves each `..` +against the preceding component. Leading `..` components are kept in relative +paths (they cannot be resolved without a base), and `..` never escapes the root +of an absolute path. An empty or fully-cancelling relative path normalizes to +`.`, and the root normalizes to a single separator. + +Examples on POSIX: +``` +(normalize \"a/./b\") ; => \"a/b\" +(normalize \"a/b/../c\") ; => \"a/c\" +(normalize \"//a///b\") ; => \"/a/b\" +(normalize \"/a/../..\") ; => \"/\" +(normalize \"a/../..\") ; => \"..\" +(normalize \"\") ; => \".\" +```") + (defn normalize [p] + (if (String.empty? p) + @"." + (let-do [abs (absolute? p) + comps (split p) + out []] + (for [i 0 (Array.length &comps)] + (let [c (Array.unsafe-nth &comps i)] + (cond + (or (String.empty? c) (= c ".")) () + (= c "..") + (let [n (Array.length &out)] + (if (= n 0) + (unless abs (set! out (Array.push-back out @c))) + (if (= (Array.unsafe-nth &out (dec n)) "..") + (set! out (Array.push-back out @c)) + (set! out (Array.prefix &out (dec n)))))) + (set! out (Array.push-back out @c))))) + (let [joined (String.join sep-string &out)] + (if abs + (String.concat &[@sep-string joined]) + (if (String.empty? &joined) @"." joined)))))) + (doc split-extension "splits the path `p` on its extension. It will return a `(Maybe (Pair String String))`. `Maybe` because there might not diff --git a/test/path.carp b/test/path.carp index adbc29c..b34f843 100644 --- a/test/path.carp +++ b/test/path.carp @@ -60,6 +60,37 @@ "path/to/file" &(join &[@"path" @"to" @"file"]) "join works") + (assert-equal test "a/b" &(normalize "a/./b") "normalize drops . components") + (assert-equal test "a/c" &(normalize "a/b/../c") "normalize resolves ..") + (assert-equal test + "/a/b" + &(normalize "//a///b") + "normalize collapses repeated separators") + (assert-equal test + "/" + &(normalize "/a/../..") + "normalize keeps absolute .. from escaping root") + (assert-equal test "/" &(normalize "/") "normalize keeps the root") + (assert-equal test + ".." + &(normalize "a/../..") + "normalize keeps a leading .. in a relative path") + (assert-equal test + "../b" + &(normalize "../a/../b") + "normalize preserves unresolvable leading ..") + (assert-equal test + "." + &(normalize "") + "normalize turns the empty path into .") + (assert-equal test + "." + &(normalize "a/b/../..") + "normalize turns a fully-cancelling relative path into .") + (assert-equal test + "a" + &(normalize "a/") + "normalize strips a trailing separator") (assert-equal test &(Maybe.Just @"file.txt") &(filename "path/to/file.txt") From 6afb0a99a52752dec9bc2e1f61b8ec34e6228265 Mon Sep 17 00:00:00 2001 From: "carpentry-heartbeat[bot]" Date: Sun, 12 Jul 2026 18:17:03 +0200 Subject: [PATCH 2/3] Use cond instead of nested if in normalize (angler lint) --- path.carp | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/path.carp b/path.carp index 761d170..3f8753d 100644 --- a/path.carp +++ b/path.carp @@ -119,16 +119,17 @@ Examples on POSIX: (or (String.empty? c) (= c ".")) () (= c "..") (let [n (Array.length &out)] - (if (= n 0) - (unless abs (set! out (Array.push-back out @c))) - (if (= (Array.unsafe-nth &out (dec n)) "..") + (cond + (= n 0) (unless abs (set! out (Array.push-back out @c))) + (= (Array.unsafe-nth &out (dec n)) "..") (set! out (Array.push-back out @c)) - (set! out (Array.prefix &out (dec n)))))) + (set! out (Array.prefix &out (dec n))))) (set! out (Array.push-back out @c))))) (let [joined (String.join sep-string &out)] - (if abs - (String.concat &[@sep-string joined]) - (if (String.empty? &joined) @"." joined)))))) + (cond + abs (String.concat &[@sep-string joined]) + (String.empty? &joined) @"." + joined))))) (doc split-extension "splits the path `p` on its extension. From 0a6be224aaf7f564b37c1ae103226f97dcbacf31 Mon Sep 17 00:00:00 2001 From: "carpentry-heartbeat[bot]" Date: Sun, 12 Jul 2026 23:16:52 +0200 Subject: [PATCH 3/3] Preserve the root prefix in normalize instead of a bare separator normalize rebuilt an absolute path as sep-string ++ joined, assuming the root is a single leading separator. True on POSIX, but on Windows C:\a\b splits to ["C:" "a" "b"], so the drive is a normal component: prepending a separator produced \C:\a\b and .. could pop the drive off. Treat the first component of an absolute path as the root marker ("" on POSIX, the drive on Windows): skip it in the component loop and re-attach it verbatim before the joined tail. POSIX output is byte-identical (47/47 unchanged); Windows absolute paths keep their drive and .. can no longer escape the root. --- path.carp | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/path.carp b/path.carp index 3f8753d..6c87314 100644 --- a/path.carp +++ b/path.carp @@ -96,7 +96,8 @@ It collapses repeated separators, drops `.` components, and resolves each `..` against the preceding component. Leading `..` components are kept in relative paths (they cannot be resolved without a base), and `..` never escapes the root of an absolute path. An empty or fully-cancelling relative path normalizes to -`.`, and the root normalizes to a single separator. +`.`, and a fully-cancelling absolute path normalizes to its root (a lone +separator on POSIX, the drive root such as `C:\\` on Windows). Examples on POSIX: ``` @@ -113,7 +114,10 @@ Examples on POSIX: (let-do [abs (absolute? p) comps (split p) out []] - (for [i 0 (Array.length &comps)] + ; on an absolute path the first component is the root marker — "" on + ; POSIX, the drive (e.g. "C:") on Windows — so skip it here and re-attach + ; it verbatim below: that preserves the drive and stops `..` escaping root + (for [i (if abs 1 0) (Array.length &comps)] (let [c (Array.unsafe-nth &comps i)] (cond (or (String.empty? c) (= c ".")) () @@ -127,7 +131,8 @@ Examples on POSIX: (set! out (Array.push-back out @c))))) (let [joined (String.join sep-string &out)] (cond - abs (String.concat &[@sep-string joined]) + abs + (String.concat &[@(Array.unsafe-nth &comps 0) @sep-string joined]) (String.empty? &joined) @"." joined)))))