about summary refs log tree commit diff
diff options
context:
space:
mode:
-rw-r--r--lib/path/default.nix143
-rw-r--r--lib/path/tests/unit.nix49
2 files changed, 192 insertions, 0 deletions
diff --git a/lib/path/default.nix b/lib/path/default.nix
index 59f670dfed0ff..96a9244407bf5 100644
--- a/lib/path/default.nix
+++ b/lib/path/default.nix
@@ -4,10 +4,20 @@ let
 
   inherit (builtins)
     isString
+    split
     match
     ;
 
+  inherit (lib.lists)
+    length
+    head
+    last
+    genList
+    elemAt
+    ;
+
   inherit (lib.strings)
+    concatStringsSep
     substring
     ;
 
@@ -28,6 +38,60 @@ let
       "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. */ {
 
 
@@ -72,4 +136,83 @@ 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);
+
 }
diff --git a/lib/path/tests/unit.nix b/lib/path/tests/unit.nix
index 15f5940a51e0d..eccf3b7b1c33b 100644
--- a/lib/path/tests/unit.nix
+++ b/lib/path/tests/unit.nix
@@ -70,6 +70,55 @@ let
       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"