lib.path.subpath.normalise: init

Changed files
+192
lib
path
+143
lib/path/default.nix
···
inherit (builtins)
isString
+
split
match
+
;
+
+
inherit (lib.lists)
+
length
+
head
+
last
+
genList
+
elemAt
;
inherit (lib.strings)
+
concatStringsSep
substring
;
···
"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);
+
in /* No rec! Add dependencies on this file at the top. */ {
···
*/
subpath.isValid = value:
subpathInvalidReason value == null;
+
+
+
/* 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 = path:
+
assert assertMsg (subpathInvalidReason path == null)
+
"lib.path.subpath.normalise: Argument is not a valid subpath string: ${subpathInvalidReason path}";
+
joinRelPath (splitRelPath path);
}
+49
lib/path/tests/unit.nix
···
expr = subpath.isValid "foo/..../bar";
expected = true;
};
+
+
testSubpathNormaliseExample1 = {
+
expr = subpath.normalise "foo//bar";
+
expected = "./foo/bar";
+
};
+
testSubpathNormaliseExample2 = {
+
expr = subpath.normalise "foo/./bar";
+
expected = "./foo/bar";
+
};
+
testSubpathNormaliseExample3 = {
+
expr = subpath.normalise "foo/bar";
+
expected = "./foo/bar";
+
};
+
testSubpathNormaliseExample4 = {
+
expr = subpath.normalise "foo/bar/";
+
expected = "./foo/bar";
+
};
+
testSubpathNormaliseExample5 = {
+
expr = subpath.normalise "foo/bar/.";
+
expected = "./foo/bar";
+
};
+
testSubpathNormaliseExample6 = {
+
expr = subpath.normalise ".";
+
expected = "./.";
+
};
+
testSubpathNormaliseExample7 = {
+
expr = (builtins.tryEval (subpath.normalise "foo/../bar")).success;
+
expected = false;
+
};
+
testSubpathNormaliseExample8 = {
+
expr = (builtins.tryEval (subpath.normalise "")).success;
+
expected = false;
+
};
+
testSubpathNormaliseExample9 = {
+
expr = (builtins.tryEval (subpath.normalise "/foo")).success;
+
expected = false;
+
};
+
testSubpathNormaliseIsValidDots = {
+
expr = subpath.normalise "./foo/.bar/.../baz...qux";
+
expected = "./foo/.bar/.../baz...qux";
+
};
+
testSubpathNormaliseWrongType = {
+
expr = (builtins.tryEval (subpath.normalise null)).success;
+
expected = false;
+
};
+
testSubpathNormaliseTwoDots = {
+
expr = (builtins.tryEval (subpath.normalise "..")).success;
+
expected = false;
+
};
};
in
if cases == [] then "Unit tests successful"