Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add "nix flake fmt" builtin formatter #192

Draft
wants to merge 5 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 18 additions & 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 Expand Up @@ -376,3 +376,20 @@ be too performance-intensive to include out-of-the-box.
Note that if `callback` returns `nil`, the helper will override the return value
and instead cache `false` (so that it can determine that it already ran
`callback` once and should not run it again).

### by_bufroot(callback)

Creates a function that caches the result of `callback`, indexed by `root`. On
the first run of the created function, null-ls will call `callback` with a
`params` table. On the next run, it will directly return the cached value
without calling `callback` again.

This is useful when the return value of `callback` is not expected to change
over the lifetime of the buffer, which works well for `cwd` and
`runtime_condition` callbacks. Users can use it as a simple shortcut to improve
performance, and built-in authors can use it to add logic that would otherwise
be too performance-intensive to include out-of-the-box.

Note that if `callback` returns `nil`, the helper will override the return value
and instead cache `false` (so that it can determine that it already ran
`callback` once and should not run it again).
151 changes: 151 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,151 @@
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 with a formatter, or if we fail to discover the formatter somehow.
---
--- 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",
jfly marked this conversation as resolved.
Show resolved Hide resolved
"--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
Comment on lines +77 to +93

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't know of any better way to check if a flake exposes an attr. but can't we just forward the error as warning in any case this fails and hope that nix provides a good enough warning so we don't need to handle it explicitly?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I could, but how would I choose the appropriate log level (warn vs error)?

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

probably warn everytime?


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",
"--out-link",
vim.fn.tempname(),
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 = {
-- Needed until <https://github.com/numtide/treefmt/issues/442> is
-- fixed, and a version of treefmt is released with the fix.
"--walk=filesystem",
"$FILENAME",
},
to_temp_file = true,
},
condition = function(utils)
return utils.root_has_file("flake.nix")
end,
factory = h.formatter_factory,
})
21 changes: 21 additions & 0 deletions lua/null-ls/helpers/cache.lua
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,27 @@ M.by_bufnr = function(cb)
end
end

--- creates a function that caches the output of a callback, indexed by project root
---@param cb function
---@return fun(params: NullLsParams): any
M.by_bufroot = function(cb)
-- assign next available key, since we just want to avoid collisions
local key = next_key
M.cache[key] = {}
next_key = next_key + 1

return function(params)
local root = params.root
-- if we haven't cached a value yet, get it from cb
if M.cache[key][root] == nil then
-- make sure we always store a value so we know we've already called cb
M.cache[key][root] = cb(params) or false
end

return M.cache[key][root]
jfly marked this conversation as resolved.
Show resolved Hide resolved
end
end

M._reset = function()
M.cache = {}
end
Expand Down
Loading