Skip to content

Commit 1e8439d

Browse files
authored
builtins/formatting: add "nix flake fmt" builtin formatter (#192)
1 parent 6f5473a commit 1e8439d

File tree

3 files changed

+243
-1
lines changed

3 files changed

+243
-1
lines changed

doc/HELPERS.md

+6-1
Original file line numberDiff line numberDiff line change
@@ -177,7 +177,7 @@ Not compatible with `ignore_stderr`.
177177

178178
Reads the contents of the temp file created by `to_temp_file` after running
179179
`command` and assigns it to `params.output`. Useful for formatters that don't
180-
output to `stdin` (see `formatter_factory`).
180+
output to `stdout` (see `formatter_factory`).
181181

182182
This option depends on `to_temp_file`.
183183

@@ -393,3 +393,8 @@ it again).
393393
Like `by_bufnr`, but `callback` is an async function. That is, `callback` is a
394394
function that takes two arguments: a `params` table and a `done` callback that
395395
must be invoked with the result.
396+
397+
### by_bufroot_async(callback)
398+
399+
Like `by_bufnr`, but `callback` is an async function, and the result is indexed
400+
by `root` rather than `bufrn`.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,213 @@
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+
})

lua/null-ls/helpers/cache.lua

+24
Original file line numberDiff line numberDiff line change
@@ -61,4 +61,28 @@ M.by_bufnr_async = function(cb)
6161
end
6262
end
6363

64+
--- creates a function that caches the output of an async callback, indexed by project root
65+
---@param cb function
66+
---@return fun(params: NullLsParams): any
67+
M.by_bufroot_async = function(cb)
68+
-- assign next available key, since we just want to avoid collisions
69+
local key = next_key
70+
M.cache[key] = {}
71+
next_key = next_key + 1
72+
73+
return function(params, done)
74+
local root = params.root
75+
-- if we haven't cached a value yet, get it from cb
76+
if M.cache[key][root] == nil then
77+
-- make sure we always store a value so we know we've already called cb
78+
cb(params, function(result)
79+
M.cache[key][root] = result or false
80+
done(M.cache[key][root])
81+
end)
82+
else
83+
done(M.cache[key][root])
84+
end
85+
end
86+
end
87+
6488
return M

0 commit comments

Comments
 (0)