Skip to content

Commit

Permalink
WIP/RFC: dynamic_command is now async, and runs immediately when op…
Browse files Browse the repository at this point in the history
…ening buffers

This implements <#193>.

This is incomplete: I've redefined `dynamic_command` to be an async
function, but I haven't actually updated all the builtins accordingly. I
have updated `nix_flake_fmt` to demonstrate the idea.

If folks like the direction I'm going with this, I'll polish up
the PR so all `dynamic_command`s are async, and I'll get the tests
passing.
  • Loading branch information
jfly committed Oct 11, 2024
1 parent ad22890 commit 5328903
Show file tree
Hide file tree
Showing 4 changed files with 233 additions and 145 deletions.
218 changes: 135 additions & 83 deletions lua/null-ls/builtins/formatting/nix_flake_fmt.lua
Original file line number Diff line number Diff line change
@@ -1,9 +1,20 @@
local h = require("null-ls.helpers")
local methods = require("null-ls.methods")
local log = require("null-ls.logger")
local client = require("null-ls.client")
local async = require("plenary.async")
local Job = require("plenary.job")

local FORMATTING = methods.internal.FORMATTING

local run_job = async.wrap(function(opts, done)
opts.on_exit = function(j, status)
done(status, j:result(), j:stderr_result())
end

Job:new(opts):start()
end, 2)

--- Return the command that `nix fmt` would run, or nil if we're not in a
--- flake.
---
Expand All @@ -18,95 +29,136 @@ local FORMATTING = methods.internal.FORMATTING
--- 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", "eval", "--impure", "--expr", "builtins.currentSystem"}):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

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
local find_nix_fmt = function(opts, done)
async.run(function()
local title = "discovering `nix fmt` entrypoint"
local progress_token = "nix-flake-fmt-discovery"

client.send_progress_notification(progress_token, {
kind = "begin",
title = title,
})

local root = opts.root

-- 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 status, stdout_lines, stderr_lines = run_job({
command = "nix",
args = { "eval", "--impure", "--expr", "builtins.currentSystem" },
})

if status ~= 0 then
local stderr = table.concat(stderr_lines, "\n")
log:warn(string.format("unable to discover builtins.currentSystem from nix. stderr: %s", stderr))
return nil
end

local nix_current_system_expr = stdout_lines[1]

local eval_nix_formatter = [[
let
formatter = formatterBySystem.${currentSystem};
drv = formatter.drvPath;
bin = lib.getExe formatter;
currentSystem = ]] .. nix_current_system_expr .. [[;
# 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
drv + "\n" + bin + "\n"
else
""
]]

cp = vim.system(
{ 'nix', '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))
formatterBySystem:
if formatterBySystem ? ${currentSystem} then
let
formatter = formatterBySystem.${currentSystem};
drv = formatter.drvPath;
bin = lib.getExe formatter;
in
drv + "\n" + bin + "\n"
else
""
]]

client.send_progress_notification(progress_token, {
kind = "report",
title = title,
message = "evaluating",
})
status, stdout_lines, stderr_lines = run_job({
command = "nix",
args = { "eval", ".#formatter", "--raw", "--apply", eval_nix_formatter },
cwd = root,
})

if status ~= 0 then
local stderr = table.concat(stderr_lines, "\n")
-- 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 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", stderr))
end

return nil
end

return nil
end
if #stdout_lines == 0 then
log:warn("this flake does not define a formatter for your system: %s", nix_current_system_expr)
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 = unpack(stdout_lines)

-- 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.
client.send_progress_notification(progress_token, {
kind = "report",
title = title,
message = "building",
})
status, stdout_lines, stderr_lines = run_job({
command = "nix",
args = { "build", "--no-link", drv_path .. "^out" },
})

-- Build the derivation. This ensures that `nix_fmt_path` exists.
cp = vim.system({ 'nix', '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
if status ~= 0 then
log:warn(string.format("unable to build 'nix fmt' entrypoint. stderr: %s", job:stderr_results()))
return nil
end

client.send_progress_notification(progress_token, {
kind = "end",
title = title,
message = "done",
})

return nix_fmt_path
done(nix_fmt_path)
end)
end

return h.make_builtin({
Expand Down Expand Up @@ -135,8 +187,8 @@ return h.make_builtin({
-- willing to format files passed explicitly, even if they're
-- gitignored:
-- https://github.com/numtide/treefmt/issues/435
'--walk=filesystem',
'$FILENAME',
"--walk=filesystem",
"$FILENAME",
},
to_temp_file = true,
},
Expand Down
9 changes: 9 additions & 0 deletions lua/null-ls/client.lua
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,15 @@ M.setup_buffer = function(bufnr)
return
end

-- Notify each generator for this filetype. This gives them a chance to
-- precompute information.
local filetype = api.nvim_get_option_value("filetype", { buf = bufnr })
for k, source in ipairs(require("null-ls.sources").get({ filetype = filetype })) do
source.generator.setup_buffer(bufnr, function()
-- Nothing to do here.
end)
end

local on_attach = c.get().on_attach
if on_attach then
on_attach(client, bufnr)
Expand Down
32 changes: 22 additions & 10 deletions lua/null-ls/helpers/cache.lua
Original file line number Diff line number Diff line change
Expand Up @@ -4,45 +4,57 @@ local M = {}

M.cache = {}

---@class NullLsCacheParams
---@field bufnr number
---@field root string?

--- >>> TODO: update all users to be async <<<
--- creates a function that caches the output of a callback, indexed by bufnr
---@param cb function
---@return fun(params: NullLsParams): any
---@return fun(params: NullLsCacheParams): any
M.by_bufnr = 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)
return function(params, done)
local bufnr = params.bufnr
-- if we haven't cached a value yet, get it from cb
if M.cache[key][bufnr] == nil then
-- make sure we always store a value so we know we've already called cb
M.cache[key][bufnr] = cb(params) or false
cb(params, function(result)
M.cache[key][bufnr] = result or false
done(M.cache[key][bufnr])
end)
else
done(M.cache[key][bufnr])
end

return M.cache[key][bufnr]
end
end

--- creates a function that caches the output of a callback, indexed by project root
---@param cb function
---@return fun(params: NullLsParams): any
---@param done function
---@return fun(params: NullLsCacheParams): 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)
return function(params, done)
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
cb(params, function(result)
M.cache[key][root] = result or false
done(M.cache[key][root])
end)
else
done(M.cache[key][root])
end

return M.cache[key][root]
end
end

Expand Down
Loading

0 comments on commit 5328903

Please sign in to comment.