···11+<p align="right">
22+ <a href="https://github.com/sponsors/vic"><img src="https://img.shields.io/badge/sponsor-vic-white?logo=githubsponsors&logoColor=white&labelColor=%23FF0000" alt="Sponsor Vic"/>
33+ </a>
44+ <a href="https://vic.github.io/dendrix/Dendritic-Ecosystem.html#vics-dendritic-libraries"> <img src="https://img.shields.io/badge/Dendritic-Nix-informational?logo=nixos&logoColor=white" alt="Dendritic Nix"/> </a>
55+ <a href="LICENSE"> <img src="https://img.shields.io/github/license/vic/import-tree" alt="License"/> </a>
66+ <a href="https://github.com/vic/import-tree/actions">
77+ <img src="https://github.com/vic/import-tree/actions/workflows/test.yml/badge.svg" alt="CI Status"/> </a>
88+</p>
99+1010+> `import-tree` and [vic](https://bsky.app/profile/oeiuwq.bsky.social)'s [dendritic libs](https://vic.github.io/dendrix/Dendritic-Ecosystem.html#vics-dendritic-libraries) made for you with Love++ and AI--. If you like my work, consider [sponsoring](https://github.com/sponsors/vic)
1111+112# ๐ฒ๐ด import-tree ๐๐ณ
213314> Recursively import [Nix modules](https://nix.dev/tutorials/module-system/) from a directory, with a simple, extensible API.
···55665667For more advanced usage, `import-tree` can be configured via its extensible API.
57685858-______________________________________________________________________
6969+---
59706071#### Obtaining the API
6172···217228218229##### ๐ณ `import-tree.initFilter`
219230220220-*Replaces* the initial filter which defaults to: Include files with `.nix` suffix and not having `/_` infix.
231231+_Replaces_ the initial filter which defaults to: Include files with `.nix` suffix and not having `/_` infix.
221232222233```nix
223234import-tree.initFilter (p: lib.hasSuffix ".nix" p && !lib.hasInfix "/ignored/" p)
···246257(import-tree.addPath ./modules) [ ]
247258```
248259249249-______________________________________________________________________
260260+---
250261251262## Why
252263···269280270281@vic is using this on [Dendrix](https://github.com/vic/dendrix) for [community conventions](https://github.com/vic/dendrix/blob/main/dev/modules/community/_pipeline.nix) on tagging files.
271282272272-This would allow us to have community-driven *sets* of configurations,
283283+This would allow us to have community-driven _sets_ of configurations,
273284much like those popular for editors: spacemacs/lazy-vim distributions.
274285275286Imagine an editor distribution exposing the following flake output:
···277288```nix
278289# editor-distro's flakeModule
279290{inputs, lib, ...}:
280280-let
291291+let
281292 flake.lib.modules-tree = lib.pipe inputs.import-tree [
282293 (i: i.addPath ./modules)
283294 (i: i.addAPI { inherit on off exclusive; })
···312323}
313324```
314325315315-______________________________________________________________________
326326+---
316327317328## Testing
318329···321332The test suite can be found in [`checkmate.nix`](checkmate.nix). To run it locally:
322333323334```sh
324324-nix flake check path:checkmate --override-input target path:.
335335+nix flake check github:vic/checkmate --override-input target path:.
325336```
326337327338Run the following to format files:
···11-# If formatting fails, run
22-# nix run github:vic/checkmate#checkmate-treefmt
33-#
44-{ inputs, lib, ... }:
55-let
66- # since we are tested by github:vic/checkmate
77- it = inputs.target;
88- lit = it.withLib lib;
99-in
1010-{
1111- perSystem = (
1212- { ... }:
1313- {
1414- nix-unit.tests = {
1515- leafs."test fails if no lib has been set" = {
1616- expr = it.leafs ./trees;
1717- expectedError.type = "ThrownError";
1818- };
1919-2020- leafs."test succeeds when lib has been set" = {
2121- expr = (it.withLib lib).leafs ./tree/hello;
2222- expected = [ ];
2323- };
2424-2525- leafs."test only returns nix non-ignored files" = {
2626- expr = lit.leafs ./tree/a;
2727- expected = [
2828- ./tree/a/a_b.nix
2929- ./tree/a/b/b_a.nix
3030- ./tree/a/b/m.nix
3131- ];
3232- };
3333-3434- filter."test returns empty if no nix files with true predicate" = {
3535- expr = (lit.filter (_: false)).leafs ./tree;
3636- expected = [ ];
3737- };
3838-3939- filter."test only returns nix files with true predicate" = {
4040- expr = (lit.filter (lib.hasSuffix "m.nix")).leafs ./tree;
4141- expected = [ ./tree/a/b/m.nix ];
4242- };
4343-4444- filter."test multiple `filter`s compose" = {
4545- expr = ((lit.filter (lib.hasInfix "b/")).filter (lib.hasInfix "_")).leafs ./tree;
4646- expected = [ ./tree/a/b/b_a.nix ];
4747- };
4848-4949- match."test returns empty if no files match regex" = {
5050- expr = (lit.match "badregex").leafs ./tree;
5151- expected = [ ];
5252- };
5353-5454- match."test returns files matching regex" = {
5555- expr = (lit.match ".*/[^/]+_[^/]+\.nix").leafs ./tree;
5656- expected = [
5757- ./tree/a/a_b.nix
5858- ./tree/a/b/b_a.nix
5959- ];
6060- };
6161-6262- matchNot."test returns files not matching regex" = {
6363- expr = (lit.matchNot ".*/[^/]+_[^/]+\.nix").leafs ./tree/a/b;
6464- expected = [
6565- ./tree/a/b/m.nix
6666- ];
6767- };
6868-6969- match."test `match` composes with `filter`" = {
7070- expr = ((lit.match ".*a_b.nix").filter (lib.hasInfix "/a/")).leafs ./tree;
7171- expected = [ ./tree/a/a_b.nix ];
7272- };
7373-7474- match."test multiple `match`s compose" = {
7575- expr = ((lit.match ".*/[^/]+_[^/]+\.nix").match ".*b\.nix").leafs ./tree;
7676- expected = [ ./tree/a/a_b.nix ];
7777- };
7878-7979- map."test transforms each matching file with function" = {
8080- expr = (lit.map import).leafs ./tree/x;
8181- expected = [ "z" ];
8282- };
8383-8484- map."test `map` composes with `filter`" = {
8585- expr = ((lit.filter (lib.hasInfix "/x")).map import).leafs ./tree;
8686- expected = [ "z" ];
8787- };
8888-8989- map."test multiple `map`s compose" = {
9090- expr = ((lit.map import).map builtins.stringLength).leafs ./tree/x;
9191- expected = [ 1 ];
9292- };
9393-9494- addPath."test `addPath` prepends a path to filter" = {
9595- expr = (lit.addPath ./tree/x).files;
9696- expected = [ ./tree/x/y.nix ];
9797- };
9898-9999- addPath."test `addPath` can be called multiple times" = {
100100- expr = ((lit.addPath ./tree/x).addPath ./tree/a/b).files;
101101- expected = [
102102- ./tree/x/y.nix
103103- ./tree/a/b/b_a.nix
104104- ./tree/a/b/m.nix
105105- ];
106106- };
107107-108108- addPath."test `addPath` identity" = {
109109- expr = ((lit.addPath ./tree/x).addPath ./tree/a/b).files;
110110- expected = lit.leafs [
111111- ./tree/x
112112- ./tree/a/b
113113- ];
114114- };
115115-116116- new."test `new` returns a clear state" = {
117117- expr = lib.pipe lit [
118118- (i: i.addPath ./tree/x)
119119- (i: i.addPath ./tree/a/b)
120120- (i: i.new)
121121- (i: i.addPath ./tree/modules/hello-world)
122122- (i: i.withLib lib)
123123- (i: i.files)
124124- ];
125125- expected = [ ./tree/modules/hello-world/mod.nix ];
126126- };
127127-128128- initFilter."test can change the initial filter to look for other file types" = {
129129- expr = (lit.initFilter (p: lib.hasSuffix ".txt" p)).leafs [ ./tree/a ];
130130- expected = [ ./tree/a/a.txt ];
131131- };
132132-133133- initFilter."test initf does filter non-paths" = {
134134- expr =
135135- let
136136- mod = (it.initFilter (x: !(x ? config.boom))) [
137137- {
138138- options.hello = lib.mkOption {
139139- default = "world";
140140- type = lib.types.str;
141141- };
142142- }
143143- {
144144- config.boom = "boom";
145145- }
146146- ];
147147- res = lib.modules.evalModules { modules = [ mod ]; };
148148- in
149149- res.config.hello;
150150- expected = "world";
151151- };
152152-153153- addAPI."test extends the API available on an import-tree object" = {
154154- expr =
155155- let
156156- extended = lit.addAPI { helloOption = self: self.addPath ./tree/modules/hello-option; };
157157- in
158158- extended.helloOption.files;
159159- expected = [ ./tree/modules/hello-option/mod.nix ];
160160- };
161161-162162- addAPI."test preserves previous API extensions on an import-tree object" = {
163163- expr =
164164- let
165165- first = lit.addAPI { helloOption = self: self.addPath ./tree/modules/hello-option; };
166166- second = first.addAPI { helloWorld = self: self.addPath ./tree/modules/hello-world; };
167167- extended = second.addAPI { res = self: self.helloOption.files; };
168168- in
169169- extended.res;
170170- expected = [ ./tree/modules/hello-option/mod.nix ];
171171- };
172172-173173- addAPI."test API extensions are late bound" = {
174174- expr =
175175- let
176176- first = lit.addAPI { res = self: self.late; };
177177- extended = first.addAPI { late = _self: "hello"; };
178178- in
179179- extended.res;
180180- expected = "hello";
181181- };
182182-183183- pipeTo."test pipes list into a function" = {
184184- expr = (lit.map lib.pathType).pipeTo (lib.length) ./tree/x;
185185- expected = 1;
186186- };
187187-188188- import-tree."test does not break if given a path to a file instead of a directory." = {
189189- expr = lit.leafs ./tree/x/y.nix;
190190- expected = [ ./tree/x/y.nix ];
191191- };
192192-193193- import-tree."test returns a module with a single imported nested module having leafs" = {
194194- expr =
195195- let
196196- oneElement = arr: if lib.length arr == 1 then lib.elemAt arr 0 else throw "Expected one element";
197197- module = it ./tree/x;
198198- inner = (oneElement module.imports) { inherit lib; };
199199- in
200200- oneElement inner.imports;
201201- expected = ./tree/x/y.nix;
202202- };
203203-204204- import-tree."test evaluates returned module as part of module-eval" = {
205205- expr =
206206- let
207207- res = lib.modules.evalModules { modules = [ (it ./tree/modules) ]; };
208208- in
209209- res.config.hello;
210210- expected = "world";
211211- };
212212-213213- import-tree."test can itself be used as a module" = {
214214- expr =
215215- let
216216- res = lib.modules.evalModules { modules = [ (it.addPath ./tree/modules) ]; };
217217- in
218218- res.config.hello;
219219- expected = "world";
220220- };
221221-222222- import-tree."test take as arg anything path convertible" = {
223223- expr = lit.leafs [
224224- {
225225- outPath = ./tree/modules/hello-world;
226226- }
227227- ];
228228- expected = [ ./tree/modules/hello-world/mod.nix ];
229229- };
230230-231231- import-tree."test passes non-paths without string conversion" = {
232232- expr =
233233- let
234234- mod = it [
235235- {
236236- options.hello = lib.mkOption {
237237- default = "world";
238238- type = lib.types.str;
239239- };
240240- }
241241- ];
242242- res = lib.modules.evalModules { modules = [ mod ]; };
243243- in
244244- res.config.hello;
245245- expected = "world";
246246- };
247247-248248- import-tree."test can take other import-trees as if they were paths" = {
249249- expr = (lit.filter (lib.hasInfix "mod")).leafs [
250250- (it.addPath ./tree/modules/hello-option)
251251- ./tree/modules/hello-world
252252- ];
253253- expected = [
254254- ./tree/modules/hello-option/mod.nix
255255- ./tree/modules/hello-world/mod.nix
256256- ];
257257- };
258258-259259- leafs."test loads from hidden directory but excludes sub-hidden" = {
260260- expr = lit.leafs ./tree/a/b/_c;
261261- expected = [ ./tree/a/b/_c/d/e.nix ];
262262- };
263263- };
264264-265265- }
266266- );
267267-}
+22-11
default.nix
···138138139139 callable =
140140 let
141141- __config = {
141141+ initial = {
142142 # Accumulated configuration
143143 api = { };
144144 mapf = (i: i);
145145 filterf = _: true;
146146 paths = [ ];
147147148148+ # config is our state (initial at first). this functor allows it
149149+ # to work as if it was a function, taking an update function
150150+ # that will return a new state. for example:
151151+ # in mergeAttrs: `config (c: c // x)` will merge x into new config.
148152 __functor =
149149- self: f:
153153+ config: update:
150154 let
151151- __config = (f self);
152152- boundAPI = builtins.mapAttrs (_: g: g (self f)) __config.api;
153153- accAttr = attrName: acc: self (c: mapAttr (f c) attrName acc);
154154- mergeAttrs = attrs: self (c: (f c) // attrs);
155155+ # updated is another config
156156+ updated = update config;
157157+158158+ # current is the result of this functor.
159159+ # it is not a config, but an import-tree object containing a __config.
160160+ current = config update;
161161+ boundAPI = builtins.mapAttrs (_: g: g current) updated.api;
162162+163163+ # these two helpers are used to **append** aggregated configs.
164164+ accAttr = attrName: acc: config (c: mapAttr (update c) attrName acc);
165165+ mergeAttrs = attrs: config (c: (update c) // attrs);
155166 in
156167 boundAPI
157168 // {
158158- inherit __config;
159159- __functor = functor;
169169+ __config = updated;
170170+ __functor = functor; # user-facing callable
160171161172 # Configuration updates (accumulating)
162173 filter = filterf: accAttr "filterf" (and filterf);
···174185 leafs = mergeAttrs { pipef = (i: i); };
175186176187 # Applies empty (for already path-configured trees)
177177- result = (self f) [ ];
188188+ result = current [ ];
178189179190 # Return a list of all filtered files.
180180- files = (self f).leafs.result;
191191+ files = current.leafs.result;
181192182193 # returns the original empty state
183194 new = callable;
184195 };
185196 };
186197 in
187187- __config (c: c);
198198+ initial (config: config);
188199189200in
190201callable