From a56c009aea7e22412d58fe220a424e340a540c53 Mon Sep 17 00:00:00 2001 From: glepnir Date: Sat, 8 Nov 2025 12:39:31 +0800 Subject: [PATCH 1/3] feat: migrate to _async --- Makefile | 12 ++ lua/guard/_async.lua | 110 +++++++++++++++++ lua/guard/format.lua | 283 ++++++++++++++++++++++--------------------- lua/guard/lint.lua | 114 +++++++++++------ lua/guard/spawn.lua | 29 ----- spec/format_spec.lua | 6 +- 6 files changed, 348 insertions(+), 206 deletions(-) create mode 100644 Makefile create mode 100644 lua/guard/_async.lua delete mode 100644 lua/guard/spawn.lua diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..c9092f9 --- /dev/null +++ b/Makefile @@ -0,0 +1,12 @@ +LUAROCKS_PATH_CMD = luarocks path --no-bin --lua-version 5.1 +BUSTED = eval $$(luarocks path --no-bin --lua-version 5.1) && busted --lua nlua +TEST_DIR = spec + +.PHONY: test +test: + @echo "Running tests..." + @if [ -n "$(file)" ]; then \ + $(BUSTED) $(file); \ + else \ + $(BUSTED) $(TEST_DIR); \ + fi diff --git a/lua/guard/_async.lua b/lua/guard/_async.lua new file mode 100644 index 0000000..47f37b6 --- /dev/null +++ b/lua/guard/_async.lua @@ -0,0 +1,110 @@ +local M = {} + +local max_timeout = 30000 +local copcall = package.loaded.jit and pcall or require('coxpcall').pcall + +--- @param thread thread +--- @param on_finish fun(err: string?, ...:any) +--- @param ... any +local function resume(thread, on_finish, ...) + --- @type {n: integer, [1]:boolean, [2]:string|function} + local ret = vim.F.pack_len(coroutine.resume(thread, ...)) + local stat = ret[1] + + if not stat then + -- Coroutine had error + on_finish(ret[2] --[[@as string]]) + elseif coroutine.status(thread) == 'dead' then + -- Coroutine finished + on_finish(nil, unpack(ret, 2, ret.n)) + else + local fn = ret[2] + --- @cast fn -string + + --- @type boolean, string? + local ok, err = copcall(fn, function(...) + resume(thread, on_finish, ...) + end) + + if not ok then + on_finish(err) + end + end +end + +--- @param func async fun(): ...:any +--- @param on_finish? fun(err: string?, ...:any) +function M.run(func, on_finish) + local res --- @type {n:integer, [integer]:any}? + resume(coroutine.create(func), function(err, ...) + res = vim.F.pack_len(err, ...) + if on_finish then + on_finish(err, ...) + end + end) + + return { + --- @param timeout? integer + --- @return any ... return values of `func` + wait = function(_self, timeout) + vim.wait(timeout or max_timeout, function() + return res ~= nil + end) + assert(res, 'timeout') + if res[1] then + error(res[1]) + end + return unpack(res, 2, res.n) + end, + } +end + +--- Asynchronous blocking wait +--- @async +--- @param argc integer +--- @param fun function +--- @param ... any func arguments +--- @return any ... +function M.await(argc, fun, ...) + assert(coroutine.running(), 'Async.await() must be called from an async function') + local args = vim.F.pack_len(...) --- @type {n:integer, [integer]:any} + + --- @param callback fun(...:any) + return coroutine.yield(function(callback) + args[argc] = assert(callback) + fun(unpack(args, 1, math.max(argc, args.n))) + end) +end + +--- @async +--- @param max_jobs integer +--- @param funs (async fun())[] +function M.join(max_jobs, funs) + if #funs == 0 then + return + end + + max_jobs = math.min(max_jobs, #funs) + + --- @type (async fun())[] + local remaining = { select(max_jobs + 1, unpack(funs)) } + local to_go = #funs + + M.await(1, function(on_finish) + local function run_next() + to_go = to_go - 1 + if to_go == 0 then + on_finish() + elseif #remaining > 0 then + local next_fun = table.remove(remaining) + M.run(next_fun, run_next) + end + end + + for i = 1, max_jobs do + M.run(funs[i], run_next) + end + end) +end + +return M diff --git a/lua/guard/format.lua b/lua/guard/format.lua index f8ee2d0..0dff9ef 100644 --- a/lua/guard/format.lua +++ b/lua/guard/format.lua @@ -1,7 +1,9 @@ -local spawn = require('guard.spawn') +local async = require('guard._async') local util = require('guard.util') local filetype = require('guard.filetype') -local api, iter, filter = vim.api, vim.iter, vim.tbl_filter +local api = vim.api + +local M = {} local function save_views(bufnr) local views = {} @@ -25,7 +27,6 @@ local function update_buffer(bufnr, prev_lines, new_lines, srow, erow, old_inden end local views = save_views(bufnr) - -- \r\n for windows compatibility new_lines = vim.split(new_lines, '\r?\n') if new_lines[#new_lines] == '' then new_lines[#new_lines] = nil @@ -43,14 +44,83 @@ local function update_buffer(bufnr, prev_lines, new_lines, srow, erow, old_inden end end +local function emit_event(status, data) + util.doau('GuardFmt', vim.tbl_extend('force', { status = status }, data or {})) +end + local function fail(msg) - util.doau('GuardFmt', { - status = 'failed', - msg = msg, - }) + emit_event('failed', { msg = msg }) vim.notify('[Guard]: ' .. msg, vim.log.levels.WARN) end +---Apply a single pure formatter +---@async +---@param buf number +---@param range table? +---@param config table +---@param fname string +---@param cwd string +---@param input string +---@return string? output +---@return string? error_msg +local function apply_pure_formatter(buf, range, config, fname, cwd, input) + -- Eval dynamic args + local cfg = vim.tbl_extend('force', {}, config) + if type(cfg.args) == 'function' then + cfg.args = cfg.args(buf) + end + + if cfg.fn then + return cfg.fn(buf, range, input), nil + end + + local result = async.await(1, function(callback) + local handle = vim.system(util.get_cmd(cfg, fname, buf), { + stdin = true, + cwd = cwd, + env = cfg.env, + timeout = cfg.timeout, + }, callback) + handle:write(input) + handle:write(nil) + end) + + if result.code ~= 0 and #result.stderr > 0 then + return nil, ('%s exited with code %d\n%s'):format(cfg.cmd, result.code, result.stderr) + end + + return result.stdout, nil +end + +---Apply a single impure formatter +---@async +---@param buf number +---@param config table +---@param fname string +---@param cwd string +---@return string? error_msg +local function apply_impure_formatter(buf, config, fname, cwd) + -- Eval dynamic args + local cfg = vim.tbl_extend('force', {}, config) + if type(cfg.args) == 'function' then + cfg.args = cfg.args(buf) + end + + local result = async.await(1, function(callback) + vim.system(util.get_cmd(cfg, fname, buf), { + text = true, + cwd = cwd, + env = cfg.env or {}, + }, callback) + end) + + if result.code ~= 0 and #result.stderr > 0 then + return ('%s exited with code %d\n%s'):format(cfg.cmd, result.code, result.stderr) + end + + return nil +end + local function do_fmt(buf) buf = buf or api.nvim_get_current_buf() local ft_conf = filetype[vim.bo[buf].filetype] @@ -60,7 +130,7 @@ local function do_fmt(buf) return end - -- get format range + -- Get format range local srow, erow = 0, -1 local range = nil local mode = api.nvim_get_mode().mode @@ -70,177 +140,116 @@ local function do_fmt(buf) erow = range['end'][1] end - -- best effort indent preserving - ---@type number? - local old_indent - if mode == 'V' then - old_indent = vim.fn.indent(srow + 1) - end + local old_indent = (mode == 'V') and vim.fn.indent(srow + 1) or nil - -- init environment - ---@type FmtConfig[] + -- Get and filter configs local fmt_configs = util.eval(ft_conf.formatter) local fname, cwd = util.buf_get_info(buf) - -- handle execution condition - fmt_configs = filter(function(config) + fmt_configs = vim.tbl_filter(function(config) return util.should_run(config, buf) end, fmt_configs) - -- check if all cmds executable again, since user can call format manually - local all_executable = not iter(fmt_configs):any(function(config) + -- Check executability + for _, config in ipairs(fmt_configs) do if config.cmd and vim.fn.executable(config.cmd) ~= 1 then util.report_error(config.cmd .. ' not executable') - return true + return end - return false - end) - - if not all_executable then - return end - -- filter out "pure" and "impure" formatters - local pure = iter(filter(function(config) + -- Classify formatters + local pure = vim.tbl_filter(function(config) return config.fn or (config.cmd and config.stdin) - end, fmt_configs)) - local impure = iter(filter(function(config) + end, fmt_configs) + + local impure = vim.tbl_filter(function(config) return config.cmd and not config.stdin - end, fmt_configs)) + end, fmt_configs) - -- error if one of the formatters is impure and the user requested range formatting - if range and #impure:totable() > 0 then + -- Check range formatting compatibility + if range and #impure > 0 then + local impure_cmds = vim.tbl_map(function(c) + return c.cmd + end, impure) util.report_error('Cannot apply range formatting for filetype ' .. vim.bo[buf].filetype) - util.report_error(impure - :map(function(config) - return config.cmd - end) - :join(', ') .. ' does not support reading from stdin') + util.report_error(table.concat(impure_cmds, ', ') .. ' does not support reading from stdin') return end - -- actually start formatting - util.doau('GuardFmt', { - status = 'pending', - using = fmt_configs, - }) - - local prev_lines = table.concat(api.nvim_buf_get_lines(buf, srow, erow, false), '\n') - local new_lines = prev_lines - local errno = nil - - coroutine.resume(coroutine.create(function() - local changedtick = -1 - -- defer initialization, since BufWritePre would trigger a tick change - vim.schedule(function() - changedtick = api.nvim_buf_get_changedtick(buf) - end) - new_lines = pure:fold(new_lines, function(acc, config) - -- check if we are in a valid state + emit_event('pending', { using = fmt_configs }) + + async.run(function() + -- Initialize changedtick BEFORE any formatting (explicitly wait) + local changedtick = async.await(1, function(callback) vim.schedule(function() - if api.nvim_buf_get_changedtick(buf) ~= changedtick then - errno = { reason = 'buffer changed' } - end + callback(api.nvim_buf_get_changedtick(buf)) end) - if errno then - return '' - end - - -- NB: we rely on the `fn` and spawn.transform to yield the coroutine - if config.fn then - return config.fn(buf, range, acc) - else - local result = spawn.transform(util.get_cmd(config, fname, buf), cwd, config, acc) - if type(result) == 'table' then - -- indicates error - errno = result - errno.reason = config.cmd .. ' exited with errors' - errno.cmd = config.cmd - return '' - else - ---@diagnostic disable-next-line: return-type-mismatch - return result - end - end end) - local co = assert(coroutine.running()) + local prev_lines = api.nvim_buf_get_lines(buf, srow, erow, false) + local new_lines = table.concat(prev_lines, '\n') - vim.schedule(function() - -- handle errors - if errno then - if errno.reason:match('exited with errors$') then - fail(('%s exited with code %d\n%s'):format(errno.cmd, errno.code, errno.stderr)) - elseif errno.reason == 'buf changed' then - fail('buffer changed during formatting') - else - fail(errno.reason) - end + -- Apply pure formatters sequentially + for _, config in ipairs(pure) do + local output, err = apply_pure_formatter(buf, range, config, fname, cwd, new_lines) + if err then + fail(err) return end - -- check buffer one last time - if api.nvim_buf_get_changedtick(buf) ~= changedtick then - fail('buffer changed during formatting') - end - if not api.nvim_buf_is_valid(buf) then - fail('buffer no longer valid') - return - end - update_buffer(buf, prev_lines, new_lines, srow, erow, old_indent) - coroutine.resume(co) - end) - - -- wait until substitution is finished - coroutine.yield() + new_lines = output + end - if impure and #impure:totable() > 0 then - impure:each(function(config) - if errno then + async.await(1, function(callback) + vim.schedule(function() + if not api.nvim_buf_is_valid(buf) then + fail('buffer no longer valid') + callback() return end - vim.system(util.get_cmd(config, fname, buf), { - text = true, - cwd = cwd, - env = config.env or {}, - }, function(result) - if result.code ~= 0 and #result.stderr > 0 then - errno = result - ---@diagnostic disable-next-line: inject-field - errno.cmd = config.cmd - coroutine.resume(co) - else - coroutine.resume(co) - end - end) + if api.nvim_buf_get_changedtick(buf) ~= changedtick then + fail('buffer changed during formatting') + callback() + return + end - coroutine.yield() + update_buffer(buf, prev_lines, new_lines, srow, erow, old_indent) + callback() end) + end) - if errno then - fail(('%s exited with code %d\n%s'):format(errno.cmd, errno.code, errno.stderr)) + -- Apply impure formatters sequentially + for _, config in ipairs(impure) do + local err = apply_impure_formatter(buf, config, fname, cwd) + if err then + fail(err) return end + end - -- refresh buffer - vim.schedule(function() - api.nvim_buf_call(buf, function() - local views = save_views(buf) - api.nvim_command('silent! edit!') - restore_views(views) + -- Refresh buffer if impure formatters were used + if #impure > 0 then + async.await(1, function(callback) + vim.schedule(function() + api.nvim_buf_call(buf, function() + local views = save_views(buf) + api.nvim_command('silent! edit!') + restore_views(views) + end) + callback() end) end) end - util.doau('GuardFmt', { - status = 'done', - }) + emit_event('done') + if util.getopt('refresh_diagnostic') then vim.diagnostic.show() end - end)) + end) end -return { - do_fmt = do_fmt, -} +M.do_fmt = do_fmt + +return M diff --git a/lua/guard/lint.lua b/lua/guard/lint.lua index c7d5d55..a37cca5 100644 --- a/lua/guard/lint.lua +++ b/lua/guard/lint.lua @@ -1,6 +1,6 @@ +local async = require('guard._async') local api = vim.api local util = require('guard.util') -local spawn = require('guard.spawn') local vd = vim.diagnostic local ft = require('guard.filetype') @@ -8,10 +8,43 @@ local M = {} local ns = api.nvim_create_namespace('Guard') local custom_ns = {} +---Execute command with stdin for linting +---@async +---@param cmd string[] +---@param cwd string +---@param config {env: table?, timeout: integer?} +---@param input string|string[] +---@return string output +---@return {code: integer, stderr: string, cmd: string}? error +local function exec_linter(cmd, cwd, config, input) + local result = async.await(1, function(callback) + local handle = vim.system(cmd, { + stdin = true, + cwd = cwd, + env = config.env, + timeout = config.timeout, + }, callback) + if type(input) == 'table' then + input = table.concat(input, '\n') + end + handle:write(input) + handle:write(nil) + end) + + if result.code ~= 0 and #result.stderr > 0 then + return '', { + code = result.code, + stderr = result.stderr, + cmd = cmd[1], + } + end + + return result.stdout, nil +end + ---@param buf number? function M.do_lint(buf) buf = buf or api.nvim_get_current_buf() - ---@type LintConfig[] local linters = util.eval( vim.tbl_map( @@ -24,12 +57,12 @@ function M.do_lint(buf) return util.should_run(config, buf) end, linters) - coroutine.resume(coroutine.create(function() + async.run(function() vd.reset(ns, buf) - vim.iter(linters):each(function(linter) + for _, linter in ipairs(linters) do M.do_lint_single(buf, linter) - end) - end)) + end + end) end ---@param buf number @@ -38,7 +71,6 @@ function M.do_lint_single(buf, config) local lint = util.eval1(config) local custom = config.events ~= nil - -- check run condition local fname, cwd = util.buf_get_info(buf) if not util.should_run(lint, buf) then return @@ -55,39 +87,50 @@ function M.do_lint_single(buf, config) end local results = {} - ---@type string - local data + local data = '' if lint.cmd then - local out = spawn.transform(util.get_cmd(lint, fname, buf), cwd, lint, prev_lines) - - -- TODO: unify this error handling logic with formatter - if type(out) == 'table' then - -- indicates error - vim.notify( - '[Guard]: ' .. ('%s exited with code %d\n%s'):format(out.cmd, out.code, out.stderr), - vim.log.levels.WARN - ) - data = '' - else - data = out - end - else - data = lint.fn(prev_lines) - end + async.run(function() + local out, err = exec_linter(util.get_cmd(lint, fname, buf), cwd, lint, prev_lines) - if #data > 0 then - results = lint.parse(data, buf) - end + if err then + vim.notify( + '[Guard]: ' .. ('%s exited with code %d\n%s'):format(err.cmd, err.code, err.stderr), + vim.log.levels.WARN + ) + data = '' + else + data = out + end - vim.schedule(function() - if api.nvim_buf_is_valid(buf) and #results ~= 0 then - if not custom then - vim.list_extend(results, vd.get(buf)) + if #data > 0 then + results = lint.parse(data, buf) end - vd.set(cns, buf, results) + + vim.schedule(function() + if api.nvim_buf_is_valid(buf) and #results ~= 0 then + if not custom then + vim.list_extend(results, vd.get(buf)) + end + vd.set(cns, buf, results) + end + end) + end) + else + data = lint.fn(prev_lines) + if #data > 0 then + results = lint.parse(data, buf) end - end) + + vim.schedule(function() + if api.nvim_buf_is_valid(buf) and #results ~= 0 then + if not custom then + vim.list_extend(results, vd.get(buf)) + end + vd.set(cns, buf, results) + end + end) + end end ---@param buf number @@ -175,7 +218,6 @@ function M.from_json(opts) local diags, offences = {}, {} if opts.lines then - -- \r\n for windows compatibility vim.tbl_map(function(line) local offence = opts.get_diagnostics(line) if offence then @@ -216,7 +258,6 @@ function M.from_regex(opts) return function(result, buf) local diags, offences = {}, {} - -- \r\n for windows compatibility local lines = vim.split(result, '\r?\n', { trimempty = true }) for _, line in ipairs(lines) do @@ -224,7 +265,6 @@ function M.from_regex(opts) local matches = { line:match(opts.regex) } - -- regex matched if #matches == #opts.groups then for i = 1, #opts.groups do offence[opts.groups[i]] = matches[i] diff --git a/lua/guard/spawn.lua b/lua/guard/spawn.lua deleted file mode 100644 index a511120..0000000 --- a/lua/guard/spawn.lua +++ /dev/null @@ -1,29 +0,0 @@ -local M = {} - ----@param cmd string[] ----@param cwd string ----@param config FmtConfigTable|LintConfigTable ----@param lines string|string[] ----@return table | string -function M.transform(cmd, cwd, config, lines) - local co = assert(coroutine.running()) - local handle = vim.system(cmd, { - stdin = true, - cwd = cwd, - env = config.env, - timeout = config.timeout, - }, function(result) - if result.code ~= 0 and #result.stderr > 0 then - -- error - coroutine.resume(co, result) - else - coroutine.resume(co, result.stdout) - end - end) - -- write to stdin and close it - handle:write(lines) - handle:write(nil) - return coroutine.yield() -end - -return M diff --git a/spec/format_spec.lua b/spec/format_spec.lua index 17c52d9..328d197 100644 --- a/spec/format_spec.lua +++ b/spec/format_spec.lua @@ -111,12 +111,12 @@ describe('format module', function() it('can format with dynamic arguments', function() ft('lua'):fmt({ - cmd = 'cat', + cmd = 'sed', args = function(_) if vim.g.blah then - return { '-E' } + return { '1s/$/\\$/' } else - return nil + return { '' } end end, stdin = true, From ccfe35f4ef707fc639f4565c15172fa380d9f68e Mon Sep 17 00:00:00 2001 From: glepnir Date: Sat, 8 Nov 2025 12:42:28 +0800 Subject: [PATCH 2/3] fix: remove spawn_spec.lua --- lua/guard/format.lua | 1 + spec/spawn_spec.lua | 14 -------------- 2 files changed, 1 insertion(+), 14 deletions(-) delete mode 100644 spec/spawn_spec.lua diff --git a/lua/guard/format.lua b/lua/guard/format.lua index 0dff9ef..b6713a2 100644 --- a/lua/guard/format.lua +++ b/lua/guard/format.lua @@ -111,6 +111,7 @@ local function apply_impure_formatter(buf, config, fname, cwd) text = true, cwd = cwd, env = cfg.env or {}, + timeout = cfg.timeout, }, callback) end) diff --git a/spec/spawn_spec.lua b/spec/spawn_spec.lua deleted file mode 100644 index da1609c..0000000 --- a/spec/spawn_spec.lua +++ /dev/null @@ -1,14 +0,0 @@ ----@diagnostic disable: undefined-field, undefined-global -local spawn = require('guard.spawn') -local same = assert.are.same - -describe('spawn module', function() - it('can spawn executables with stdin access', function() - coroutine.resume(coroutine.create(function() - local result = spawn.transform({ 'tac', '-s', ' ' }, { - stdin = true, - }, 'test1 test2 test3 ') - same(result, 'test3 test2 test1 ') - end)) - end) -end) From ee206e686c981bed8ef778dd6dc147d1cf71f364 Mon Sep 17 00:00:00 2001 From: glepnir Date: Sat, 8 Nov 2025 13:07:23 +0800 Subject: [PATCH 3/3] fixup! Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- spec/format_spec.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spec/format_spec.lua b/spec/format_spec.lua index 328d197..cc173db 100644 --- a/spec/format_spec.lua +++ b/spec/format_spec.lua @@ -116,7 +116,7 @@ describe('format module', function() if vim.g.blah then return { '1s/$/\\$/' } else - return { '' } + return { 's/^//' } end end, stdin = true,