Skip to content

Commit

Permalink
lib.path.relativeNormalise: init
Browse files Browse the repository at this point in the history
Adds a new function to normalise relative paths. This is a prerequisite
to later functions like `path.relativeJoin` and `path.append`.
  • Loading branch information
infinisil committed Dec 8, 2022
1 parent f2c7458 commit c2584ae
Show file tree
Hide file tree
Showing 2 changed files with 220 additions and 1 deletion.
172 changes: 171 additions & 1 deletion lib/path.nix
Original file line number Diff line number Diff line change
@@ -1,4 +1,174 @@
# Functions for working with paths, see ./path-design.md
{ lib }: /* No rec! Add dependencies on this file just above */ {
{ lib }:
let

inherit (builtins)
isString
split
;

inherit (lib.lists)
length
head
last
genList
elemAt
;

inherit (lib.strings)
concatStringsSep
substring
;

inherit (lib.generators)
toPretty
;

pretty = toPretty { multiline = false; };

# Returns true if the value is a valid relative path string, otherwise throws an error
validRelativeString = value: errorPrefix:
if ! isString value then
throw "${errorPrefix}: Not a string"
else if value == "" then
throw "${errorPrefix}: The string is empty"
else if substring 0 1 value == "/" then
throw "${errorPrefix}: The string is an absolute path because it starts with `/`"
else true;

# Splits and normalises a relative path string into its components.
# Errors for ".." components and doesn't include "." components
splitRelative = path: errorPrefix:
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"
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 []

# And we can use this to generate the result list directly. Doing it this
# way over a combination of `filter`, `init` and `tail` makes it more
# efficient, because we don't allocate any intermediate lists
else genList (index:
let
# 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`
value = elemAt parts ((skipStart + index) * 2);
in

# We don't support ".." components, see ./path-design.md
if value == ".." then
throw "${errorPrefix}: Path string contains contains a `..` component, which is not supported"
# Otherwise just return the part unchanged
else
value
) componentCount;

# joins relative path components together
joinRelative = components:
"./" +
# 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 just above */ {

/* Normalise relative paths.
- Limit repeating `/` to a single one
- Remove redundant `.` components
- Error on empty strings
- Remove trailing `/` and `/.`
- Error on `..` path components
- Add leading `./`
Laws:
- (Idempotency) Normalising multiple times gives the same result:
`relativeNormalise (relativeNormalise p) == relativeNormalise p`
- (Uniqueness) There's only a single normalisation for a path:
`normalise p != normalise q => $(realpath -ms ${p}) != $(realpath -ms ${q})`
- Doesn't change the path according to `realpath -ms`:
`normalise p != normalise q => $(realpath -ms ${p}) != $(realpath -ms ${q})`
Example:
# limits repeating `/` to a single one
relativeNormalise "foo//bar"
=> "./foo/bar"
# removes redundant `.` components
relativeNormalise "foo/./bar"
=> "./foo/bar"
# adds leading `./`
relativeNormalise "foo/bar"
=> "./foo/bar"
# removes trailing `/`
relativeNormalise "foo/bar/"
=> "./foo/bar"
# removes trailing `/.`
relativeNormalise "foo/bar/."
=> "./foo/bar"
# Returns the current directory as `./.`
relativeNormalise "."
=> "./."
# errors on `..` path components
relativeNormalise "foo/../bar"
=> <error>
# errors on empty string
relativeNormalise ""
=> <error>
# errors on absolute path
relativeNormalise "/foo"
=> <error>
Type:
relativeNormalise :: String -> String
*/
relativeNormalise = path:
assert validRelativeString path "lib.path.relativeNormalise: Argument ${pretty path} is not a valid relative path string";
let components = splitRelative path "lib.path.relativeNormalise: Argument ${path} can't be normalised";
in joinRelative components;

}
49 changes: 49 additions & 0 deletions lib/tests/misc.nix
Original file line number Diff line number Diff line change
Expand Up @@ -1398,4 +1398,53 @@ runTests {
expr = (with types; either int (listOf (either bool str))).description;
expected = "signed integer or list of (boolean or string)";
};

testRelativeNormaliseExample1 = {
expr = path.relativeNormalise "foo//bar";
expected = "./foo/bar";
};
testRelativeNormaliseExample2 = {
expr = path.relativeNormalise "foo/./bar";
expected = "./foo/bar";
};
testRelativeNormaliseExample3 = {
expr = path.relativeNormalise "foo/bar";
expected = "./foo/bar";
};
testRelativeNormaliseExample4 = {
expr = path.relativeNormalise "foo/bar/";
expected = "./foo/bar";
};
testRelativeNormaliseExample5 = {
expr = path.relativeNormalise "foo/bar/.";
expected = "./foo/bar";
};
testRelativeNormaliseExample6 = {
expr = path.relativeNormalise ".";
expected = "./.";
};
testRelativeNormaliseExample7 = {
expr = (builtins.tryEval (path.relativeNormalise "foo/../bar")).success;
expected = false;
};
testRelativeNormaliseExample8 = {
expr = (builtins.tryEval (path.relativeNormalise "")).success;
expected = false;
};
testRelativeNormaliseExample9 = {
expr = (builtins.tryEval (path.relativeNormalise "/foo")).success;
expected = false;
};
testRelativeNormaliseValidDots = {
expr = path.relativeNormalise "./foo/.bar/.../baz...qux";
expected = "./foo/.bar/.../baz...qux";
};
testRelativeNormaliseWrongType = {
expr = (builtins.tryEval (path.relativeNormalise null)).success;
expected = false;
};
testRelativeNormaliseTwoDots = {
expr = (builtins.tryEval (path.relativeNormalise "..")).success;
expected = false;
};
}

0 comments on commit c2584ae

Please sign in to comment.