|
| 1 | +local h = require("null-ls.helpers") |
| 2 | +local methods = require("null-ls.methods") |
| 3 | +local log = require("null-ls.logger") |
| 4 | +local client = require("null-ls.client") |
| 5 | +local async = require("plenary.async") |
| 6 | +local Job = require("plenary.job") |
| 7 | + |
| 8 | +local FORMATTING = methods.internal.FORMATTING |
| 9 | + |
| 10 | +local run_job = async.wrap(function(opts, done) |
| 11 | + opts.on_exit = function(j, status) |
| 12 | + done(status, j:result(), j:stderr_result()) |
| 13 | + end |
| 14 | + |
| 15 | + Job:new(opts):start() |
| 16 | +end, 2) |
| 17 | + |
| 18 | +local tmpname = async.wrap(function(done) |
| 19 | + vim.defer_fn(function() |
| 20 | + done(vim.fn.tempname()) |
| 21 | + end, 0) |
| 22 | +end, 1) |
| 23 | + |
| 24 | +--- Asynchronously computes the command that `nix fmt` would run, or nil if |
| 25 | +--- we're not in a flake with a formatter, or if we fail to discover the |
| 26 | +--- formatter somehow. When finished, it invokes the `done` callback with a |
| 27 | +--- single string|nil parameter identifier the `nix fmt` entrypoint if found. |
| 28 | +--- |
| 29 | +--- The formatter must follow treefmt's [formatter |
| 30 | +--- spec](https://github.com/numtide/treefmt/blob/main/docs/formatter-spec.md). |
| 31 | +--- |
| 32 | +--- This basically re-implements the "entrypoint discovery" that `nix fmt` does. |
| 33 | +--- So why are we doing this ourselves rather than just invoking `nix fmt`? |
| 34 | +--- Unfortunately, it can take a few moments to evaluate all your nix code to |
| 35 | +--- figure out the formatter entrypoint. It can even be slow enough to exceed |
| 36 | +--- Neovim's default LSP timeout. |
| 37 | +--- By doing this ourselves, we can cache the result. |
| 38 | +local find_nix_fmt = function(opts, done) |
| 39 | + done = vim.schedule_wrap(done) |
| 40 | + |
| 41 | + async.run(function() |
| 42 | + local title = "discovering `nix fmt` entrypoint" |
| 43 | + local progress_token = "nix-flake-fmt-discovery" |
| 44 | + |
| 45 | + client.send_progress_notification(progress_token, { |
| 46 | + kind = "begin", |
| 47 | + title = title, |
| 48 | + }) |
| 49 | + |
| 50 | + local root = opts.root |
| 51 | + |
| 52 | + -- Discovering `currentSystem` here lets us keep the *next* eval pure. |
| 53 | + -- We want to keep that part pure as a performance improvement: an impure |
| 54 | + -- eval that references the flake would copy *all* files (including |
| 55 | + -- gitignored files!), which can be quite expensive if you've got many GiB |
| 56 | + -- of artifacts in the directory. This optimization can probably go away |
| 57 | + -- once the [Lazy trees PR] lands. |
| 58 | + -- |
| 59 | + -- [Lazy trees PR]: https://github.com/NixOS/nix/pull/6530 |
| 60 | + local status, stdout_lines, stderr_lines = run_job({ |
| 61 | + command = "nix", |
| 62 | + args = { |
| 63 | + "--extra-experimental-features", |
| 64 | + "nix-command", |
| 65 | + "config", |
| 66 | + "show", |
| 67 | + "system", |
| 68 | + }, |
| 69 | + }) |
| 70 | + |
| 71 | + if status ~= 0 then |
| 72 | + local stderr = table.concat(stderr_lines, "\n") |
| 73 | + vim.defer_fn(function() |
| 74 | + log:warn(string.format("unable to discover builtins.currentSystem from nix. stderr: %s", stderr)) |
| 75 | + end, 0) |
| 76 | + done(nil) |
| 77 | + return |
| 78 | + end |
| 79 | + |
| 80 | + local nix_current_system = stdout_lines[1] |
| 81 | + |
| 82 | + local eval_nix_formatter = [[ |
| 83 | + let |
| 84 | + currentSystem = "]] .. nix_current_system .. [["; |
| 85 | + # Various functions vendored from nixpkgs lib (to avoid adding a |
| 86 | + # dependency on nixpkgs). |
| 87 | + lib = rec { |
| 88 | + getOutput = output: pkg: |
| 89 | + if ! pkg ? outputSpecified || ! pkg.outputSpecified |
| 90 | + then pkg.${output} or pkg.out or pkg |
| 91 | + else pkg; |
| 92 | + getBin = getOutput "bin"; |
| 93 | + # Simplified by removing various type assertions. |
| 94 | + getExe' = x: y: "${getBin x}/bin/${y}"; |
| 95 | + # getExe is simplified to assume meta.mainProgram is specified. |
| 96 | + getExe = x: getExe' x x.meta.mainProgram; |
| 97 | + }; |
| 98 | + in |
| 99 | + formatterBySystem: |
| 100 | + if formatterBySystem ? ${currentSystem} then |
| 101 | + let |
| 102 | + formatter = formatterBySystem.${currentSystem}; |
| 103 | + drv = formatter.drvPath; |
| 104 | + bin = lib.getExe formatter; |
| 105 | + in |
| 106 | + drv + "\n" + bin + "\n" |
| 107 | + else |
| 108 | + "" |
| 109 | + ]] |
| 110 | + |
| 111 | + client.send_progress_notification(progress_token, { |
| 112 | + kind = "report", |
| 113 | + title = title, |
| 114 | + message = "evaluating", |
| 115 | + }) |
| 116 | + status, stdout_lines, stderr_lines = run_job({ |
| 117 | + command = "nix", |
| 118 | + args = { |
| 119 | + "--extra-experimental-features", |
| 120 | + "nix-command flakes", |
| 121 | + "eval", |
| 122 | + ".#formatter", |
| 123 | + "--raw", |
| 124 | + "--apply", |
| 125 | + eval_nix_formatter, |
| 126 | + }, |
| 127 | + cwd = root, |
| 128 | + }) |
| 129 | + |
| 130 | + if status ~= 0 then |
| 131 | + local stderr = table.concat(stderr_lines, "\n") |
| 132 | + vim.defer_fn(function() |
| 133 | + log:warn(string.format("unable discover 'nix fmt' command. stderr: %s", stderr)) |
| 134 | + end, 0) |
| 135 | + done(nil) |
| 136 | + return |
| 137 | + end |
| 138 | + |
| 139 | + if #stdout_lines == 0 then |
| 140 | + vim.defer_fn(function() |
| 141 | + log:warn( |
| 142 | + string.format("this flake does not define a formatter for your system: %s", nix_current_system) |
| 143 | + ) |
| 144 | + end, 0) |
| 145 | + done(nil) |
| 146 | + return |
| 147 | + end |
| 148 | + |
| 149 | + -- stdout has 2 lines of output: |
| 150 | + -- 1. drv path |
| 151 | + -- 2. exe path |
| 152 | + local drv_path, nix_fmt_path = unpack(stdout_lines) |
| 153 | + |
| 154 | + -- Build the derivation. This ensures that `nix_fmt_path` exists. |
| 155 | + client.send_progress_notification(progress_token, { |
| 156 | + kind = "report", |
| 157 | + title = title, |
| 158 | + message = "building", |
| 159 | + }) |
| 160 | + status, stdout_lines, stderr_lines = run_job({ |
| 161 | + command = "nix", |
| 162 | + args = { |
| 163 | + "--extra-experimental-features", |
| 164 | + "nix-command", |
| 165 | + "build", |
| 166 | + "--out-link", |
| 167 | + tmpname(), |
| 168 | + drv_path .. "^out", |
| 169 | + }, |
| 170 | + }) |
| 171 | + |
| 172 | + if status ~= 0 then |
| 173 | + local stderr = table.concat(stderr_lines, "\n") |
| 174 | + vim.defer_fn(function() |
| 175 | + log:warn(string.format("unable to build 'nix fmt' entrypoint. stderr: %s", stderr)) |
| 176 | + end, 0) |
| 177 | + done(nil) |
| 178 | + return |
| 179 | + end |
| 180 | + |
| 181 | + client.send_progress_notification(progress_token, { |
| 182 | + kind = "end", |
| 183 | + title = title, |
| 184 | + message = "done", |
| 185 | + }) |
| 186 | + |
| 187 | + done(nix_fmt_path) |
| 188 | + end) |
| 189 | +end |
| 190 | + |
| 191 | +return h.make_builtin({ |
| 192 | + name = "nix flake fmt", |
| 193 | + meta = { |
| 194 | + url = "https://nix.dev/manual/nix/latest/command-ref/new-cli/nix3-fmt", |
| 195 | + description = "`nix fmt` - reformat your code in the standard style (this is a generic formatter, not to be confused with nixfmt, a formatter for .nix files)", |
| 196 | + }, |
| 197 | + method = FORMATTING, |
| 198 | + filetypes = {}, |
| 199 | + generator_opts = { |
| 200 | + -- It can take a few moments to find the `nix fmt` entrypoint. The |
| 201 | + -- underlying command shouldn't change very often for a given |
| 202 | + -- project, so cache it for the project root. |
| 203 | + dynamic_command = h.cache.by_bufroot_async(find_nix_fmt), |
| 204 | + args = { |
| 205 | + "$FILENAME", |
| 206 | + }, |
| 207 | + to_temp_file = true, |
| 208 | + }, |
| 209 | + condition = function(utils) |
| 210 | + return utils.root_has_file("flake.nix") |
| 211 | + end, |
| 212 | + factory = h.formatter_factory, |
| 213 | +}) |
0 commit comments