Skip to content

Commit

Permalink
Add "nix flake fmt" formatter
Browse files Browse the repository at this point in the history
This uses `nix fmt` under the hood. I introduced some caching make it
performant, as `nix fmt` can take a little while to evaluate all your
nix code to figure out the formatter entrypoint.
  • Loading branch information
jfly committed Oct 11, 2024
1 parent fbbeda2 commit ec16dd4
Show file tree
Hide file tree
Showing 2 changed files with 155 additions and 1 deletion.
2 changes: 1 addition & 1 deletion doc/HELPERS.md
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,7 @@ Not compatible with `ignore_stderr`.

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

This option depends on `to_temp_file`.

Expand Down
154 changes: 154 additions & 0 deletions lua/null-ls/builtins/formatting/nix_flake_fmt.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
local h = require("null-ls.helpers")
local methods = require("null-ls.methods")
local log = require("null-ls.logger")

local FORMATTING = methods.internal.FORMATTING

--- Return the command that `nix fmt` would run, or nil if we're not in a
--- flake.
---
--- The formatter must follow treefmt's [formatter
--- spec](https://github.com/numtide/treefmt/blob/main/docs/formatter-spec.md).
---
--- This basically re-implements the "entrypoint discovery" that `nix fmt` does.
--- So why are we doing this ourselves rather than just invoking `nix fmt`?
--- Unfortunately, it can take a few moments to evaluate all your nix code to
--- figure out the formatter entrypoint. It can even be slow enough to exceed
--- Neovim's default LSP timeout.
--- By doing this ourselves, we can cache the result.
---
---@return string|nil
local find_nix_fmt = function(params)
-- Discovering currentSystem here lets us keep the *next* eval pure.
-- We want to keep that part pure as a performance improvement: an impure
-- eval that references the flake would copy *all* files (including
-- gitignored files!), which can be quite expensive if you've got many GiB
-- of artifacts in the directory. This optimization can probably go away
-- once the [Lazy trees PR] lands.
--
-- [Lazy trees PR]: https://github.com/NixOS/nix/pull/6530
local cp = vim.system({ "nix", "--extra-experimental-features", "nix-command", "config", "show", "system" }):wait()
if cp.code ~= 0 then
log:warn(string.format("unable to discover builtins.currentSystem from nix. stderr: %s", cp.stderr))
return nil
end
local nix_current_system = cp.stdout:match("^(.-)%s+") -- remove trailing newline

local eval_nix_formatter = [[
let
currentSystem = "]] .. nix_current_system .. [[";
# Various functions vendored from nixpkgs lib (to avoid adding a
# dependency on nixpkgs).
lib = rec {
getOutput = output: pkg:
if ! pkg ? outputSpecified || ! pkg.outputSpecified
then pkg.${output} or pkg.out or pkg
else pkg;
getBin = getOutput "bin";
# Simplified by removing various type assertions.
getExe' = x: y: "${getBin x}/bin/${y}";
# getExe is simplified to assume meta.mainProgram is specified.
getExe = x: getExe' x x.meta.mainProgram;
};
in
formatterBySystem:
if formatterBySystem ? ${currentSystem} then
let
formatter = formatterBySystem.${currentSystem};
drv = formatter.drvPath;
bin = lib.getExe formatter;
in
drv + "\n" + bin + "\n"
else
""
]]

cp = vim.system({
"nix",
"--extra-experimental-features",
"nix-command flakes",
"eval",
".#formatter",
"--raw",
"--apply",
eval_nix_formatter,
}, { cwd = params.root }):wait()
if cp.code ~= 0 then
-- Dirty hack to check if the flake actually defines a formatter.
--
-- I cannot for the *life* of me figure out a less hacky way of
-- checking if a flake defines a formatter. Things I've tried:
--
-- - `nix eval . --apply '...'`: This doesn't not give me the flake
-- itself, it gives me the default package.
-- - `builtins.getFlake`: Every incantation I've tried requires
-- `--impure`, which has the performance downside described above.
-- - `nix flake show --json .`: This works, but it can be quite slow:
-- we end up evaluating all outputs, which can take a while for
-- `nixosConfigurations`.
if cp.stderr:find("error: flake .+ does not provide attribute .+ or 'formatter'") then
log:warn("this flake does not define a `nix fmt` entrypoint")
else
log:error(string.format("unable discover 'nix fmt' command. stderr: %s", cp.stderr))
end

return nil
end

if cp.stdout == "" then
log:warn("this flake does not define a formatter for your system: %s", nix_current_system)
return nil
end

-- stdout has 2 lines of output:
-- 1. drv path
-- 2. exe path
local drv_path, nix_fmt_path = cp.stdout:match("([^\n]+)\n([^\n]+)\n")

-- Build the derivation. This ensures that `nix_fmt_path` exists.
cp = vim.system({ "nix", "--extra-experimental-features", "nix-command", "build", "--no-link", drv_path .. "^out" })
:wait()
if cp.code ~= 0 then
log:warn(string.format("unable to build 'nix fmt' entrypoint. stderr: %s", cp.stderr))
return nil
end

return nix_fmt_path
end

return h.make_builtin({
name = "nix flake fmt",
meta = {
url = "https://nix.dev/manual/nix/latest/command-ref/new-cli/nix3-fmt",
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)",
},
method = FORMATTING,
filetypes = {},
generator_opts = {
-- It can take a few moments to find the `nix fmt` entrypoint. The
-- underlying command shouldn't change very often for a given
-- project, so cache it for the project root.
dynamic_command = h.cache.by_bufroot(find_nix_fmt),
args = {
-- `--walk` is specific to treefmt, and this formatter is supposed
-- to be generic.
-- Note: this could get converted to the new `TREEFMT_WALK`
-- environment variable once
-- <https://github.com/numtide/treefmt/commit/bac4a0d102e1142406d3a7d15106e5ba108bfcf8>
-- is fixed, which would at least play nicely with other types of
-- `nix fmt` entrypoints.
--
-- However, IMO, the real fix is to change treefmt itself to be
-- willing to format files passed explicitly, even if they're
-- gitignored:
-- https://github.com/numtide/treefmt/issues/435
"--walk=filesystem",
"$FILENAME",
},
to_temp_file = true,
},
condition = function(utils)
return utils.root_has_file("flake.nix")
end,
factory = h.formatter_factory,
})

0 comments on commit ec16dd4

Please sign in to comment.