fspath

String functions over POSIX paths: normalize, resolve, join and split them without touching the filesystem.

use fspath/fspath

let p = fspath.normalize("a/./b/../c")             # "a/c"
let abs = fspath.absolute("src/main.dawn", "/srv")  # "/srv/src/main.dawn"
let dir = fspath.parent(abs)                       # Some("/srv/src")
let ext = fspath.extension("main.dawn")            # Some("dawn")

The separator is /. Nothing here reads the filesystem or the working directory: absolute takes its base as an argument.

FunctionWhat it answers
is_absolute(p)whether p starts at the root
normalize(p)p without empty segments, . segments and name/.. pairs; a .. above the root is dropped, a leading .. on a relative path is kept
absolute(p, base)p resolved against base when relative, then normalized
parent(p)everything above p, or None for a bare name or the root
join(base, p)p under base; an absolute p ignores base
basename(p)the last segment, "" for the root and the empty path
extension(p)the extension without its dot, or None; .gitignore has none
with_extension(p, ext)p with its extension replaced; an empty ext removes it

Changes between versions: CHANGELOG.md.

API

fspath/fspath

fspath.is_absolute

fn is_absolute(p: String) -> Bool

Whether p starts at the root.

fspath.normalize

fn normalize(p: String) -> String

p with empty segments, . segments and name/.. pairs removed.

A .. that would climb past the root is dropped (/../a is /a), because the kernel resolves the root's parent to the root. A leading .. on a relative path is kept -- there it still means something. Both match java.nio.file.Path.normalize, which scripts/path-contract checks; the root-climbing case was written down here as a divergence first and measured as agreement second.

fspath.absolute

fn absolute(p: String, base: String) -> String

p resolved against base when it is relative, then normalized. base is the working directory at the call site -- passed in rather than read here, because reading it is an effect and normalizing is not.

Subject first, like every other function in this module. It used to read absolute(base, p), which put the one path it does not always look at in front of the one it always does, and was the module's only disagreement with itself (audit RD-09).

fspath.parent

fn parent(p: String) -> Option[String]

Everything above p, or None when p is a bare name or the root itself. p is normalized first, so a/b/../c has parent a.

fspath.join

fn join(base: String, p: String) -> String

p resolved against directory base, normalized.

An absolute p ignores base entirely -- the same rule absolute and the shell's cd follow -- and an empty base or p leaves the other one normalized rather than growing a stray separator. This is the function the 41 ++ "/" ++ sites wanted: io.list_dir answers with bare names, and a bare name joined onto a directory is the shape every one of them had.

fspath.basename

fn basename(p: String) -> String

The last segment of p: its name, with every directory above it removed. p is normalized first, so a/b/ and a/b/. both name b.

"" for the root and for the empty path, which are the two paths that have no name. Not Option: every call site here holds a path it just built or just listed, and a branch none of them can take is a branch all of them would have to write.

fspath.extension

fn extension(p: String) -> Option[String]

The extension of basename(p), written without its dot, or None.

A dot only starts an extension when there is a name before it and something after it: .gitignore is a dotfile and not an extension, and x. has none either. Inquiry (spec §4.8, criterion 2) -- "this file has no extension" is a case every caller distinguishes, which is precisely what the two - 5 sites could not.

fspath.with_extension

fn with_extension(p: String, ext: String) -> String

p (normalized) with its extension replaced by ext, itself written without a dot. A path with no extension gains one, and an empty ext removes it -- so with_extension(p, "") is how you drop a suffix without counting its characters.