From 457f876c92070c1bcfa88f977238ea8b61c9ce55 Mon Sep 17 00:00:00 2001 From: Alex Ionescu Date: Sun, 22 Jun 2025 01:27:42 +0200 Subject: [PATCH 1/2] Allow repeated applications of `filtered`, `matching`, and `mapWith` --- README.md | 80 ++++++++++++++++++++++++++++++++++++++++----------- checkmate.nix | 10 +++++++ default.nix | 65 ++++++++++++++++++++--------------------- 3 files changed, 105 insertions(+), 50 deletions(-) diff --git a/README.md b/README.md index fd7bc38..d8e54f2 100644 --- a/README.md +++ b/README.md @@ -102,40 +102,44 @@ lib.pipe import-tree [ ] ``` -Here is a less readable equivalent: +Here is a simpler but less readable equivalent: ```nix ((import-tree.mapWith lib.traceVal).filtered (lib.hasInfix ".mod.")) ./modules ``` -### `import-tree.withLib` +### `import-tree.filtered` + +`filtered` takes a predicate function `path -> bool`. Only paths for which the filter returns `true` are selected: > \[!NOTE\] -> `withLib` is required prior to invocation of any of `.leafs` or `.pipeTo`. -> Because with the use of those functions the implementation does not have access to a `lib` that is provided as a module argument. +> Only files with suffix `.nix` are candidates. ```nix -# import-tree.withLib : lib -> import-tree +# import-tree.filtered : (path -> bool) -> import-tree -import-tree.withLib pkgs.lib +import-tree.filtered (lib.hasInfix ".mod.") ./some-dir ``` -### `import-tree.filtered` +`filtered` can be applied multiple times, in which case only the files matching _all_ filters will be selected: -`filtered` takes a predicate function `path -> bool`. `true` means included. +```nix +lib.pipe import-tree [ + (i: i.filtered (lib.hasInfix ".mod.")) + (i: i.filtered (lib.hasSuffix "default.nix")) + (i: i ./some-dir) +] +``` -> \[!NOTE\] -> Only files with suffix `.nix` are candidates. +Or, in a simpler but less readable way: ```nix -# import-tree.filtered : (path -> bool) -> import-tree - -import-tree.filtered (lib.hasInfix ".mod.") ./some-dir +(import-tree.filtered (lib.hasInfix ".mod.")).filtered (lib.hasSuffix "default.nix") ./some-dir ``` ### `import-tree.matching` -`matching` takes a regular expression. The regex should match the full path for the path to be selected. Match is done with `lib.strings.match`; +`matching` takes a regular expression. The regex should match the full path for the path to be selected. Matching is done with `builtins.match`. ```nix # import-tree.matching : regex -> import-tree @@ -143,10 +147,13 @@ import-tree.filtered (lib.hasInfix ".mod.") ./some-dir import-tree.matching ".*/[a-z]+@(foo|bar)\.nix" ./some-dir ``` +`matching` can be applied multiple times, in which case only the paths matching _all_ regex patterns will be selected, and can be combined with any number of `filtered`, in any order. + ### `import-tree.mapWith` `mapWith` can be used to transform each path by providing a function. -e.g. to convert the path into a module explicitly. + +e.g. to convert the path into a module explicitly: ```nix # import-tree.mapWith : (path -> any) -> import-tree @@ -158,6 +165,37 @@ import-tree.mapWith (path: { }) ``` +`mapWith` can be applied multiple times, composing the transformations: + +```nix +lib.pipe import-tree [ + (i: i.mapWith (lib.removeSuffix ".nix")) + (i: i.mapWith builtins.stringLength) +] ./some-dir +``` + +The above example first removes the `.nix` suffix from all selected paths, then takes their lengths. + +Or, in a simpler but less readable way: + +```nix +((import-tree.mapWith (lib.removeSuffix ".nix")).mapWith builtins.stringLength) ./some-dir +``` + +`mapWith` can be combined with any number of `filtered` and `matching` calls, in any order, but the (composed) transformation is applied _after_ the filters, and only to the paths that match all of them. + +### `import-tree.withLib` + +> \[!NOTE\] +> `withLib` is required prior to invocation of any of `.leafs` or `.pipeTo`. +> Because with the use of those functions the implementation does not have access to a `lib` that is provided as a module argument. + +```nix +# import-tree.withLib : lib -> import-tree + +import-tree.withLib pkgs.lib +``` + ### `import-tree.pipeTo` `pipeTo` takes a function that will receive the list of paths. @@ -171,7 +209,7 @@ import-tree.pipeTo lib.id # equivalent to `.leafs` ### `import-tree.leafs` -`leafs` takes no arguments, it is equivalent to calling `import-tree.pipeTo lib.id`, that is, instead of producing a nix module, just return the list of results. +`leafs` takes no arguments, it is equivalent to calling `import-tree.pipeTo lib.id`. That is, instead of producing a nix module, just return the list of results. ```nix # import-tree.leafs : import-tree @@ -226,3 +264,13 @@ So, clearly this pattern is not for every situation, but most likely for sharing However, one advantage of this is that the dependency tree would be flat, giving the final user's flake absolute control on what inputs are used, without having to worry whether some third-party forgot to use `foo.inputs.nixpkgs.follows = "nixpkgs";` on any flake we are trying to re-use. + +## Testing + +`import-tree` uses [`checkmate`](https://github.com/vic/checkmate) for testing. + +The test suite can be found in [`checkmate.nix`](checkmate.nix). To run it locally: + +```sh +nix flake check ./checkmate +``` diff --git a/checkmate.nix b/checkmate.nix index 34a947a..8f61dbe 100644 --- a/checkmate.nix +++ b/checkmate.nix @@ -54,11 +54,21 @@ in ]; }; + matching."test `filter` composes with `matching`" = { + expr = ((lit.matching ".*/[^/]+_[^/]+\.nix").filtered (lib.hasSuffix "b.nix")).leafs ./tree; + expected = [ ./tree/a/a_b.nix ]; + }; + mapWith."test transforms each matching file with function" = { expr = (lit.mapWith import).leafs ./tree/x; expected = [ "z" ]; }; + mapWith."test multiple `mapWith`s compose" = { + expr = ((lit.mapWith import).mapWith builtins.stringLength).leafs ./tree/x; + expected = [ 1 ]; + }; + pipeTo."test pipes list into a function" = { expr = (lit.mapWith lib.pathType).pipeTo (lib.length) ./tree/x; expected = 1; diff --git a/default.nix b/default.nix index 3e487ad..838e592 100644 --- a/default.nix +++ b/default.nix @@ -1,10 +1,8 @@ let - perform = { lib ? null, - filter ? null, - regex ? null, + filterf ? null, mapf ? null, pipef ? null, ... @@ -29,56 +27,55 @@ let leafs = lib: root: let - isNixFile = lib.hasSuffix ".nix"; - notIgnored = p: !lib.hasInfix "/_" p; - matchesRegex = a: b: (lib.strings.match a b) != null; - - stringFilter = f: path: f (builtins.toString path); - filterWithS = f: lib.filter (stringFilter f); - - userFilter = - if filter != null then - filter - else if regex != null then - matchesRegex regex - else - (_: true); - - mapped = if mapf != null then lib.map mapf else (i: i); - + initialFilter = p: lib.hasSuffix ".nix" p && !lib.hasInfix "/_" p; in lib.pipe root [ - (lib.toList) (lib.lists.flatten) - (lib.map lib.filesystem.listFilesRecursive) + (map lib.filesystem.listFilesRecursive) (lib.lists.flatten) - (filterWithS isNixFile) - (filterWithS notIgnored) - (filterWithS userFilter) - (mapped) + (builtins.filter (compose (and filterf initialFilter) toString)) + (map mapf) ]; in result; + compose = + g: f: x: + g (f x); + + # Applies the second function first, to allow partial application when building the configuration. + and = + g: f: x: + f x && g x; + + matchesRegex = re: p: builtins.match re p != null; + + mapAttr = + attrs: k: f: + attrs // { ${k} = f attrs.${k}; }; + functor = self: perform self.config; + callable = let config = { + # Accumulated configuration + mapf = (i: i); + filterf = _: true; + __functor = self: f: { config = (f self); __functor = functor; - withLib = lib: self (c: (f c) // { inherit lib; }); - - filtered = filter: self (c: (f c) // { inherit filter; }); - - matching = regex: self (c: (f c) // { inherit regex; }); - - mapWith = mapf: self (c: (f c) // { inherit mapf; }); + # Configuration updates (accumulating) + filtered = filterf: self (c: mapAttr (f c) "filterf" (and filterf)); + matching = regex: self (c: mapAttr (f c) "filterf" (and (matchesRegex regex))); + mapWith = mapf: self (c: mapAttr (f c) "mapf" (compose mapf)); + # Configuration updates (non-accumulating) + withLib = lib: self (c: (f c) // { inherit lib; }); pipeTo = pipef: self (c: (f c) // { inherit pipef; }); - leafs = self (c: (f c) // { pipef = (i: i); }); }; }; From cd8b13fa82a8bc719b9edc6107c24522799753ac Mon Sep 17 00:00:00 2001 From: Alex Ionescu Date: Sun, 22 Jun 2025 02:00:59 +0200 Subject: [PATCH 2/2] Add more test cases --- checkmate.nix | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/checkmate.nix b/checkmate.nix index 8f61dbe..98c55ea 100644 --- a/checkmate.nix +++ b/checkmate.nix @@ -41,6 +41,11 @@ in expected = [ ./tree/a/b/m.nix ]; }; + filtered."test multiple `filtered`s compose" = { + expr = ((lit.filtered (lib.hasInfix "b/")).filtered (lib.hasInfix "_")).leafs ./tree; + expected = [ ./tree/a/b/b_a.nix ]; + }; + matching."test returns empty if no files matching regex" = { expr = (lit.matching "badregex").leafs ./tree; expected = [ ]; @@ -54,16 +59,26 @@ in ]; }; - matching."test `filter` composes with `matching`" = { + matching."test `matching` composes with `filtered`" = { expr = ((lit.matching ".*/[^/]+_[^/]+\.nix").filtered (lib.hasSuffix "b.nix")).leafs ./tree; expected = [ ./tree/a/a_b.nix ]; }; + matching."test multiple `matching`s compose" = { + expr = ((lit.matching ".*/[^/]+_[^/]+\.nix").matching ".*b\.nix").leafs ./tree; + expected = [ ./tree/a/a_b.nix ]; + }; + mapWith."test transforms each matching file with function" = { expr = (lit.mapWith import).leafs ./tree/x; expected = [ "z" ]; }; + mapWith."test `mapWith` composes with `filtered`" = { + expr = ((lit.filtered (lib.hasInfix "/x")).mapWith import).leafs ./tree; + expected = [ "z" ]; + }; + mapWith."test multiple `mapWith`s compose" = { expr = ((lit.mapWith import).mapWith builtins.stringLength).leafs ./tree/x; expected = [ 1 ];