Skip to content

Commit 43b7bb5

Browse files
authored
helpers: make dynamic_command async (#197)
1 parent c279e54 commit 43b7bb5

8 files changed

+239
-81
lines changed

doc/HELPERS.md

+25-16
Original file line numberDiff line numberDiff line change
@@ -105,10 +105,11 @@ directory defaults to the project's root.
105105

106106
### dynamic_command
107107

108-
Optional callback to set `command` dynamically. Takes one arguments, a `params`
109-
table. The generator's original command (if set) is available as
110-
`params.command`. The callback should return a string containing the command to
111-
run or `nil`, meaning that no command should run.
108+
Optional callback to set `command` dynamically. Takes two arguments, a `params`
109+
table and a `done` callback. The generator's original command (if set) is
110+
available as `params.command`. The `done` callback should be invoked with a
111+
string containing the command to run or `nil`, meaning that no command should
112+
run.
112113

113114
`dynamic_command` runs every time its parent generator runs and can affect
114115
performance, so it's best to cache its output when possible.
@@ -362,17 +363,25 @@ Helpers used to cache output from callbacks and help improve performance.
362363

363364
### by_bufnr(callback)
364365

365-
Creates a function that caches the result of `callback`, indexed by `bufnr`. On
366-
the first run of the created function, null-ls will call `callback` with a
367-
`params` table. On the next run, it will directly return the cached value
368-
without calling `callback` again.
366+
Creates a function that caches the result of `callback`, indexed by `bufnr`.
367+
`callback` is a function that takes one argument: a `params` table.
369368

370-
This is useful when the return value of `callback` is not expected to change
371-
over the lifetime of the buffer, which works well for `cwd` and
372-
`runtime_condition` callbacks. Users can use it as a simple shortcut to improve
373-
performance, and built-in authors can use it to add logic that would otherwise
374-
be too performance-intensive to include out-of-the-box.
369+
On the first run of the created function, null-ls will invoke `callback`. On the
370+
next run, it will directly return the cached value without calling `callback`
371+
again.
375372

376-
Note that if `callback` returns `nil`, the helper will override the return value
377-
and instead cache `false` (so that it can determine that it already ran
378-
`callback` once and should not run it again).
373+
This is useful when the result of `callback` is not expected to change over the
374+
lifetime of the buffer, which works well for `cwd` and `runtime_condition`
375+
callbacks. Users can use it as a simple shortcut to improve performance, and
376+
built-in authors can use it to add logic that would otherwise be too
377+
performance-intensive to include out-of-the-box.
378+
379+
Note that if `callback` resolved to `nil`, the helper will instead cache `false`
380+
(so that it can determine that it already ran `callback` once and should not run
381+
it again).
382+
383+
### by_bufnr_async(callback)
384+
385+
Like `by_bufnr`, but `callback` is an async function. That is, `callback` is a
386+
function that takes two arguments: a `params` table and a `done` callback that
387+
must be invoked with the result.

lua/null-ls/helpers/cache.lua

+28
Original file line numberDiff line numberDiff line change
@@ -33,4 +33,32 @@ M.by_bufnr = function(cb)
3333
end
3434
end
3535

36+
--- creates a function that caches the output of an async callback, indexed by bufnr
37+
---@param cb function
38+
---@return fun(params: NullLsParams): any
39+
M.by_bufnr_async = function(cb)
40+
-- assign next available key, since we just want to avoid collisions
41+
local key = next_key
42+
M.cache[key] = {}
43+
next_key = next_key + 1
44+
45+
return function(params, done)
46+
local bufnr = params.bufnr
47+
-- if we haven't cached a value yet, get it from cb
48+
if M.cache[key][bufnr] == nil then
49+
-- make sure we always store a value so we know we've already called cb
50+
cb(params, function(result)
51+
M.cache[key][bufnr] = result or false
52+
done(M.cache[key][bufnr])
53+
end)
54+
else
55+
done(M.cache[key][bufnr])
56+
end
57+
end
58+
end
59+
60+
M._reset = function()
61+
M.cache = {}
62+
end
63+
3664
return M

lua/null-ls/helpers/command_resolver.lua

+7-4
Original file line numberDiff line numberDiff line change
@@ -31,23 +31,26 @@ end
3131
M.generic = function(prefix)
3232
---@param params NullLsParams
3333
---@return string|nil
34-
return cache.by_bufnr(function(params)
34+
return cache.by_bufnr_async(function(params, done)
3535
local executable_to_find = prefix and u.path.join(prefix, params.command) or params.command
3636
if not executable_to_find then
37+
done(nil)
3738
return
3839
end
3940

4041
local stop_path = u.get_vcs_root() or u.get_root()
4142
local resolved_executable = search_ancestors_for_executable(params.bufname, stop_path, executable_to_find)
42-
return resolved_executable
43+
done(resolved_executable)
4344
end)
4445
end
4546

4647
--- creates a resolver that searches for a local node_modules executable and falls back to a global executable
4748
M.from_node_modules = function()
4849
local node_modules_resolver = M.generic(u.path.join("node_modules", ".bin"))
49-
return function(params)
50-
return node_modules_resolver(params) or params.command
50+
return function(params, done)
51+
node_modules_resolver(params, function(resolved)
52+
done(resolved or params.command)
53+
end)
5154
end
5255
end
5356

lua/null-ls/helpers/generator_factory.lua

+53-52
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,12 @@ return function(opts)
133133
end
134134
end
135135

136+
if dynamic_command == nil then
137+
dynamic_command = function(params, done)
138+
done(params.command)
139+
end
140+
end
141+
136142
local is_nil_table_or_func = function(v)
137143
return v == nil or vim.tbl_contains({ "function", "table" }, type(v))
138144
end
@@ -279,65 +285,60 @@ return function(opts)
279285

280286
params.command = command
281287

282-
local resolved_command
283-
if dynamic_command then
284-
resolved_command = dynamic_command(params)
285-
else
286-
resolved_command = command
287-
end
288-
289-
-- if dynamic_command returns nil, don't fall back to command
290-
if not resolved_command then
291-
log:debug(string.format("unable to resolve command %s; aborting", command))
292-
return done()
293-
end
288+
dynamic_command(params, function(resolved_command)
289+
-- if dynamic_command returns nil, don't fall back to command
290+
if not resolved_command then
291+
log:debug(string.format("unable to resolve command %s; aborting", command))
292+
return done()
293+
end
294294

295-
local resolved_cwd = cwd and cwd(params) or root
296-
params.cwd = resolved_cwd
295+
local resolved_cwd = cwd and cwd(params) or root
296+
params.cwd = resolved_cwd
297297

298-
if type(env) == "function" then
299-
env = env(params)
300-
end
298+
if type(env) == "function" then
299+
env = env(params)
300+
end
301301

302-
local spawn_opts = {
303-
cwd = resolved_cwd,
304-
input = to_stdin and get_content(params) or nil,
305-
handler = wrapper,
306-
check_exit_code = check_exit_code,
307-
timeout = timeout or c.get().default_timeout,
308-
env = env,
309-
}
310-
311-
if to_temp_file then
312-
local content = get_content(params)
313-
local temp_path, cleanup = loop.temp_file(content, params.bufname, temp_dir or c.get().temp_dir)
314-
315-
spawn_opts.on_stdout_end = function()
316-
if from_temp_file then
317-
params.output = loop.read_file(temp_path)
302+
local spawn_opts = {
303+
cwd = resolved_cwd,
304+
input = to_stdin and get_content(params) or nil,
305+
handler = wrapper,
306+
check_exit_code = check_exit_code,
307+
timeout = timeout or c.get().default_timeout,
308+
env = env,
309+
}
310+
311+
if to_temp_file then
312+
local content = get_content(params)
313+
local temp_path, cleanup = loop.temp_file(content, params.bufname, temp_dir or c.get().temp_dir)
314+
315+
spawn_opts.on_stdout_end = function()
316+
if from_temp_file then
317+
params.output = loop.read_file(temp_path)
318+
end
319+
cleanup()
318320
end
319-
cleanup()
321+
params.temp_path = temp_path
320322
end
321-
params.temp_path = temp_path
322-
end
323-
324-
local resolved_args = args or {}
325-
resolved_args = type(resolved_args) == "function" and resolved_args(params) or resolved_args
326-
resolved_args = parse_args(resolved_args, params)
327-
328-
opts._last_command = resolved_command
329-
opts._last_args = resolved_args
330-
opts._last_cwd = resolved_cwd
331323

332-
log:debug(
333-
string.format(
334-
"spawning command %s at %s with args %s",
335-
vim.inspect(resolved_command),
336-
resolved_cwd,
337-
vim.inspect(resolved_args)
324+
local resolved_args = args or {}
325+
resolved_args = type(resolved_args) == "function" and resolved_args(params) or resolved_args
326+
resolved_args = parse_args(resolved_args, params)
327+
328+
opts._last_command = resolved_command
329+
opts._last_args = resolved_args
330+
opts._last_cwd = resolved_cwd
331+
332+
log:debug(
333+
string.format(
334+
"spawning command %s at %s with args %s",
335+
vim.inspect(resolved_command),
336+
resolved_cwd,
337+
vim.inspect(resolved_args)
338+
)
338339
)
339-
)
340-
loop.spawn(resolved_command, resolved_args, spawn_opts)
340+
loop.spawn(resolved_command, resolved_args, spawn_opts)
341+
end)
341342
end,
342343
filetypes = opts.filetypes,
343344
opts = opts,

lua/null-ls/helpers/make_builtin.lua

+5-3
Original file line numberDiff line numberDiff line change
@@ -98,9 +98,11 @@ local function make_builtin(opts)
9898
local prefix = type(maybe_prefix) == "string" and maybe_prefix or nil
9999
local resolver = cmd_resolver.generic(prefix)
100100

101-
generator_opts.dynamic_command = function(params)
102-
local resolved_command = resolver(params) or (prefer_local and params.command)
103-
return resolved_command
101+
generator_opts.dynamic_command = function(params, done)
102+
resolver(params, function(val)
103+
local resolved_command = val or (prefer_local and params.command)
104+
done(resolved_command)
105+
end)
104106
end
105107
end
106108

test/spec/client_spec.lua

+12
Original file line numberDiff line numberDiff line change
@@ -297,6 +297,18 @@ describe("client", function()
297297
c._set({ on_attach = on_attach })
298298
end)
299299

300+
local api
301+
302+
-- set up setup_buffer conditions
303+
before_each(function()
304+
api = mock(vim.api, true)
305+
api.nvim_get_option_value.returns("")
306+
sources.get.returns({})
307+
end)
308+
after_each(function()
309+
mock.revert(api)
310+
end)
311+
300312
it("should do nothing if no client", function()
301313
client.setup_buffer(mock_bufnr)
302314

test/spec/helpers/cache_spec.lua

+103
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,22 @@
11
local stub = require("luassert.stub")
22

3+
function call_sync(async_fn, arg)
4+
local co = coroutine.running()
5+
assert(co, "not running inside a coroutine")
6+
7+
local val = nil
8+
async_fn(
9+
arg,
10+
vim.schedule_wrap(function(result)
11+
val = result
12+
coroutine.resume(co)
13+
end)
14+
)
15+
coroutine.yield()
16+
17+
return val
18+
end
19+
320
describe("cache", function()
421
local cache = require("null-ls.helpers").cache
522
after_each(function()
@@ -80,4 +97,90 @@ describe("cache", function()
8097
assert.stub(mock_cb).was_called(2)
8198
end)
8299
end)
100+
101+
describe("by_bufnr_async", function()
102+
local mock_params = { bufnr = 1 }
103+
local mock_val = "mock_val"
104+
local invoked_params = {}
105+
local mock_cb = function(params, done)
106+
vim.defer_fn(function()
107+
table.insert(invoked_params, params)
108+
done(mock_val)
109+
end, 0)
110+
end
111+
after_each(function()
112+
mock_val = "mock_val"
113+
invoked_params = {}
114+
end)
115+
116+
it("should call cb with params", function()
117+
local fn = cache.by_bufnr_async(mock_cb)
118+
119+
call_sync(fn, mock_params)
120+
121+
assert.are.same({ mock_params }, invoked_params)
122+
end)
123+
124+
it("should return cb return value", function()
125+
local fn = cache.by_bufnr_async(mock_cb)
126+
127+
local val = call_sync(fn, mock_params)
128+
129+
assert.equals("mock_val", val)
130+
end)
131+
132+
it("should return false if cb returns nil", function()
133+
mock_val = nil
134+
local fn = cache.by_bufnr_async(mock_cb)
135+
136+
local val = call_sync(fn, mock_params)
137+
138+
assert.equals(false, val)
139+
end)
140+
141+
it("should return cached value", function()
142+
local co = coroutine.running()
143+
assert(co, "not running inside a coroutine")
144+
145+
local fn = cache.by_bufnr_async(mock_cb)
146+
147+
local val = call_sync(fn, mock_params)
148+
assert.equals("mock_val", val)
149+
150+
-- Change the return value for the uncached function, and invoke
151+
-- the cached function. We shouldn't see the change.
152+
mock_val = "other_val"
153+
val = call_sync(fn, mock_params)
154+
155+
assert.equals("mock_val", val)
156+
end)
157+
158+
it("should only call cb once if bufnr is the same", function()
159+
local fn = cache.by_bufnr_async(mock_cb)
160+
161+
call_sync(fn, mock_params)
162+
call_sync(fn, mock_params)
163+
164+
assert.are.same({ mock_params }, invoked_params)
165+
end)
166+
167+
it("should only call cb once if cb returns false", function()
168+
mock_val = false
169+
local fn = cache.by_bufnr_async(mock_cb)
170+
171+
call_sync(fn, mock_params)
172+
call_sync(fn, mock_params)
173+
174+
assert.are.same({ mock_params }, invoked_params)
175+
end)
176+
177+
it("should call cb twice if bufnr is different", function()
178+
local fn = cache.by_bufnr_async(mock_cb)
179+
180+
call_sync(fn, mock_params)
181+
call_sync(fn, { bufnr = 2 })
182+
183+
assert.are.same({ mock_params, { bufnr = 2 } }, invoked_params)
184+
end)
185+
end)
83186
end)

0 commit comments

Comments
 (0)