aboutsummaryrefslogtreecommitdiff
path: root/path/default.nix
diff options
context:
space:
mode:
Diffstat (limited to 'path/default.nix')
-rw-r--r--path/default.nix433
1 files changed, 433 insertions, 0 deletions
diff --git a/path/default.nix b/path/default.nix
new file mode 100644
index 000000000..ce5d77599
--- /dev/null
+++ b/path/default.nix
@@ -0,0 +1,433 @@
+# Functions for working with paths, see ./path.md
+{ lib }:
+let
+
+ inherit (builtins)
+ isString
+ isPath
+ split
+ match
+ typeOf
+ ;
+
+ inherit (lib.lists)
+ length
+ head
+ last
+ genList
+ elemAt
+ all
+ concatMap
+ foldl'
+ take
+ drop
+ ;
+
+ inherit (lib.strings)
+ concatStringsSep
+ substring
+ ;
+
+ inherit (lib.asserts)
+ assertMsg
+ ;
+
+ inherit (lib.path.subpath)
+ isValid
+ ;
+
+ # Return the reason why a subpath is invalid, or `null` if it's valid
+ subpathInvalidReason = value:
+ if ! isString value then
+ "The given value is of type ${builtins.typeOf value}, but a string was expected"
+ else if value == "" then
+ "The given string is empty"
+ else if substring 0 1 value == "/" then
+ "The given string \"${value}\" starts with a `/`, representing an absolute path"
+ # We don't support ".." components, see ./path.md#parent-directory
+ else if match "(.*/)?\\.\\.(/.*)?" value != null then
+ "The given string \"${value}\" contains a `..` component, which is not allowed in subpaths"
+ else null;
+
+ # Split and normalise a relative path string into its components.
+ # Error for ".." components and doesn't include "." components
+ splitRelPath = path:
+ let
+ # Split the string into its parts using regex for efficiency. This regex
+ # matches patterns like "/", "/./", "/././", with arbitrarily many "/"s
+ # together. These are the main special cases:
+ # - Leading "./" gets split into a leading "." part
+ # - Trailing "/." or "/" get split into a trailing "." or ""
+ # part respectively
+ #
+ # These are the only cases where "." and "" parts can occur
+ parts = split "/+(\\./+)*" path;
+
+ # `split` creates a list of 2 * k + 1 elements, containing the k +
+ # 1 parts, interleaved with k matches where k is the number of
+ # (non-overlapping) matches. This calculation here gets the number of parts
+ # back from the list length
+ # floor( (2 * k + 1) / 2 ) + 1 == floor( k + 1/2 ) + 1 == k + 1
+ partCount = length parts / 2 + 1;
+
+ # To assemble the final list of components we want to:
+ # - Skip a potential leading ".", normalising "./foo" to "foo"
+ # - Skip a potential trailing "." or "", normalising "foo/" and "foo/." to
+ # "foo". See ./path.md#trailing-slashes
+ skipStart = if head parts == "." then 1 else 0;
+ skipEnd = if last parts == "." || last parts == "" then 1 else 0;
+
+ # We can now know the length of the result by removing the number of
+ # skipped parts from the total number
+ componentCount = partCount - skipEnd - skipStart;
+
+ in
+ # Special case of a single "." path component. Such a case leaves a
+ # componentCount of -1 due to the skipStart/skipEnd not verifying that
+ # they don't refer to the same character
+ if path == "." then []
+
+ # Generate the result list directly. This is more efficient than a
+ # combination of `filter`, `init` and `tail`, because here we don't
+ # allocate any intermediate lists
+ else genList (index:
+ # To get to the element we need to add the number of parts we skip and
+ # multiply by two due to the interleaved layout of `parts`
+ elemAt parts ((skipStart + index) * 2)
+ ) componentCount;
+
+ # Join relative path components together
+ joinRelPath = components:
+ # Always return relative paths with `./` as a prefix (./path.md#leading-dots-for-relative-paths)
+ "./" +
+ # An empty string is not a valid relative path, so we need to return a `.` when we have no components
+ (if components == [] then "." else concatStringsSep "/" components);
+
+ # Deconstruct a path value type into:
+ # - root: The filesystem root of the path, generally `/`
+ # - components: All the path's components
+ #
+ # This is similar to `splitString "/" (toString path)` but safer
+ # because it can distinguish different filesystem roots
+ deconstructPath =
+ let
+ recurse = components: path:
+ # If the parent of a path is the path itself, then it's a filesystem root
+ if path == dirOf path then { root = path; inherit components; }
+ else recurse ([ (baseNameOf path) ] ++ components) (dirOf path);
+ in recurse [];
+
+ # Used as an abstraction between `hasPrefix`, `hasProperPrefix` and `removePrefix`
+ #
+ # Takes four arguments:
+ # - context: A string describing the callee, for error messages
+ # - continue: A function with four arguments
+ # - path1: A path
+ # - path2: Another path
+ #
+ # The function checks whether `path1` is a path and computes its
+ # decomposition (`deconPath1`) before taking `path2` as an argument, which
+ # allows the computation to be cached in a thunk between multiple calls.
+ # With `path2` also provided, it checks whether it's also a path, computes
+ # the decomposition (`deconPath2`), checks whether both paths have the same
+ # filesystem root, and then calls `continue path1 deconPath1 path2 deconPath2`,
+ # allowing the caller to decide what to do with these values.
+ withTwoDeconstructedPaths = context: continue:
+ path1:
+ assert assertMsg
+ (isPath path1)
+ "${context}: First argument is of type ${typeOf path1}, but a path was expected";
+ let
+ deconPath1 = deconstructPath path1;
+ in
+ path2:
+ assert assertMsg
+ (isPath path2)
+ "${context}: Second argument is of type ${typeOf path2}, but a path was expected";
+ let
+ deconPath2 = deconstructPath path2;
+ in
+ assert assertMsg
+ (deconPath1.root == deconPath2.root) ''
+ ${context}: Filesystem roots must be the same for both paths, but paths with different roots were given:
+ first argument: "${toString path1}" (root "${toString deconPath1.root}")
+ second argument: "${toString path2}" (root "${toString deconPath2.root}")'';
+ continue path1 deconPath1 path2 deconPath2;
+
+in /* No rec! Add dependencies on this file at the top. */ {
+
+ /* Append a subpath string to a path.
+
+ Like `path + ("/" + string)` but safer, because it errors instead of returning potentially surprising results.
+ More specifically, it checks that the first argument is a [path value type](https://nixos.org/manual/nix/stable/language/values.html#type-path"),
+ and that the second argument is a valid subpath string (see `lib.path.subpath.isValid`).
+
+ Type:
+ append :: Path -> String -> Path
+
+ Example:
+ append /foo "bar/baz"
+ => /foo/bar/baz
+
+ # subpaths don't need to be normalised
+ append /foo "./bar//baz/./"
+ => /foo/bar/baz
+
+ # can append to root directory
+ append /. "foo/bar"
+ => /foo/bar
+
+ # first argument needs to be a path value type
+ append "/foo" "bar"
+ => <error>
+
+ # second argument needs to be a valid subpath string
+ append /foo /bar
+ => <error>
+ append /foo ""
+ => <error>
+ append /foo "/bar"
+ => <error>
+ append /foo "../bar"
+ => <error>
+ */
+ append =
+ # The absolute path to append to
+ path:
+ # The subpath string to append
+ subpath:
+ assert assertMsg (isPath path) ''
+ lib.path.append: The first argument is of type ${builtins.typeOf path}, but a path was expected'';
+ assert assertMsg (isValid subpath) ''
+ lib.path.append: Second argument is not a valid subpath string:
+ ${subpathInvalidReason subpath}'';
+ path + ("/" + subpath);
+
+ hasPrefix = withTwoDeconstructedPaths "lib.path.hasPrefix" (prefix: deconPrefix: path: deconPath:
+ take (length deconPrefix.components) deconPath.components == deconPrefix.components
+ );
+
+ removePrefix = withTwoDeconstructedPaths "lib.path.removePrefix" (prefix: deconPrefix: path: deconPath:
+ if take (length deconPrefix.components) deconPath.components == deconPrefix.components
+ then drop (length deconPrefix.components) deconPath.components
+ else throw ''
+ lib.path.removePrefix: The first prefix path argument (${toString prefix}) is not a prefix of the second path argument (${toString path})''
+ );
+
+ /* Whether a value is a valid subpath string.
+
+ - The value is a string
+
+ - The string is not empty
+
+ - The string doesn't start with a `/`
+
+ - The string doesn't contain any `..` path components
+
+ Type:
+ subpath.isValid :: String -> Bool
+
+ Example:
+ # Not a string
+ subpath.isValid null
+ => false
+
+ # Empty string
+ subpath.isValid ""
+ => false
+
+ # Absolute path
+ subpath.isValid "/foo"
+ => false
+
+ # Contains a `..` path component
+ subpath.isValid "../foo"
+ => false
+
+ # Valid subpath
+ subpath.isValid "foo/bar"
+ => true
+
+ # Doesn't need to be normalised
+ subpath.isValid "./foo//bar/"
+ => true
+ */
+ subpath.isValid =
+ # The value to check
+ value:
+ subpathInvalidReason value == null;
+
+
+ /* Join subpath strings together using `/`, returning a normalised subpath string.
+
+ Like `concatStringsSep "/"` but safer, specifically:
+
+ - All elements must be valid subpath strings, see `lib.path.subpath.isValid`
+
+ - The result gets normalised, see `lib.path.subpath.normalise`
+
+ - The edge case of an empty list gets properly handled by returning the neutral subpath `"./."`
+
+ Laws:
+
+ - Associativity:
+
+ subpath.join [ x (subpath.join [ y z ]) ] == subpath.join [ (subpath.join [ x y ]) z ]
+
+ - Identity - `"./."` is the neutral element for normalised paths:
+
+ subpath.join [ ] == "./."
+ subpath.join [ (subpath.normalise p) "./." ] == subpath.normalise p
+ subpath.join [ "./." (subpath.normalise p) ] == subpath.normalise p
+
+ - Normalisation - the result is normalised according to `lib.path.subpath.normalise`:
+
+ subpath.join ps == subpath.normalise (subpath.join ps)
+
+ - For non-empty lists, the implementation is equivalent to normalising the result of `concatStringsSep "/"`.
+ Note that the above laws can be derived from this one.
+
+ ps != [] -> subpath.join ps == subpath.normalise (concatStringsSep "/" ps)
+
+ Type:
+ subpath.join :: [ String ] -> String
+
+ Example:
+ subpath.join [ "foo" "bar/baz" ]
+ => "./foo/bar/baz"
+
+ # normalise the result
+ subpath.join [ "./foo" "." "bar//./baz/" ]
+ => "./foo/bar/baz"
+
+ # passing an empty list results in the current directory
+ subpath.join [ ]
+ => "./."
+
+ # elements must be valid subpath strings
+ subpath.join [ /foo ]
+ => <error>
+ subpath.join [ "" ]
+ => <error>
+ subpath.join [ "/foo" ]
+ => <error>
+ subpath.join [ "../foo" ]
+ => <error>
+ */
+ subpath.join =
+ # The list of subpaths to join together
+ subpaths:
+ # Fast in case all paths are valid
+ if all isValid subpaths
+ then joinRelPath (concatMap splitRelPath subpaths)
+ else
+ # Otherwise we take our time to gather more info for a better error message
+ # Strictly go through each path, throwing on the first invalid one
+ # Tracks the list index in the fold accumulator
+ foldl' (i: path:
+ if isValid path
+ then i + 1
+ else throw ''
+ lib.path.subpath.join: Element at index ${toString i} is not a valid subpath string:
+ ${subpathInvalidReason path}''
+ ) 0 subpaths;
+
+ /* Normalise a subpath. Throw an error if the subpath isn't valid, see
+ `lib.path.subpath.isValid`
+
+ - Limit repeating `/` to a single one
+
+ - Remove redundant `.` components
+
+ - Remove trailing `/` and `/.`
+
+ - Add leading `./`
+
+ Laws:
+
+ - Idempotency - normalising multiple times gives the same result:
+
+ subpath.normalise (subpath.normalise p) == subpath.normalise p
+
+ - Uniqueness - there's only a single normalisation for the paths that lead to the same file system node:
+
+ subpath.normalise p != subpath.normalise q -> $(realpath ${p}) != $(realpath ${q})
+
+ - Don't change the result when appended to a Nix path value:
+
+ base + ("/" + p) == base + ("/" + subpath.normalise p)
+
+ - Don't change the path according to `realpath`:
+
+ $(realpath ${p}) == $(realpath ${subpath.normalise p})
+
+ - Only error on invalid subpaths:
+
+ builtins.tryEval (subpath.normalise p)).success == subpath.isValid p
+
+ Type:
+ subpath.normalise :: String -> String
+
+ Example:
+ # limit repeating `/` to a single one
+ subpath.normalise "foo//bar"
+ => "./foo/bar"
+
+ # remove redundant `.` components
+ subpath.normalise "foo/./bar"
+ => "./foo/bar"
+
+ # add leading `./`
+ subpath.normalise "foo/bar"
+ => "./foo/bar"
+
+ # remove trailing `/`
+ subpath.normalise "foo/bar/"
+ => "./foo/bar"
+
+ # remove trailing `/.`
+ subpath.normalise "foo/bar/."
+ => "./foo/bar"
+
+ # Return the current directory as `./.`
+ subpath.normalise "."
+ => "./."
+
+ # error on `..` path components
+ subpath.normalise "foo/../bar"
+ => <error>
+
+ # error on empty string
+ subpath.normalise ""
+ => <error>
+
+ # error on absolute path
+ subpath.normalise "/foo"
+ => <error>
+ */
+ subpath.normalise =
+ # The subpath string to normalise
+ subpath:
+ assert assertMsg (isValid subpath) ''
+ lib.path.subpath.normalise: Argument is not a valid subpath string:
+ ${subpathInvalidReason subpath}'';
+ joinRelPath (splitRelPath subpath);
+
+ commonAncestor = a: b:
+ let
+ a' = deconstructPath a;
+ b' = deconstructPath b;
+ in
+ if a'.root != b'.root then
+ throw "lib.path.commonAncestor: Given paths don't have the same filesystem root"
+ else
+ a'.root + ("/" + concatStringsSep "/" (lib.lists.commonPrefix a'.components b'.components));
+
+ deconstruct = path: deconstructPath path;
+ construct = { root, components }: root + ("/" + concatStringsSep "/" components);
+
+ components = {
+ toSubpath = joinRelPath;
+ fromSubpath = splitRelPath;
+ };
+}