From 6e4768cdc60551d188238280446dcc24e59381a3 Mon Sep 17 00:00:00 2001 From: Silvan Mosberger Date: Fri, 6 Jan 2023 21:37:02 +0100 Subject: [PATCH 1/9] lib.path.difference: init --- lib/path/default.nix | 87 +++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 86 insertions(+), 1 deletion(-) diff --git a/lib/path/default.nix b/lib/path/default.nix index a4a08668ae62e..bc439ccaddbd3 100644 --- a/lib/path/default.nix +++ b/lib/path/default.nix @@ -5,6 +5,9 @@ let inherit (builtins) isString isPath + isAttrs + typeOf + seq split match ; @@ -18,6 +21,8 @@ let all concatMap foldl' + take + drop ; inherit (lib.strings) @@ -33,10 +38,16 @@ let isValid ; + inherit (lib.attrsets) + mapAttrsToList + listToAttrs + nameValuePair + ; + # 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" + "The given value is of type ${typeOf value}, but a string was expected" else if value == "" then "The given string is empty" else if substring 0 1 value == "/" then @@ -100,6 +111,19 @@ let # 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); + + # Takes a Nix path value and deconstructs it into the filesystem root + # (generally `/`) and a subpath + deconstructPath = + let + go = 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 go ([ (baseNameOf path) ] ++ components) (dirOf path); + in go []; + + + in /* No rec! Add dependencies on this file at the top. */ { /* Append a subpath string to a path. @@ -149,6 +173,67 @@ in /* No rec! Add dependencies on this file at the top. */ { ${subpathInvalidReason subpath}''; path + ("/" + subpath); + difference = paths: + let + # Deconstruct every path into its root + subpath + deconstructed = mapAttrsToList (name: value: + # Check each item to be an actual path + assert assertMsg (isPath value) "lib.path.difference: Given attribute ${name} is of type ${typeOf value}, but a path value was expected"; + deconstructPath value // { inherit name value; } + ) paths; + + first = head deconstructed; + + # The common root to all paths, errors if there are different roots + commonRoot = + # Fast happy path in case all roots are the same + if all (dec: dec.root == first.root) deconstructed then first.root + # Slower sad path when that's not the case and we need to throw an error + else let + trigger = foldl' + (skip: dec: + if dec.root == first.root then skip + else throw "lib.path.difference: Path ${first.name} = ${toString first.value} (root ${toString first.root}) has a different filesystem root than path ${toString dec.name} = ${toString dec.value} (root ${toString dec.root})") + null + deconstructed; + fallbackAbort = + abort "lib.path.difference: This should never happen and indicates a bug! Some paths don't have the same filesystem root but it couldn't be found!"; + in seq trigger fallbackAbort; + + goCommonAncestorLength = level: + let headComponent = elemAt first.components level; in + if all (dec: + # If all paths have another level of components + length dec.components > level + # And they all match + && elemAt dec.components level == headComponent + ) deconstructed + then goCommonAncestorLength (level + 1) + else level; + + commonAncestorLength = + # Ensure that we have a common root before trying to find a common ancestor + # If we didn't do this one could evaluate `relativePaths` without an error even when there's no common root + seq commonRoot + (goCommonAncestorLength 0); + + commonAncestor = commonRoot + + ("/" + joinRelPath (take commonAncestorLength first.components)); + + subpaths = listToAttrs (map (dec: + nameValuePair dec.name (joinRelPath (drop commonAncestorLength dec.components)) + ) deconstructed); + in + assert assertMsg + (isAttrs paths) + "lib.path.difference: The given argument is of type ${typeOf paths}, but an attribute set was expected"; + assert assertMsg + (paths != {}) + "lib.path.difference: An empty attribute set was given, but was expecting a non-empty attribute set"; + { + inherit commonAncestor subpaths; + }; + /* Whether a value is a valid subpath string. - The value is a string From 5e5b9d8123e1a2ec5b52418ad3f08dbd5d037713 Mon Sep 17 00:00:00 2001 From: Silvan Mosberger Date: Mon, 9 Jan 2023 22:52:48 +0100 Subject: [PATCH 2/9] lib.path.difference: Only trigger non-empty assert for commonAncestor --- lib/path/default.nix | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/lib/path/default.nix b/lib/path/default.nix index bc439ccaddbd3..45f4f72f4e40f 100644 --- a/lib/path/default.nix +++ b/lib/path/default.nix @@ -182,7 +182,11 @@ in /* No rec! Add dependencies on this file at the top. */ { deconstructPath value // { inherit name value; } ) paths; - first = head deconstructed; + first = + assert assertMsg + (paths != {}) + "lib.path.difference: An empty attribute set was given, but was expecting a non-empty attribute set"; + head deconstructed; # The common root to all paths, errors if there are different roots commonRoot = @@ -227,9 +231,6 @@ in /* No rec! Add dependencies on this file at the top. */ { assert assertMsg (isAttrs paths) "lib.path.difference: The given argument is of type ${typeOf paths}, but an attribute set was expected"; - assert assertMsg - (paths != {}) - "lib.path.difference: An empty attribute set was given, but was expecting a non-empty attribute set"; { inherit commonAncestor subpaths; }; From 47ace14255f6edc3ea6f24871b33bbe196e57788 Mon Sep 17 00:00:00 2001 From: Silvan Mosberger Date: Tue, 17 Jan 2023 20:49:49 +0100 Subject: [PATCH 3/9] Address some review --- lib/path/default.nix | 64 ++++++++++++++++++++++---------------------- 1 file changed, 32 insertions(+), 32 deletions(-) diff --git a/lib/path/default.nix b/lib/path/default.nix index 45f4f72f4e40f..ca6848dfd1bf7 100644 --- a/lib/path/default.nix +++ b/lib/path/default.nix @@ -6,6 +6,8 @@ let isString isPath isAttrs + dirOf + baseNameOf typeOf seq split @@ -122,8 +124,6 @@ let else go ([ (baseNameOf path) ] ++ components) (dirOf path); in go []; - - in /* No rec! Add dependencies on this file at the top. */ { /* Append a subpath string to a path. @@ -175,58 +175,58 @@ in /* No rec! Add dependencies on this file at the top. */ { difference = paths: let - # Deconstruct every path into its root + subpath - deconstructed = mapAttrsToList (name: value: + # Deconstruct every path into its root and subpath + deconstructedPaths = mapAttrsToList (name: value: # Check each item to be an actual path - assert assertMsg (isPath value) "lib.path.difference: Given attribute ${name} is of type ${typeOf value}, but a path value was expected"; + assert assertMsg (isPath value) "lib.path.difference: Attribute ${name} is of type ${typeOf value}, but a path was expected"; deconstructPath value // { inherit name value; } ) paths; - first = + firstPath = assert assertMsg (paths != {}) - "lib.path.difference: An empty attribute set was given, but was expecting a non-empty attribute set"; - head deconstructed; + "lib.path.difference: An empty attribute set was given, but a non-empty attribute set was expected"; + head deconstructedPaths; # The common root to all paths, errors if there are different roots commonRoot = # Fast happy path in case all roots are the same - if all (dec: dec.root == first.root) deconstructed then first.root + if all (path: path.root == firstPath.root) deconstructedPaths then firstPath.root # Slower sad path when that's not the case and we need to throw an error else let trigger = foldl' - (skip: dec: - if dec.root == first.root then skip - else throw "lib.path.difference: Path ${first.name} = ${toString first.value} (root ${toString first.root}) has a different filesystem root than path ${toString dec.name} = ${toString dec.value} (root ${toString dec.root})") + (skip: path: + if path.root == firstPath.root then skip + else throw "lib.path.difference: Path ${firstPath.name} = ${toString firstPath.value} (root ${toString firstPath.root}) has a different filesystem root than path ${toString path.name} = ${toString path.value} (root ${toString path.root})") null - deconstructed; + deconstructedPaths; fallbackAbort = abort "lib.path.difference: This should never happen and indicates a bug! Some paths don't have the same filesystem root but it couldn't be found!"; in seq trigger fallbackAbort; - goCommonAncestorLength = level: - let headComponent = elemAt first.components level; in - if all (dec: - # If all paths have another level of components - length dec.components > level - # And they all match - && elemAt dec.components level == headComponent - ) deconstructed - then goCommonAncestorLength (level + 1) - else level; - commonAncestorLength = - # Ensure that we have a common root before trying to find a common ancestor - # If we didn't do this one could evaluate `relativePaths` without an error even when there's no common root - seq commonRoot - (goCommonAncestorLength 0); + let + go = index: + let firstComponent = elemAt firstPath.components index; in + if all (path: + # If all paths have another level of components + length path.components > index + # And they all match + && elemAt path.components index == firstComponent + ) deconstructedPaths + then go (index + 1) + else index; + in + # Ensure that we have a common root before trying to find a common ancestor + # If we didn't do this one could evaluate `relativePaths` without an error even when there's no common root + seq commonRoot (go 0); commonAncestor = commonRoot - + ("/" + joinRelPath (take commonAncestorLength first.components)); + + ("/" + joinRelPath (take commonAncestorLength firstPath.components)); - subpaths = listToAttrs (map (dec: - nameValuePair dec.name (joinRelPath (drop commonAncestorLength dec.components)) - ) deconstructed); + subpaths = listToAttrs (map (path: + nameValuePair path.name (joinRelPath (drop commonAncestorLength path.components)) + ) deconstructedPaths); in assert assertMsg (isAttrs paths) From 2debd69ab0722553517d345222668a5001a1543e Mon Sep 17 00:00:00 2001 From: Silvan Mosberger Date: Wed, 18 Jan 2023 16:34:25 +0100 Subject: [PATCH 4/9] go -> recurse --- lib/path/default.nix | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/lib/path/default.nix b/lib/path/default.nix index ca6848dfd1bf7..a0ae6a1a6bf35 100644 --- a/lib/path/default.nix +++ b/lib/path/default.nix @@ -118,11 +118,11 @@ let # (generally `/`) and a subpath deconstructPath = let - go = components: path: + 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 go ([ (baseNameOf path) ] ++ components) (dirOf path); - in go []; + else recurse ([ (baseNameOf path) ] ++ components) (dirOf path); + in recurse []; in /* No rec! Add dependencies on this file at the top. */ { @@ -206,7 +206,7 @@ in /* No rec! Add dependencies on this file at the top. */ { commonAncestorLength = let - go = index: + recurse = index: let firstComponent = elemAt firstPath.components index; in if all (path: # If all paths have another level of components @@ -214,12 +214,12 @@ in /* No rec! Add dependencies on this file at the top. */ { # And they all match && elemAt path.components index == firstComponent ) deconstructedPaths - then go (index + 1) + then recurse (index + 1) else index; in # Ensure that we have a common root before trying to find a common ancestor # If we didn't do this one could evaluate `relativePaths` without an error even when there's no common root - seq commonRoot (go 0); + seq commonRoot (recurse 0); commonAncestor = commonRoot + ("/" + joinRelPath (take commonAncestorLength firstPath.components)); From 8f094bc390ec44b32d2bff750a168985a5024921 Mon Sep 17 00:00:00 2001 From: Silvan Mosberger Date: Wed, 18 Jan 2023 16:37:24 +0100 Subject: [PATCH 5/9] Use newlines for multiple roots error --- lib/path/default.nix | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/lib/path/default.nix b/lib/path/default.nix index a0ae6a1a6bf35..1b8c51001d24f 100644 --- a/lib/path/default.nix +++ b/lib/path/default.nix @@ -197,7 +197,11 @@ in /* No rec! Add dependencies on this file at the top. */ { trigger = foldl' (skip: path: if path.root == firstPath.root then skip - else throw "lib.path.difference: Path ${firstPath.name} = ${toString firstPath.value} (root ${toString firstPath.root}) has a different filesystem root than path ${toString path.name} = ${toString path.value} (root ${toString path.root})") + else throw '' + lib.path.difference: Filesystem roots must be the same for all paths, but paths with different roots were given: + ${firstPath.name} = ${toString firstPath.value} (root ${toString firstPath.root}) + ${toString path.name} = ${toString path.value} (root ${toString path.root})'' + ) null deconstructedPaths; fallbackAbort = From d469d685646667870921b8a2419f4eaf715bd91b Mon Sep 17 00:00:00 2001 From: Silvan Mosberger Date: Tue, 14 Mar 2023 21:11:43 +0100 Subject: [PATCH 6/9] Simplify code a bit and use prefix/suffix naming --- lib/path/default.nix | 30 +++++++++++++----------------- 1 file changed, 13 insertions(+), 17 deletions(-) diff --git a/lib/path/default.nix b/lib/path/default.nix index 1b8c51001d24f..ce5453375ba77 100644 --- a/lib/path/default.nix +++ b/lib/path/default.nix @@ -193,20 +193,16 @@ in /* No rec! Add dependencies on this file at the top. */ { # Fast happy path in case all roots are the same if all (path: path.root == firstPath.root) deconstructedPaths then firstPath.root # Slower sad path when that's not the case and we need to throw an error - else let - trigger = foldl' - (skip: path: - if path.root == firstPath.root then skip - else throw '' - lib.path.difference: Filesystem roots must be the same for all paths, but paths with different roots were given: - ${firstPath.name} = ${toString firstPath.value} (root ${toString firstPath.root}) - ${toString path.name} = ${toString path.value} (root ${toString path.root})'' - ) - null - deconstructedPaths; - fallbackAbort = - abort "lib.path.difference: This should never happen and indicates a bug! Some paths don't have the same filesystem root but it couldn't be found!"; - in seq trigger fallbackAbort; + else foldl' + (skip: path: + if path.root == firstPath.root then skip + else throw '' + lib.path.difference: Filesystem roots must be the same for all paths, but paths with different roots were given: + ${firstPath.name} = ${toString firstPath.value} (root ${toString firstPath.root}) + ${toString path.name} = ${toString path.value} (root ${toString path.root})'' + ) + null + deconstructedPaths; commonAncestorLength = let @@ -225,10 +221,10 @@ in /* No rec! Add dependencies on this file at the top. */ { # If we didn't do this one could evaluate `relativePaths` without an error even when there's no common root seq commonRoot (recurse 0); - commonAncestor = commonRoot + commonPrefix = commonRoot + ("/" + joinRelPath (take commonAncestorLength firstPath.components)); - subpaths = listToAttrs (map (path: + suffix = listToAttrs (map (path: nameValuePair path.name (joinRelPath (drop commonAncestorLength path.components)) ) deconstructedPaths); in @@ -236,7 +232,7 @@ in /* No rec! Add dependencies on this file at the top. */ { (isAttrs paths) "lib.path.difference: The given argument is of type ${typeOf paths}, but an attribute set was expected"; { - inherit commonAncestor subpaths; + inherit commonPrefix suffix; }; /* Whether a value is a valid subpath string. From 6fa657889ffef81aca0d323d00ad5c895d610d19 Mon Sep 17 00:00:00 2001 From: Silvan Mosberger Date: Tue, 14 Mar 2023 23:06:14 +0100 Subject: [PATCH 7/9] Write documentation --- lib/path/default.nix | 55 ++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 53 insertions(+), 2 deletions(-) diff --git a/lib/path/default.nix b/lib/path/default.nix index ce5453375ba77..fff12facbff77 100644 --- a/lib/path/default.nix +++ b/lib/path/default.nix @@ -113,7 +113,6 @@ let # 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); - # Takes a Nix path value and deconstructs it into the filesystem root # (generally `/`) and a subpath deconstructPath = @@ -173,7 +172,59 @@ in /* No rec! Add dependencies on this file at the top. */ { ${subpathInvalidReason subpath}''; path + ("/" + subpath); - difference = paths: + /* Determine the difference between multiple paths, including the longest + common prefix and the individual suffixes between them + + The input is an attribute set of paths, where the keys are only for naming + and can be picked arbitrarily. The returned attribute set contains two + attributes: + + - `commonPrefix`: A path value containing the common prefix between all the + given paths. + + - `suffix`: An attribute set of normalised subpaths (see + `lib.path.subpath.normalise`). The keys are the same + as were given as the input, they can be used to easily match up the + suffixes to the inputs. + + Throws an error if all paths don't share the same filesystem root. + + Laws: + + - The input paths can be recovered by appending each suffix to the common ancestor + + forall paths, result = difference paths. + mapAttrs (_: append result.commonPrefix) result.suffix == paths + + - The _longest_ common prefix is returned + + forall paths, result = difference paths. + ! exists longerPrefix. hasProperPrefix result.commonPrefix longerPrefix && all (hasPrefix longerPrefix) (attrValues paths) + + - Suffixes are normalised + + forall paths, result = difference paths. + mapAttrs (_: subpath.normalise) result.suffix == result.suffix + + Type: + difference :: AttrsOf Path -> { commonPrefix :: Path, suffix :: AttrsOf String } + + Example: + difference { foo = ./foo; bar = ./bar; } + => { commonPrefix = ./.; suffix = { foo = "./foo"; bar = "./bar"; }; } + + difference { foo = ./foo; bar = ./.; } + => { commonPrefix = ./.; suffix = { foo = "./foo"; bar = "./."; }; } + + difference { foo = ./foo; bar = ./foo; } + => { commonPrefix = ./foo; suffix = { foo = "./."; bar = "./."; }; } + + difference { foo = ./foo; bar = ./foo/bar; } + => { commonPrefix = ./foo; suffix = { foo = "./."; bar = "./bar"; }; } + */ + difference = + # The attribute set of paths to calculate the difference between + paths: let # Deconstruct every path into its root and subpath deconstructedPaths = mapAttrsToList (name: value: From 44032d8e57dbaa6d07daa30ca7580e84dbd1000a Mon Sep 17 00:00:00 2001 From: Silvan Mosberger Date: Tue, 14 Mar 2023 23:11:49 +0100 Subject: [PATCH 8/9] Add tests --- lib/path/tests/unit.nix | 34 +++++++++++++++++++++++++++++++++- 1 file changed, 33 insertions(+), 1 deletion(-) diff --git a/lib/path/tests/unit.nix b/lib/path/tests/unit.nix index 61c4ab4d6f2ee..3a00767aa4153 100644 --- a/lib/path/tests/unit.nix +++ b/lib/path/tests/unit.nix @@ -3,7 +3,7 @@ { libpath }: let lib = import libpath; - inherit (lib.path) append subpath; + inherit (lib.path) append difference subpath; cases = lib.runTests { # Test examples from the lib.path.append documentation @@ -40,6 +40,38 @@ let expected = false; }; + # Test examples from the documentation + testDifferenceExample1 = { + expr = difference { foo = ./foo; bar = ./bar; }; + expected = { commonPrefix = ./.; suffix = { foo = "./foo"; bar = "./bar"; }; }; + }; + testDifferenceExample2 = { + expr = difference { foo = ./foo; bar = ./.; }; + expected = { commonPrefix = ./.; suffix = { foo = "./foo"; bar = "./."; }; }; + }; + testDifferenceExample3 = { + expr = difference { foo = ./foo; bar = ./foo; }; + expected = { commonPrefix = ./foo; suffix = { foo = "./."; bar = "./."; }; }; + }; + testDifferenceExample4 = { + expr = difference { foo = ./foo; bar = ./foo/bar; }; + expected = { commonPrefix = ./foo; suffix = { foo = "./."; bar = "./bar"; }; }; + }; + # Test that the invalid cases cause throws + testDifferenceNonAttrset = { + expr = (builtins.tryEval (difference 10).commonPrefix).success; + expected = false; + }; + testDifferenceEmpty = { + expr = (builtins.tryEval (difference { }).commonPrefix).success; + expected = false; + }; + testDifferenceNonPath = { + expr = (builtins.tryEval (difference { a = 10; }).commonPrefix).success; + expected = false; + }; + # We can't test for multiple roots with the current Nix version though + # Test examples from the lib.path.subpath.isValid documentation testSubpathIsValidExample1 = { expr = subpath.isValid null; From 090b1f1cfbedf060c96e690ecd64d8250f78b830 Mon Sep 17 00:00:00 2001 From: Silvan Mosberger Date: Wed, 15 Mar 2023 00:44:05 +0100 Subject: [PATCH 9/9] Address review --- lib/path/default.nix | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/path/default.nix b/lib/path/default.nix index fff12facbff77..ec19f513cfae4 100644 --- a/lib/path/default.nix +++ b/lib/path/default.nix @@ -175,9 +175,9 @@ in /* No rec! Add dependencies on this file at the top. */ { /* Determine the difference between multiple paths, including the longest common prefix and the individual suffixes between them - The input is an attribute set of paths, where the keys are only for naming - and can be picked arbitrarily. The returned attribute set contains two - attributes: + The input is an attribute set of paths, where the keys are only for user + convenience and can be chosen arbitrarily. The returned attribute set + contains two attributes: - `commonPrefix`: A path value containing the common prefix between all the given paths.