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..6c87314 100644 --- a/path.carp +++ b/path.carp @@ -89,6 +89,53 @@ 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 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: +``` +(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 []] + ; 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 ".")) () + (= c "..") + (let [n (Array.length &out)] + (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.push-back out @c))))) + (let [joined (String.join sep-string &out)] + (cond + abs + (String.concat &[@(Array.unsafe-nth &comps 0) @sep-string joined]) + (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")