diff --git a/README.md b/README.md index 01a28b0..6f362c2 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,14 @@ By default, nu2nix checks each script during the Nix build: These checks run after `patchPhase`, so they can be used to confirm your patching logic doesn't break the script (at least, as far as LSP diagnostics can confirm that). They can be disabled individually with `formatCheck = false` or `lintCheck = false`. +## Modules + +nu2nix supports packaging [nu modules](https://www.nushell.sh/book/modules/creating_modules.html), using [`inputs.nu2nix.lib.mkNuModule`](./lib/mkNuModule.nix). +These modules support all of the same features as nu2nix scripts, including the checks (which run across all `.nu` files in the module), and external dependencies. +Both single-file modules and directory modules are supported, though the build directory format is that of a directory module irrespective of if the original file was a single-file module. So, a module at `utils.nu` will end up in `$out/lib/nu/utils/mod.nu`. + +Scripts and modules can consume modules through `mkNuScript`/`mkNuModule`'s `modules` argument. Nothing is required in the script files aside from a normal `use` directive. The builders will patch the script / module files to have a hardcoded path to the module. Scripts and modules will inherit dependencies of their module dependencies. + ## External Dependencies nu2nix supports a custom format for declaring external dependencies inline in your script files, using an `External Dependencies` block comment. @@ -84,9 +92,9 @@ All declared dependencies are automatically added to the script's `$PATH` when e ``` > [!WARNING] -> Only dependencies declared in the `External Dependencies` block comment or passed through `mkNuScript`'s `extraPackages` argument are available at runtime. -> nu2nix replaces `$PATH` entirely instead of extending it. This makes scripts reproducible; if a command is not explicitly declared, it will not be available. -> Nushell itself is the exception, as it's automatically added by `mkNuScript`. +> Only dependencies declared in the `External Dependencies` block comment or passed through `mkNuScript`/`mkNuModule`'s `extraPackages` argument are available at runtime. +> nu2nix replaces `$PATH` entirely instead of extending it. This makes scripts & modules reproducible; if a command is not explicitly declared, it will not be available. +> For `mkNuScript`, Nushell itself is the exception. ### Dependency Formats diff --git a/flake.nix b/flake.nix index a38108a..1099d31 100644 --- a/flake.nix +++ b/flake.nix @@ -3,6 +3,7 @@ outputs = { ... }: { lib = { mkNuScript = import ./lib/mkNuScript.nix; + mkNuModule = import ./lib/mkNuModule.nix; }; }; } diff --git a/lib/mkNuModule.nix b/lib/mkNuModule.nix new file mode 100644 index 0000000..24c4402 --- /dev/null +++ b/lib/mkNuModule.nix @@ -0,0 +1,86 @@ +{ + pkgs, + lib ? pkgs.lib, + self ? null, + inputs ? null, + module, + name ? lib.removeSuffix ".nu" (baseNameOf module), + version ? "unstable", + meta ? { }, + nushell ? pkgs.nushell, + nufmt ? pkgs.nufmt, + system ? pkgs.stdenv.hostPlatform.system, + extraPackages ? [ ], + modules ? [ ], + patchPhase ? "", + formatCheck ? true, # Run nufmt in dry-run mode against the module as part of the checkPhase + lintCheck ? true, # Run nu's LSP diagnostics against the module as part of the checkPhase + checkPhase ? "", # Extra checks to run as part of the checkPhase +}: +let + dependencies = import ./utils/dependencies.nix { + inherit + pkgs + lib + self + inputs + system + ; + caller = "mkNuModule"; + }; + phases = import ./utils/phases.nix { inherit pkgs lib; }; + + filename = baseNameOf module; + deps = lib.unique ( + lib.flatten ( + (dependencies.getExternalDependenciesForPath module) + ++ extraPackages + ++ (map (m: m.dependencies) modules) + ) + ); +in +pkgs.stdenv.mkDerivation { + pname = name; + inherit version meta; + + src = module; + doCheck = true; + + passthru = { + dependencies = deps; + }; + + unpackPhase = '' + runHook preUnpack + cp -r $src ${filename} + runHook postUnpack + ''; + + patchPhase = phases.generatePatchPhase { + inherit modules patchPhase; + }; + + checkPhase = phases.generateCheckPhase { + inherit + nushell + nufmt + formatCheck + lintCheck + checkPhase + ; + }; + + installPhase = '' + runHook preInstall + + mkdir -p "$out/lib/nu/${name}" + + if [ -d "${filename}" ]; then + cp -r "${filename}/." "$out/lib/nu/${name}/" + else + install -m755 "${filename}" "$out/lib/nu/${name}/mod.nu" + fi + + runHook postInstall + ''; +} diff --git a/lib/mkNuScript.nix b/lib/mkNuScript.nix index 11c646a..1457590 100644 --- a/lib/mkNuScript.nix +++ b/lib/mkNuScript.nix @@ -11,66 +11,34 @@ nushell ? pkgs.nushell, nufmt ? pkgs.nufmt, extraPackages ? [ ], + modules ? [ ], patchPhase ? "", formatCheck ? true, # Run nufmt in dry-run mode against the script as part of the checkPhase lintCheck ? true, # Run nu's LSP diagnostics against the script as part of the checkPhase checkPhase ? "", # Extra checks to run as part of the checkPhase }: let - resolveDependency = - dep: - let - segments = lib.splitString "." dep; - scope = builtins.head segments; - rest = builtins.tail segments; - error = "Dependency `${dep}` not found"; - in - if (scope == "self" && rest != [ ]) then - if self != null then - lib.attrByPath ( - [ - "packages" - system - ] - ++ rest - ) (throw error) self - else - throw "`self` was not passed to `mkNuScript` or is `null`, but a script references a dependency with the `self.` scope." - else if (scope == "inputs" && rest != [ ]) then - if inputs != null then - lib.attrByPath ( - [ - (builtins.head rest) - "packages" - system - ] - ++ (builtins.tail rest) - ) (throw error) inputs - else - throw "`inputs` was not passed to `mkNuScript` or is `null`, but a script references a dependency with the `inputs.` scope." - else - lib.attrByPath segments (throw error) pkgs; - - externalDependencies = - let - scriptText = builtins.readFile script; - lines = lib.splitString "\n" scriptText; - start = lib.lists.findFirstIndex (line: line == "# START: External Dependencies") null lines; - end = lib.lists.findFirstIndex (line: line == "# END: External Dependencies") null lines; - deps = - if (start != null && end != null) then - map (line: lib.removePrefix "# " line) ( - builtins.filter (line: lib.hasPrefix "#" line && line != "#") ( - lib.sublist (start + 1) (end - start - 1) lines - ) - ) - else - [ ]; - in - map resolveDependency deps; + dependencies = import ./utils/dependencies.nix { + inherit + pkgs + lib + self + inputs + system + ; + caller = "mkNuScript"; + }; + phases = import ./utils/phases.nix { inherit pkgs lib; }; filename = baseNameOf script; - deps = externalDependencies ++ extraPackages ++ [ nushell ]; + deps = lib.unique ( + lib.flatten ( + (dependencies.getExternalDependenciesForPath script) + ++ extraPackages + ++ (map (m: m.dependencies) modules) + ++ [ nushell ] + ) + ); in pkgs.stdenv.mkDerivation { pname = name; @@ -82,37 +50,29 @@ pkgs.stdenv.mkDerivation { nativeBuildInputs = [ pkgs.makeWrapper ]; buildInputs = [ nushell ]; + passthru = { + dependencies = deps; + }; + unpackPhase = '' runHook preUnpack cp $src ${filename} runHook postUnpack ''; - patchPhase = '' - runHook prePatch - ${patchPhase} - runHook postPatch - ''; - - checkPhase = lib.concatStringsSep "\n" [ - (lib.optionalString formatCheck '' - ${lib.getExe nufmt} --dry-run '${filename}' - '') - - (lib.optionalString lintCheck '' - nu='${lib.getExe nushell}' - lspOutput=$("$nu" --ide-check 100 '${filename}') # returns 0 even when diagnostics are reported - # I considered using jq for this, but we already have nushell in the closure, so may as well use it instead of pulling in jq - diagnostics=$(echo "$lspOutput" | "$nu" --stdin --no-newline -c '$in | from json --objects | where "type" == "diagnostic" | to json') - - if [[ "$diagnostics" != '[]' ]]; then - echo "$diagnostics" - exit 1 - fi - '') + patchPhase = phases.generatePatchPhase { + inherit modules patchPhase; + }; - (lib.optionalString (checkPhase != "") checkPhase) - ]; + checkPhase = phases.generateCheckPhase { + inherit + nushell + nufmt + formatCheck + lintCheck + checkPhase + ; + }; installPhase = '' runHook preInstall diff --git a/lib/utils/dependencies.nix b/lib/utils/dependencies.nix new file mode 100644 index 0000000..0b234c8 --- /dev/null +++ b/lib/utils/dependencies.nix @@ -0,0 +1,78 @@ +{ + pkgs, + lib ? pkgs.lib, + self ? null, + inputs ? null, + system ? pkgs.stdenv.hostPlatform.system, + caller, +}: +rec { + resolveDependency = + dep: + let + segments = lib.splitString "." dep; + scope = builtins.head segments; + rest = builtins.tail segments; + error = "Dependency `${dep}` not found"; + in + if (scope == "self" && rest != [ ]) then + if self != null then + lib.attrByPath ( + [ + "packages" + system + ] + ++ rest + ) (throw error) self + else + throw "`self` was not passed to `${caller}` or is `null`, but a file references a dependency with the `self.` scope." + else if (scope == "inputs" && rest != [ ]) then + if inputs != null then + lib.attrByPath ( + [ + (builtins.head rest) + "packages" + system + ] + ++ (builtins.tail rest) + ) (throw error) inputs + else + throw "`inputs` was not passed to `${caller}` or is `null`, but a file references a dependency with the `inputs.` scope." + else + lib.attrByPath segments (throw error) pkgs; + + getExternalDependenciesForFile = + filePath: + let + scriptText = builtins.readFile filePath; + lines = lib.splitString "\n" scriptText; + start = lib.lists.findFirstIndex (line: line == "# START: External Dependencies") null lines; + end = lib.lists.findFirstIndex (line: line == "# END: External Dependencies") null lines; + deps = + if (start != null && end != null) then + map (line: lib.removePrefix "# " line) ( + builtins.filter (line: lib.hasPrefix "#" line && line != "#") ( + lib.sublist (start + 1) (end - start - 1) lines + ) + ) + else + [ ]; + in + map resolveDependency deps; + + getExternalDependenciesForPath = + path: + let + type = builtins.readFileType path; + in + if type == "directory" then + lib.unique ( + lib.flatten (lib.mapAttrsToList (p: _: getExternalDependenciesForPath p) (builtins.readDir path)) + ) + else if !(lib.hasSuffix ".nu" path) then + [ ] + else if type == "regular" then + getExternalDependenciesForFile path + else + [ ]; +} diff --git a/lib/utils/phases.nix b/lib/utils/phases.nix new file mode 100644 index 0000000..0c0d7bf --- /dev/null +++ b/lib/utils/phases.nix @@ -0,0 +1,70 @@ +{ pkgs, lib }: rec { + filteredConcat = list: lib.concatStringsSep "\n" (lib.filter (e: e != "" && e != null) list); + + generatePatchPhase = + { + modules ? [ ], + patchPhase ? "", + }: + filteredConcat [ + "runHook prePatch" + + (filteredConcat ( + lib.map (module: '' + find . -type f -name '*.nu' -exec sed -Ei 's|^ *use [\./a-zA-Z0-9_-]*${module.pname}(\.nu)?|use ${module}/lib/nu/${module.pname}|g' {} + + '') modules + )) + + (lib.optionalString (patchPhase != "") patchPhase) + + "runHook postPatch" + ]; + + lspScript = + nushell: + pkgs.writeTextFile rec { + name = "nushell-lsp-check-script"; + text = '' + #!${lib.getExe nushell} + def main [filename: path]: nothing -> any { + let lsp_output = ^${lib.getExe nushell} --ide-check 100 $filename + + let diagnostics = $lsp_output + | from json --objects + | where type == "diagnostic" + + if ($diagnostics | is-not-empty) { + print $"($filename):" + $diagnostics | to json + exit 1 + } else { + print $"no LSP diagnostics reported for ($filename)" + } + } + ''; + executable = true; + destination = "/bin/${name}"; + meta.mainProgram = name; + }; + + generateCheckPhase = + { + nushell, + nufmt, + formatCheck ? true, + lintCheck ? true, + checkPhase ? "", + ... + }: + filteredConcat [ + (lib.optionalString formatCheck '' + find . -type f -name '*.nu' -exec ${lib.getExe nufmt} --dry-run {} + + '') + + (lib.optionalString lintCheck '' + find . -type f -name '*.nu' -exec ${lib.getExe (lspScript nushell)} {} \; + '') + + (lib.optionalString (checkPhase != "") checkPhase) + ]; +}