From 6c3b3cb4e61446fa74ccec1a22f300efe541838a Mon Sep 17 00:00:00 2001 From: xiaoshihou <108414369+xiaoshihou514@users.noreply.github.com> Date: Sun, 21 Jul 2024 14:54:05 +0800 Subject: [PATCH] ref!: major refactor (#150) closes #68 #86 #123 #109 #76 #141 #111 ## changes - [x] remove uv.spawn wrapper in favour of vim.system - [x] `do_lint` now respects *all* config fields - [x] linter can now use lnum_end and col_end - [x] added step by step tutorial for advanced usage - [x] remove all version checking code, only supports 0.10+ from now on - [x] apply exepath fix for windows ## internal changes - [x] events now contains truly all autocmd related functions - [x] utils now contains execution checking functions - [x] use custom simpler table copy --- .editorconfig | 3 + .github/workflows/ci.yml | 3 +- ADVANCED.md | 189 ++++++++++++++++++++++++- README.md | 31 +++-- doc/guard.nvim.txt | 2 +- lua/guard/events.lua | 118 +++++++++++++--- lua/guard/filetype.lua | 19 ++- lua/guard/format.lua | 294 ++++++++++++++++++++++----------------- lua/guard/health.lua | 10 +- lua/guard/init.lua | 13 +- lua/guard/lint.lua | 161 ++++++++++----------- lua/guard/lsp.lua | 47 +++++++ lua/guard/spawn.lua | 130 +++-------------- lua/guard/util.lua | 148 ++++++++++++++++---- plugin/guard.lua | 14 +- test/autocmd_spec.lua | 9 +- test/command_spec.lua | 71 ++++++++++ test/filetype_spec.lua | 120 +++++++++------- test/format_spec.lua | 61 ++++++-- test/spawn_spec.lua | 16 +-- test/util_spec.lua | 20 +++ 21 files changed, 986 insertions(+), 493 deletions(-) create mode 100644 .editorconfig create mode 100644 lua/guard/lsp.lua create mode 100644 test/command_spec.lua create mode 100644 test/util_spec.lua diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..9a6cc75 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,3 @@ +[*.lua] +indent_style = space +indent_size = 2 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cfd1794..72886c1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,7 +14,6 @@ jobs: version: latest args: --check . - docs: runs-on: ubuntu-latest name: pandoc to vimdoc @@ -28,7 +27,7 @@ jobs: treesitter: true - uses: stefanzweifel/git-auto-commit-action@v4 with: - commit_message: 'chore(doc): auto generate docs' + commit_message: "chore(doc): auto generate docs" commit_user_name: "github-actions[bot]" commit_user_email: "github-actions[bot]@users.noreply.github.com" commit_author: "github-actions[bot] " diff --git a/ADVANCED.md b/ADVANCED.md index bf88608..1ff4c77 100644 --- a/ADVANCED.md +++ b/ADVANCED.md @@ -1,5 +1,187 @@ # Advanced tips +## Special case formatting logic + +With the introduction of `vim.system` in neovim 0.10, it is now easy to write custom formatting logic. Let's work through an example of how you could do so. + +`prettierd` does not work well with guard because of it's error mechanism. Guard follows a reasonable unix standard when it comes to determining exit status, that is, assuming the program would exit with non-zero exit code and print some reasonable error message in stderr: + +```lua +if exit_code ~= 0 and num_stderr_chunks ~= 0 then + -- failed +else + -- success +end +``` + +However, `prettierd` prints error messages to stdout, so guard will fail to detect an error and proceed to replace your code with its error message :cry: + +But fear not! You can create your custom logic by passing a function in the config table, let's do this step by step: + +```lua +local function prettierd_fmt(buf, range, acc) + local co = assert(coroutine.running()) +end +``` + +Guard runs the format function in a coroutine so as not to block the UI, to achieve what we want we have to interact with the current coroutine. + +We can now go on to mimic how we would call `prettierd` on the cmdline: + +``` +cat test.js | prettierd test.js +``` + +```lua +-- previous code omitted +local handle = vim.system({ "prettierd", vim.api.nvim_buf_get_name(buf) }, { + stdin = true, +}, function(result) + if result.code ~= 0 then + -- "returns" the error + coroutine.resume(co, result) + else + -- "returns" the result + coroutine.resume(co, result.stdout) + end +end) +``` + +We get the unformatted code, then call `vim.system` with 3 arguments + +- the cmd, which is of the form `prettierd ` +- the option table, here we only specified that we wish to write to its stdin, but you can refer to `:h vim.system` for more options +- the `at_exit` function, which takes in a result table (again, check out `:h vim.system` for more details) + +Now we can do our custom error handling, here we simply return if `prettierd` failed. But if it succeeded we replace the range with the formatted code and save the file. + +Finally we write the unformatted code to stdin + +```lua +-- previous code omitted +handle:write(acc) +handle:write(nil) -- closes stdin +return coroutine.yield() -- this returns either the error or the formatted code we returned earlier +``` + +Whoa la! Now we can tell guard to register our formatting function + +```lua +ft("javascript"):fmt({ + fn = prettierd_fmt +}) +``` + +[demo](https://github.com/xiaoshihou514/guard.nvim/assets/108414369/56dd35d4-8bf6-445a-adfd-8786fb461021) + +You can always refer to [spawn.lua](https://github.com/nvimdev/guard.nvim/blob/main/lua/guard/spawn.lua). + +## Custom logic with linters + +`clippy-driver` is a linter for rust, because it prints diagnostics to stderr, you cannot just specify it the usual way. But guard allows you to pass a custom function, which would make it work :) + +Let's start by doing some imports: + +```lua +local ft = require("guard.filetype") +local lint = require("guard.lint") +``` + +The lint function is a simple modification of the one in [spawn.lua](https://github.com/nvimdev/guard.nvim/blob/main/lua/guard/spawn.lua). + +```lua +local function clippy_driver_lint(acc) + local co = assert(coroutine.running()) + local handle = vim.system({ "clippy-driver", "-", "--error-format=json", "--edition=2021" }, { + stdin = true, + }, function(result) + -- wake coroutine on exit, omit error checking + coroutine.resume(co, result.stderr) + end) + -- write contents to stdin and close it + handle:write(acc) + handle:write(nil) + -- sleep until awakened after process finished + return coroutine.yield() +end +``` + +We register it via guard: + +```lua +ft("rust"):lint({ + fn = clippy_driver_lint, + stdin = true, + parse = clippy_diagnostic_parse, -- TODO! +}) +``` + +To write the lint function, we inspect the output of `clippy-driver` when called with the arguments above: + +
+full output + +``` +❯ cat test.rs +fn main() { + let _ = 'a' .. 'z'; + if 42 > i32::MAX {} +} +❯ cat test.rs | clippy-driver - --error-format=json --edition=2021 +{"$message_type":"diagnostic","message":"almost complete ascii range","code":{"code":"clippy::almost_complete_range","explanation":null},"level":"warning","spans":[{"file_name":"","byte_start":24,"byte_end":34,"line_start":2,"line_end":2,"column_start":13,"column_end":23,"is_primary":true,"text":[{"text":" let _ = 'a' .. 'z';","highlight_start":13,"highlight_end":23}],"label":null,"suggested_replacement":null,"suggestion_applicability":null,"expansion":null}],"children":[{"message":"for further information visit https://rust-lang.github.io/rust-clippy/master/index.html#almost_complete_range","code":null,"level":"help","spans":[],"children":[],"rendered":null},{"message":"`#[warn(clippy::almost_complete_range)]` on by default","code":null,"level":"note","spans":[],"children":[],"rendered":null},{"message":"use an inclusive range","code":null,"level":"help","spans":[{"file_name":"","byte_start":28,"byte_end":30,"line_start":2,"line_end":2,"column_start":17,"column_end":19,"is_primary":true,"text":[{"text":" let _ = 'a' .. 'z';","highlight_start":17,"highlight_end":19}],"label":null,"suggested_replacement":"..=","suggestion_applicability":"MaybeIncorrect","expansion":null}],"children":[],"rendered":null}],"rendered":"warning: almost complete ascii range\n --> :2:13\n |\n2 | let _ = 'a' .. 'z';\n | ^^^^--^^^^\n | |\n | help: use an inclusive range: `..=`\n |\n = help: for further information visit https://rust-lang.github.io/rust-clippy/master/index.html#almost_complete_range\n = note: `#[warn(clippy::almost_complete_range)]` on by default\n\n"} +{"$message_type":"diagnostic","message":"this comparison involving the minimum or maximum element for this type contains a case that is always true or always false","code":{"code":"clippy::absurd_extreme_comparisons","explanation":null},"level":"error","spans":[{"file_name":"","byte_start":43,"byte_end":56,"line_start":3,"line_end":3,"column_start":8,"column_end":21,"is_primary":true,"text":[{"text":" if 42 > i32::MAX {}","highlight_start":8,"highlight_end":21}],"label":null,"suggested_replacement":null,"suggestion_applicability":null,"expansion":null}],"children":[{"message":"because `i32::MAX` is the maximum value for this type, this comparison is always false","code":null,"level":"help","spans":[],"children":[],"rendered":null},{"message":"for further information visit https://rust-lang.github.io/rust-clippy/master/index.html#absurd_extreme_comparisons","code":null,"level":"help","spans":[],"children":[],"rendered":null},{"message":"`#[deny(clippy::absurd_extreme_comparisons)]` on by default","code":null,"level":"note","spans":[],"children":[],"rendered":null}],"rendered":"error: this comparison involving the minimum or maximum element for this type contains a case that is always true or always false\n --> :3:8\n |\n3 | if 42 > i32::MAX {}\n | ^^^^^^^^^^^^^\n |\n = help: because `i32::MAX` is the maximum value for this type, this comparison is always false\n = help: for further information visit https://rust-lang.github.io/rust-clippy/master/index.html#absurd_extreme_comparisons\n = note: `#[deny(clippy::absurd_extreme_comparisons)]` on by default\n\n"} +{"$message_type":"diagnostic","message":"aborting due to 1 previous error; 1 warning emitted","code":null,"level":"error","spans":[],"children":[],"rendered":"error: aborting due to 1 previous error; 1 warning emitted\n\n"} +``` + +
+ +That's a lot of output! But we can see three main blocks: the first two diagnostics and the last one an overview. We only need the first two: + +```lua +local clippy_diagnostic_parse = + parse = lint.from_json({ + get_diagnostics = function(line) + local json = vim.json.decode(line) + -- ignore overview json which does not have position info + if not vim.tbl_isempty(json.spans) then + return json + end + end, + lines = true, + attributes = { ... } -- TODO + }) +``` + +Now our diagnostics are transformed into a list of json, we just need to get the attributes we need: the positions, the message and the error level. That's what the attributes field does, it extracts them from the json table: + +```lua +attributes = { + -- it is json turned into a lua table + lnum = function(it) + -- clippy has really weird indexes + return math.ceil(tonumber(it.spans[1].line_start) / 2) + end, + lnum_end = function(it) + return math.ceil(tonumber(it.spans[1].line_end) / 2) + end, + code = function(it) + return it.code.code + end, + col = function(it) + return it.spans[1].column_start + end, + col_end = function(it) + return it.spans[1].column_end + end, + severity = "level", -- "it.level" + message = "message", -- "it.message" +}, +``` + +Et voilà! + +![image](https://github.com/xiaoshihou514/guard.nvim/assets/108414369/f9137b5a-ae69-494f-9f5b-b6044ae63c86) + ## Take advantage of autocmd events Guard exposes a `GuardFmt` user event that you can use. It is called both before formatting starts and after it is completely done. To differentiate between pre-format and post-format calls, a `data` table is passed. @@ -8,17 +190,16 @@ Guard exposes a `GuardFmt` user event that you can use. It is called both before -- for pre-format calls data = { status = "pending", -- type: string, called whenever a format is requested - using = {...} -- type: table, whatever formatters you are using for this format action + using = {...} -- type: table, formatters that are going to run } -- for post-format calls data = { status = "done", -- type: string, only called on success - results = {...} -- type: table, formatted buffer text as a list of lines } -- or data = { status = "failed" -- type: string, buffer remain unchanged - msg = "..." -- type: string, currently only if buffer became invalid or changed during formatting + msg = "..." -- type: string, reason for failure } ``` @@ -50,4 +231,6 @@ vim.api.nvim_create_autocmd("User", { }) ``` +[demo](https://github.com/xiaoshihou514/guard.nvim/assets/108414369/339ff4ff-288c-49e4-8ab1-789a6175d201) + You can do the similar for your statusline plugin of choice as long as you "refresh" it on `GuardFmt`. diff --git a/README.md b/README.md index 05caca7..2d564e8 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # guard.nvim -Async formatting and linting utility for neovim. +Async formatting and linting utility for neovim `0.10+`. ## Features @@ -88,19 +88,26 @@ Easily setup your custom tool if not in the defaults or you do not want guard-co ``` { + -- specify an executable cmd -- string: tool command - args -- table: command arguments + args -- string[]: command arguments fname -- boolean: insert filename to args tail stdin -- boolean: pass buffer contents into stdin - timeout -- integer - ignore_patterns -- table: don't run formatter when pattern match against file name + + -- or provide your own logic + fn -- function: write your own logic for formatting / linting, more in ADVANCED.md + + -- running condition + ignore_patterns -- string|string[]: don't run formatter when pattern match against file name ignore_error -- boolean: when has lsp error ignore format - find -- string: format if the file is found in the lsp root dir - env -- table: environment variables passed to cmd (key value pair) + find -- string|string[]: format if the file is found in the lsp root dir + + -- misc + env -- table?: environment variables passed to cmd (key value pair) + timeout -- integer -- special - parse -- function: used to parse linter output to neovim diagnostic - fn -- function: if fn is set other fields will not take effect + parse -- function: linter only, parses linter output to neovim diagnostic } ``` @@ -115,6 +122,14 @@ ft('asm'):fmt({ Consult the [builtin tools](https://github.com/nvimdev/guard-collection/tree/main/lua/guard-collection) if needed. +### Advanced configuration + +[ADVANCED.md](https://github.com/nvimdev/guard.nvim/blob/main/ADVANCED.md) contains tiny tutorials to: + +- Write your own formatting logic using the fn field +- Write your own linting logic using the fn field +- leverage guard's autocmds, say showing formatting status? + ### Supported Tools See [here](https://github.com/nvimdev/guard-collection) for an exhaustive list. diff --git a/doc/guard.nvim.txt b/doc/guard.nvim.txt index f4d1c75..4fc46c7 100644 --- a/doc/guard.nvim.txt +++ b/doc/guard.nvim.txt @@ -1,4 +1,4 @@ -*guard.nvim.txt* For NVIM v0.8.0 Last change: 2024 June 05 +*guard.nvim.txt* For NVIM v0.8.0 Last change: 2024 June 06 ============================================================================== Table of Contents *guard.nvim-table-of-contents* diff --git a/lua/guard/events.lua b/lua/guard/events.lua index b116164..f547e48 100644 --- a/lua/guard/events.lua +++ b/lua/guard/events.lua @@ -1,12 +1,33 @@ -local api = vim.api +local api, uv = vim.api, vim.uv local group = api.nvim_create_augroup('Guard', { clear = true }) -local ft_handler = require('guard.filetype') -local format = require('guard.format') +local au = api.nvim_create_autocmd +local iter = vim.iter +local M = {} -local function watch_ft(fts) - api.nvim_create_autocmd('FileType', { +function M.attach_to_buf(buf) + au('BufWritePre', { group = group, - pattern = fts, + buffer = buf, + callback = function(opt) + if vim.bo[opt.buf].modified then + require('guard.format').do_fmt(opt.buf) + end + end, + }) +end + +function M.watch_ft(ft) + -- check if all cmds executable before registering formatter + iter(require('guard.filetype')[ft].formatter):any(function(config) + if config.cmd and vim.fn.executable(config.cmd) ~= 1 then + error(config.cmd .. ' not executable', 1) + end + return true + end) + + au('FileType', { + group = group, + pattern = ft, callback = function(args) if #api.nvim_get_autocmds({ @@ -15,15 +36,15 @@ local function watch_ft(fts) buffer = args.buf, }) == 0 then - format.attach_to_buf(args.buf) + M.attach_to_buf(args.buf) end end, desc = 'guard', }) end -local function create_lspattach_autocmd(fmt_on_save) - api.nvim_create_autocmd('LspAttach', { +function M.create_lspattach_autocmd(fmt_on_save) + au('LspAttach', { group = group, callback = function(args) local client = vim.lsp.get_client_by_id(args.data.client_id) @@ -31,27 +52,84 @@ local function create_lspattach_autocmd(fmt_on_save) if not client.supports_method('textDocument/formatting') then return end + local ft_handler = require('guard.filetype') local ft = vim.bo[args.buf].filetype if not (ft_handler[ft] and ft_handler[ft].formatter) then ft_handler(ft):fmt('lsp') end - if - fmt_on_save - and #api.nvim_get_autocmds({ + if fmt_on_save then + if + #api.nvim_get_autocmds({ group = group, event = 'FileType', pattern = ft, + }) == 0 + then + M.watch_ft(ft) + end + if + #api.nvim_get_autocmds({ + group = group, + event = 'BufWritePre', + buffer = args.buf, + }) == 0 + then + M.attach_to_buf(args.buf) + end + end + end, + }) +end + +local debounce_timer = nil +function M.register_lint(ft, events) + iter(require('guard.filetype')[ft].linter):any(function(config) + if config.cmd and vim.fn.executable(config.cmd) ~= 1 then + error(config.cmd .. ' not executable', 1) + end + return true + end) + + au('FileType', { + pattern = ft, + group = group, + callback = function(args) + local cb = function(opt) + if debounce_timer then + debounce_timer:stop() + debounce_timer = nil + end + debounce_timer = assert(uv.new_timer()) --[[uv_timer_t]] + debounce_timer:start(500, 0, function() + debounce_timer:stop() + debounce_timer:close() + debounce_timer = nil + vim.schedule(function() + require('guard.lint').do_lint(opt.buf) + end) + end) + end + for _, ev in ipairs(events) do + if ev == 'User GuardFmt' then + au('User', { + group = group, + pattern = 'GuardFmt', + callback = function(opt) + if opt.data.status == 'done' then + cb(opt) + end + end, }) - == 0 - then - format.attach_to_buf(args.buf) + else + au(ev, { + group = group, + buffer = args.buf, + callback = cb, + }) + end end end, }) end -return { - group = group, - watch_ft = watch_ft, - create_lspattach_autocmd = create_lspattach_autocmd, -} +return M diff --git a/lua/guard/filetype.lua b/lua/guard/filetype.lua index 5fbe78a..627b431 100644 --- a/lua/guard/filetype.lua +++ b/lua/guard/filetype.lua @@ -1,12 +1,9 @@ +local util = require('guard.util') local M = {} local function get_tool(tool_type, tool_name) if tool_name == 'lsp' then - return { - fn = function(bufnr, range) - vim.lsp.buf.format({ bufnr = bufnr, range = range, async = true }) - end, - } + return { fn = require('guard.lsp').format } end local ok, tbl = pcall(require, 'guard-collection.' .. tool_type) if not ok then @@ -16,14 +13,15 @@ local function get_tool(tool_type, tool_name) ), 4 ) - return + return {} end if not tbl[tool_name] then vim.notify(('[Guard]: %s %s has no builtin configuration'):format(tool_type, tool_name), 4) - return + return {} end return tbl[tool_name] end +---@return FmtConfig|LintConfig local function try_as(tool_type, config) return type(config) == 'table' and config or get_tool(tool_type, config) end @@ -39,7 +37,7 @@ local function box() }) current = 'formatter' self.formatter = { - vim.deepcopy(try_as('formatter', config)), + util.toolcopy(try_as('formatter', config)), } return self end @@ -50,7 +48,7 @@ local function box() }) current = 'linter' self.linter = { - vim.deepcopy(try_as('linter', config)), + util.toolcopy(try_as('linter', config)), } return self end @@ -78,6 +76,7 @@ local function box() end local tool = self[current][#self[current]] tool.env = {} + ---@diagnostic disable-next-line: undefined-field env = vim.tbl_extend('force', vim.uv.os_environ(), env or {}) for k, v in pairs(env) do tool.env[#tool.env + 1] = ('%s=%s'):format(k, tostring(v)) @@ -112,7 +111,7 @@ local function box() local target = self:key_alias(key) local tool_type = key == 'fmt' and 'formatter' or 'linter' for _, item in ipairs(cfg) do - target[#target + 1] = vim.deepcopy(try_as(tool_type, item)) + target[#target + 1] = util.toolcopy(try_as(tool_type, item)) end end diff --git a/lua/guard/format.lua b/lua/guard/format.lua index 75056c4..f11bcfb 100644 --- a/lua/guard/format.lua +++ b/lua/guard/format.lua @@ -1,24 +1,21 @@ +---@class FmtConfig +---@field cmd string? +---@field args string[]? +---@field fname boolean? +---@field stdin boolean? +---@field fn function? +---@field ignore_patterns string|string[]? +---@field ignore_error boolean? +---@field find string|string[]? +---@field env table? +---@field timeout integer? + local api = vim.api ----@diagnostic disable-next-line: deprecated -local uv = vim.version().minor >= 10 and vim.uv or vim.loop -local spawn = require('guard.spawn').try_spawn +local spawn = require('guard.spawn') local util = require('guard.util') -local get_prev_lines = util.get_prev_lines +local report_error = util.report_error local filetype = require('guard.filetype') - -local function ignored(buf, patterns) - local fname = api.nvim_buf_get_name(buf) - if #fname == 0 then - return false - end - - for _, pattern in pairs(util.as_table(patterns)) do - if fname:find(pattern) then - return true - end - end - return false -end +local iter, filter = vim.iter, vim.tbl_filter local function save_views(bufnr) local views = {} @@ -49,158 +46,193 @@ local function update_buffer(bufnr, prev_lines, new_lines, srow, erow) if new_lines ~= prev_lines then api.nvim_buf_set_lines(bufnr, srow, erow, false, new_lines) - if require("guard").config.opts.save_on_fmt then + if require('guard').config.opts.save_on_fmt then api.nvim_command('silent! noautocmd write!') end restore_views(views) end end -local function find(startpath, patterns, root_dir) - patterns = util.as_table(patterns) - for _, pattern in ipairs(patterns) do - if - #vim.fs.find(pattern, { - upward = true, - stop = root_dir and vim.fn.fnamemodify(root_dir, ':h') or vim.env.HOME, - path = startpath, - }) > 0 - then - return true - end - end -end - -local function override_lsp(buf) - local co = assert(coroutine.running()) - local original = vim.lsp.util.apply_text_edits - local clients = util.get_clients(buf, 'textDocument/formatting') - if #clients == 0 then - return - end - local total = #clients - - local changed_tick = api.nvim_buf_get_changedtick(buf) - ---@diagnostic disable-next-line: duplicate-set-field - vim.lsp.util.apply_text_edits = function(text_edits, bufnr, offset_encoding) - total = total - 1 - original(text_edits, bufnr, offset_encoding) - if api.nvim_buf_get_changedtick(buf) ~= changed_tick then - api.nvim_command('silent! noautocmd write!') - end - if total == 0 then - coroutine.resume(co) - end - end +local function fail(msg) + util.doau('GuardFmt', { + status = 'failed', + msg = msg, + }) + vim.notify('[Guard]: ' .. msg, vim.log.levels.WARN) end local function do_fmt(buf) buf = buf or api.nvim_get_current_buf() - if not filetype[vim.bo[buf].filetype] then - vim.notify('[Guard] missing config for filetype ' .. vim.bo[buf].filetype, vim.log.levels.ERROR) + local ft_conf = filetype[vim.bo[buf].filetype] + if not ft_conf or not ft_conf.formatter then + report_error('missing config for filetype ' .. vim.bo[buf].filetype) return end - local srow = 0 - local erow = -1 - local range + + -- get format range + local srow, erow = 0, -1 + local range = nil local mode = api.nvim_get_mode().mode if mode == 'V' or mode == 'v' then range = util.range_from_selection(buf, mode) srow = range.start[1] - 1 erow = range['end'][1] end - local fmt_configs = filetype[vim.bo[buf].filetype].formatter - local fname = vim.fn.fnameescape(api.nvim_buf_get_name(buf)) - local startpath = vim.fn.expand(fname, ':p:h') - local root_dir = util.get_lsp_root() - local cwd = root_dir or uv.cwd() + + -- init environment + ---@type FmtConfig[] + local fmt_configs = ft_conf.formatter + local fname, startpath, root_dir, cwd = util.buf_get_info(buf) + + -- handle execution condition + fmt_configs = filter(function(config) + return util.should_run(config, buf, startpath, root_dir) + end, fmt_configs) + + -- check if all cmds executable again, since user can call format manually + iter(fmt_configs):any(function(config) + if config.cmd and vim.fn.executable(config.cmd) ~= 1 then + error(config.cmd .. ' not executable', 1) + end + return true + end) + + -- filter out "pure" and "impure" formatters + local pure = iter(filter(function(config) + return config.fn or (config.cmd and config.stdin) + end, fmt_configs)) + local impure = iter(filter(function(config) + return config.cmd and not config.stdin + end, fmt_configs)) + + -- error if one of the formatters is impure and the user requested range formatting + if range and #impure:totable() > 0 then + report_error('Cannot apply range formatting for filetype ' .. vim.bo[buf].filetype) + report_error(impure + :map(function(config) + return config.cmd + end) + :join(', ') .. ' does not support reading from stdin') + return + end + + -- actually start formatting util.doau('GuardFmt', { status = 'pending', using = fmt_configs, }) - local prev_lines = table.concat(get_prev_lines(buf, srow, erow), '') + + local prev_lines = table.concat(util.get_prev_lines(buf, srow, erow), '') + local new_lines = prev_lines + local errno = nil coroutine.resume(coroutine.create(function() - local new_lines - local changedtick = api.nvim_buf_get_changedtick(buf) - local reload = nil - - for i, config in ipairs(fmt_configs) do - local allow = true - if config.ignore_patterns and ignored(buf, config.ignore_patterns) then - allow = false - elseif config.ignore_error and #vim.diagnostic.get(buf, { severity = 1 }) ~= 0 then - allow = false - elseif config.find and not find(startpath, config.find, root_dir) then - allow = false + 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 + vim.schedule(function() + if api.nvim_buf_get_changedtick(buf) ~= changedtick then + errno = { reason = 'buffer changed' } + end + end) + if errno then + return '' end - if allow then - if config.cmd then - config.lines = new_lines and new_lines or prev_lines - config.args = config.args or {} - config.args[#config.args + 1] = config.fname and fname or nil - config.cwd = cwd - reload = (not reload and config.stdout == false) and true or false - new_lines = spawn(config) - --restore - config.lines = nil - config.cwd = nil - if config.fname then - config.args[#config.args] = nil - end - elseif config.fn then - if not config.override then - override_lsp(buf) - config.override = true - end - config.fn(buf, range) - util.doau('GuardFmt', { - status = 'done', - }) - coroutine.yield() - if i ~= #fmt_configs then - new_lines = table.concat(get_prev_lines(buf, srow, erow), '') - 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 + config.cwd = config.cwd or cwd + local result = spawn.transform(util.get_cmd(config, fname), config, acc) + if type(result) == 'table' then + -- indicates error + errno = result + errno.reason = config.cmd .. ' exited with errors' + return '' + else + ---@diagnostic disable-next-line: return-type-mismatch + return result end - changedtick = vim.b[buf].changedtick end - end + end) + + local co = assert(coroutine.running()) vim.schedule(function() - if not api.nvim_buf_is_valid(buf) or changedtick ~= api.nvim_buf_get_changedtick(buf) then - util.doau('GuardFmt', { - status = 'failed', - msg = 'buffer changed or no longer valid', - }) + -- handle errors + if errno then + if errno.reason == 'exit 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 return end - update_buffer(buf, prev_lines, new_lines, srow, erow) - if reload and api.nvim_get_current_buf() == buf then - vim.cmd.edit() + -- 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 - util.doau('GuardFmt', { - status = 'done', - results = new_lines, - }) + update_buffer(buf, prev_lines, new_lines, srow, erow) + coroutine.resume(co) end) - end)) -end -local function attach_to_buf(buf) - api.nvim_create_autocmd('BufWritePre', { - group = require('guard.events').group, - buffer = buf, - callback = function(opt) - if not vim.bo[opt.buf].modified then + -- wait until substitution is finished + coroutine.yield() + + impure:fold(nil, function(_, config, _) + if errno then return end - require('guard.format').do_fmt(opt.buf) - end, - }) + + vim.system(util.get_cmd(config, fname), { + 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) + + coroutine.yield() + end) + + if errno then + fail(('%s exited with code %d\n%s'):format(errno.cmd, errno.code, errno.stderr)) + return + 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) + end) + end) + + util.doau('GuardFmt', { + status = 'done', + }) + end)) end return { do_fmt = do_fmt, - attach_to_buf = attach_to_buf, } diff --git a/lua/guard/health.lua b/lua/guard/health.lua index 0427a0b..3dfdc71 100644 --- a/lua/guard/health.lua +++ b/lua/guard/health.lua @@ -1,8 +1,6 @@ local fn, health = vim.fn, vim.health local filetype = require('guard.filetype') -local start = vim.version().minor >= 10 and health.start or health.report_start -local ok = vim.version().minor >= 10 and health.ok or health.report_ok -local health_error = vim.version().minor >= 10 and health.error or health.report_error +local ok, error = health.ok, health.error local M = {} local function executable_check() @@ -13,7 +11,7 @@ local function executable_check() if fn.executable(conf.cmd) == 1 then ok(conf.cmd .. ' found') else - health_error(conf.cmd .. ' not found') + error(conf.cmd .. ' not found') end table.insert(checked, conf.cmd) end @@ -24,7 +22,7 @@ local function executable_check() if fn.executable(conf.cmd) == 1 then ok(conf.cmd .. ' found') else - health_error(conf.cmd .. ' not found') + error(conf.cmd .. ' not found') end table.insert(checked, conf.cmd) end @@ -33,7 +31,7 @@ local function executable_check() end M.check = function() - start('Executable check') + health.start('Executable check') executable_check() end diff --git a/lua/guard/init.lua b/lua/guard/init.lua index 2c09450..920e2e8 100644 --- a/lua/guard/init.lua +++ b/lua/guard/init.lua @@ -3,7 +3,7 @@ local ft_handler = require('guard.filetype') local events = require('guard.events') local config = { - opts = nil + opts = nil, } local function register_cfg_by_table(fts_with_cfg) @@ -22,9 +22,11 @@ local function resolve_multi_ft() local keys = vim.tbl_keys(ft_handler) vim.tbl_map(function(key) if key:find(',') then - local t = vim.split(key, ',') - for _, item in ipairs(t) do - ft_handler[item] = vim.deepcopy(ft_handler[key]) + local src = ft_handler[key] + for _, item in ipairs(vim.split(key, ',')) do + ft_handler[item] = {} + ft_handler[item].formatter = src.formatter and vim.tbl_map(util.toolcopy, src.formatter) + ft_handler[item].linter = src.linter and vim.tbl_map(util.toolcopy, src.linter) end ft_handler[key] = nil end @@ -45,7 +47,6 @@ local function setup(opt) events.create_lspattach_autocmd(config.opts.fmt_on_save) end - local lint = require('guard.lint') for ft, conf in pairs(ft_handler) do local lint_events = { 'BufWritePost', 'BufEnter' } @@ -60,7 +61,7 @@ local function setup(opt) table.insert(lint_events, 'TextChanged') table.insert(lint_events, 'InsertLeave') end - lint.register_lint(ft, lint_events) + events.register_lint(ft, lint_events) end end end diff --git a/lua/guard/lint.lua b/lua/guard/lint.lua index 89b8f37..489c12e 100644 --- a/lua/guard/lint.lua +++ b/lua/guard/lint.lua @@ -1,15 +1,28 @@ +---@class LintConfig +---@field cmd string? +---@field args string[]? +---@field fname boolean? +---@field stdin boolean? +---@field fn function? +---@field parse function +---@field ignore_patterns string|string[]? +---@field ignore_error boolean? +---@field find string|string[]? +---@field env table? +---@field timeout integer? + local api = vim.api ----@diagnostic disable-next-line: deprecated -local uv = vim.version().minor >= 10 and vim.uv or vim.loop local ft_handler = require('guard.filetype') -local spawn = require('guard.spawn').try_spawn +local util = require('guard.util') local ns = api.nvim_create_namespace('Guard') +local spawn = require('guard.spawn') local get_prev_lines = require('guard.util').get_prev_lines local vd = vim.diagnostic -local group = require('guard.events').group +local M = {} -local function do_lint(buf) +function M.do_lint(buf) buf = buf or api.nvim_get_current_buf() + ---@type LintConfig[] local linters, generic_linters local generic_config = ft_handler['*'] @@ -24,12 +37,18 @@ local function do_lint(buf) linters = generic_linters else -- buf_config exists, we want both - linters = vim.deepcopy(buf_config.linter) + linters = vim.tbl_map(util.toolcopy, buf_config.linter) if generic_linters then vim.list_extend(linters, generic_linters) end end - local fname = vim.fn.fnameescape(api.nvim_buf_get_name(buf)) + + -- check run condition + local fname, startpath, root_dir, cwd = util.buf_get_info(buf) + linters = vim.tbl_filter(function(config) + return util.should_run(config, buf, startpath, root_dir) + end, linters) + local prev_lines = get_prev_lines(buf, 0, -1) vd.reset(ns, buf) @@ -37,11 +56,13 @@ local function do_lint(buf) local results = {} for _, lint in ipairs(linters) do - lint = vim.deepcopy(lint) - lint.args = lint.args or {} - lint.args[#lint.args + 1] = fname - lint.lines = prev_lines - local data = spawn(lint) + local data + if lint.cmd then + lint.cwd = lint.cwd or cwd + data = spawn.transform(util.get_cmd(lint, fname), lint, prev_lines) + else + data = lint.fn(prev_lines) + end if #data > 0 then vim.list_extend(results, lint.parse(data, buf)) end @@ -56,57 +77,21 @@ local function do_lint(buf) end)) end -local debounce_timer = nil -local function register_lint(ft, events) - api.nvim_create_autocmd('FileType', { - pattern = ft, - group = group, - callback = function(args) - local cb = function(opt) - if debounce_timer then - debounce_timer:stop() - debounce_timer = nil - end - debounce_timer = uv.new_timer() - debounce_timer:start(500, 0, function() - debounce_timer:stop() - debounce_timer:close() - debounce_timer = nil - vim.schedule(function() - do_lint(opt.buf) - end) - end) - end - for _, ev in ipairs(events) do - if ev == 'User GuardFmt' then - api.nvim_create_autocmd('User', { - group = group, - pattern = 'GuardFmt', - callback = function(opt) - if opt.data.status == 'done' then - cb(opt) - end - end, - }) - else - api.nvim_create_autocmd(ev, { - group = group, - buffer = args.buf, - callback = cb, - }) - end - end - end, - }) -end - -local function diag_fmt(buf, lnum, col, message, severity, source) +---@param buf number +---@param lnum_start number +---@param lnum_end number +---@param col_start number +---@param col_end number +---@param message string +---@param severity number +---@param source string +function M.diag_fmt(buf, lnum_start, lnum_end, col_start, col_end, message, severity, source) return { bufnr = buf, - col = col, - end_col = col, - end_lnum = lnum, - lnum = lnum, + col = col_start, + end_col = col_end or col_start, + lnum = lnum_start, + end_lnum = lnum_end or lnum_start, message = message or '', namespace = ns, severity = severity or vim.diagnostic.severity.HINT, @@ -120,6 +105,7 @@ local severities = { info = 3, style = 4, } +M.severities = severities local from_opts = { offset = 1, @@ -127,6 +113,11 @@ local from_opts = { severities = severities, } +local regex_opts = { + regex = nil, + groups = { 'lnum', 'col', 'severity', 'code', 'message' }, +} + local json_opts = { get_diagnostics = function(...) return vim.json.decode(...) @@ -145,7 +136,11 @@ local function formulate_msg(msg, code) return (msg or '') .. (code and ('[%s]'):format(code) or '') end -local function from_json(opts) +local function attr_value(mes, attribute) + return type(attribute) == 'function' and attribute(mes) or mes[attribute] +end + +function M.from_json(opts) opts = vim.tbl_deep_extend('force', from_opts, opts or {}) opts = vim.tbl_deep_extend('force', json_opts, opts) @@ -155,23 +150,27 @@ local function from_json(opts) if opts.lines then -- \r\n for windows compatibility vim.tbl_map(function(line) - offences[#offences + 1] = opts.get_diagnostics(line) + local offence = opts.get_diagnostics(line) + if offence then + offences[#offences + 1] = offence + end end, vim.split(result, '\r?\n', { trimempty = true })) else offences = opts.get_diagnostics(result) end vim.tbl_map(function(mes) - local function attr_value(attribute) - return type(attribute) == 'function' and attribute(mes) or mes[attribute] - end - local message, code = attr_value(opts.attributes.message), attr_value(opts.attributes.code) - diags[#diags + 1] = diag_fmt( + local attr = opts.attributes + local message = attr_value(mes, attr.message) + local code = attr_value(mes, attr.code) + diags[#diags + 1] = M.diag_fmt( buf, - tonumber(attr_value(opts.attributes.lnum)) - opts.offset, - tonumber(attr_value(opts.attributes.col)) - opts.offset, + tonumber(attr_value(mes, attr.lnum)) - opts.offset, + tonumber(attr_value(mes, attr.lnum_end or attr.lnum)) - opts.offset, + tonumber(attr_value(mes, attr.col)) - opts.offset, + tonumber(attr_value(mes, attr.col_end or attr.lnum)) - opts.offset, formulate_msg(message, code), - opts.severities[attr_value(opts.attributes.severity)], + opts.severities[attr_value(mes, attr.severity)], opts.source ) end, offences or {}) @@ -180,12 +179,7 @@ local function from_json(opts) end end -local regex_opts = { - regex = nil, - groups = { 'lnum', 'col', 'severity', 'code', 'message' }, -} - -local function from_regex(opts) +function M.from_regex(opts) opts = vim.tbl_deep_extend('force', from_opts, opts or {}) opts = vim.tbl_deep_extend('force', regex_opts, opts) @@ -210,10 +204,12 @@ local function from_regex(opts) end vim.tbl_map(function(mes) - diags[#diags + 1] = diag_fmt( + diags[#diags + 1] = M.diag_fmt( buf, tonumber(mes.lnum) - opts.offset, + tonumber(mes.lnum_end or mes.lnum) - opts.offset, tonumber(mes.col) - opts.offset, + tonumber(mes.col_end or mes.col) - opts.offset, formulate_msg(mes.message, mes.code), opts.severities[mes.severity], opts.source @@ -224,11 +220,4 @@ local function from_regex(opts) end end -return { - do_lint = do_lint, - register_lint = register_lint, - diag_fmt = diag_fmt, - from_json = from_json, - from_regex = from_regex, - severities = severities, -} +return M diff --git a/lua/guard/lsp.lua b/lua/guard/lsp.lua new file mode 100644 index 0000000..a66ce34 --- /dev/null +++ b/lua/guard/lsp.lua @@ -0,0 +1,47 @@ +local M = {} +local api = vim.api + +---@param buf number +---@param range table +---@param acc string +---@return string +function M.format(buf, range, acc) + local co = assert(coroutine.running()) + local clients = vim.lsp.get_clients({ bufnr = buf, method = 'textDocument/formatting' }) + if #clients == 0 then + return acc + end + + -- use a temporary buffer to apply edits + local scratch = api.nvim_create_buf(false, true) + local apply = vim.lsp.util.apply_text_edits + local n_edits = #clients + api.nvim_buf_set_lines(scratch, 0, -1, false, vim.split(acc, '\r?\n')) + local line_offset = range and range.start[1] - 1 or 0 + + ---@diagnostic disable-next-line: duplicate-set-field + vim.lsp.util.apply_text_edits = function(text_edits, _, offset_encoding) + -- the target buffer must be buf, we apply it to our scratch buffer + n_edits = n_edits - 1 + vim.tbl_map(function(edit) + edit.range.start.line = edit.range.start.line - line_offset + edit.range['end'].line = edit.range['end'].line - line_offset + end, text_edits) + apply(text_edits, scratch, offset_encoding) + if n_edits == 0 then + vim.lsp.util.apply_text_edits = apply + api.nvim_command('silent! bufwipe! ' .. scratch) + coroutine.resume(co, table.concat(api.nvim_buf_get_lines(scratch, 0, -1, false), '\n')) + end + end + + vim.lsp.buf.format({ + bufnr = buf, + range = range, + async = true, + }) + + return (coroutine.yield()) +end + +return M diff --git a/lua/guard/spawn.lua b/lua/guard/spawn.lua index c632a96..f6622f3 100644 --- a/lua/guard/spawn.lua +++ b/lua/guard/spawn.lua @@ -1,119 +1,25 @@ ----@diagnostic disable-next-line: deprecated -local uv = vim.version().minor >= 10 and vim.uv or vim.loop +local M = {} -local function safe_close(handle) - if not uv.is_closing(handle) then - uv.close(handle) - end -end - -local function on_failed(msg) - vim.schedule(function() - vim.notify('[Guard] ' .. msg, vim.log.levels.ERROR) - end) -end - ---TODO: replace by vim.system when neovim 0.10 released -local function spawn(opt) - assert(opt, 'missing opt param') +-- @return table | string +function M.transform(cmd, config, lines) local co = assert(coroutine.running()) - - local chunks = {} - local num_stderr_chunks = 0 - local stdin = opt.lines and assert(uv.new_pipe()) or nil - local stdout = assert(uv.new_pipe()) - local stderr = assert(uv.new_pipe()) - local handle - - local timeout - local killed = false - if opt.timeout then - timeout = assert(uv.new_timer()) - timeout:start(opt.timeout, 0, function() - safe_close(handle) - killed = true - end) - end - - handle = uv.spawn(opt.cmd, { - stdio = { stdin, stdout, stderr }, - args = opt.args, - cwd = opt.cwd, - env = opt.env, - }, function(exit_code, signal) - if timeout then - timeout:stop() - timeout:close() - end - safe_close(handle) - safe_close(stdout) - safe_close(stderr) - local check = assert(uv.new_check()) - check:start(function() - if not stdout:is_closing() or not stderr:is_closing() then - return - end - check:stop() - if killed then - on_failed( - ('process %s was killed because it reached the timeout signal %s code %s'):format( - opt.cmd, - signal, - exit_code - ) - ) - coroutine.resume(co) - return - end - end) - - if exit_code ~= 0 and num_stderr_chunks ~= 0 then - on_failed(('process %s exited with non-zero exit code %s'):format(opt.cmd, exit_code)) - coroutine.resume(co) - return + local handle = vim.system(cmd, { + stdin = true, + cwd = config.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, table.concat(chunks)) + coroutine.resume(co, result.stdout) end end) - - if not handle then - on_failed('failed to spawn process ' .. opt.cmd) - return - end - - if stdin then - stdin:write(opt.lines) - uv.shutdown(stdin, function() - safe_close(stdin) - end) - end - - stdout:read_start(function(err, data) - assert(not err, err) - if data then - chunks[#chunks + 1] = data - end - end) - - stderr:read_start(function(err, data) - assert(not err, err) - if data then - num_stderr_chunks = num_stderr_chunks + 1 - end - end) - - return (coroutine.yield()) -end - -local function try_spawn(opt) - local ok, out = pcall(spawn, opt) - if not ok then - on_failed('error: ' .. out) - return - end - return out + -- write to stdin and close it + handle:write(lines) + handle:write(nil) + return coroutine.yield() end -return { - try_spawn = try_spawn, -} +return M diff --git a/lua/guard/util.lua b/lua/guard/util.lua index 25cdab2..b7dc202 100644 --- a/lua/guard/util.lua +++ b/lua/guard/util.lua @@ -1,9 +1,12 @@ ----@diagnostic disable-next-line: deprecated -local get_clients = vim.version().minor >= 10 and vim.lsp.get_clients or vim.lsp.get_active_clients local api = vim.api -local util = {} +local iter = vim.iter +local M = {} -function util.get_prev_lines(bufnr, srow, erow) +---@param bufnr number +---@param srow number +---@param erow number +---@return string[] +function M.get_prev_lines(bufnr, srow, erow) local tbl = api.nvim_buf_get_lines(bufnr, srow, erow, false) local res = {} for _, text in ipairs(tbl) do @@ -12,9 +15,10 @@ function util.get_prev_lines(bufnr, srow, erow) return res end -function util.get_lsp_root(buf) +---@return string? +function M.get_lsp_root(buf) buf = buf or api.nvim_get_current_buf() - local clients = get_clients({ bufnr = buf }) + local clients = vim.lsp.get_clients({ bufnr = buf }) if #clients == 0 then return end @@ -25,25 +29,21 @@ function util.get_lsp_root(buf) end end -function util.as_table(t) - return (vim.fn.has('nvim-0.10') == 1 and vim.islist or vim.tbl_islist)(t) and t or { t } +function M.as_table(t) + return vim.islist(t) and t or { t } end --- TODO: Use `vim.region()` instead ? +---@source runtime/lua/vim/lsp/buf.lua ---@param bufnr integer ---@param mode "v"|"V" ---@return table {start={row,col}, end={row,col}} using (1, 0) indexing -function util.range_from_selection(bufnr, mode) - -- [bufnum, lnum, col, off]; both row and column 1-indexed +function M.range_from_selection(bufnr, mode) local start = vim.fn.getpos('v') local end_ = vim.fn.getpos('.') local start_row = start[2] local start_col = start[3] local end_row = end_[2] local end_col = end_[3] - - -- A user can start visual selection at the end and move backwards - -- Normalize the range to start < end if start_row == end_row and end_col < start_col then end_col, start_col = start_col, end_col elseif end_row < start_row then @@ -61,21 +61,117 @@ function util.range_from_selection(bufnr, mode) } end -function util.get_clients(bufnr, method) - if vim.version().minor >= 10 then - return vim.lsp.get_clients({ bufnr = bufnr, method = method }) - end - local clients = vim.lsp.get_active_clients({ bufnr = bufnr }) - return vim.tbl_filter(function(client) - return client.supports_method(method) - end, clients) -end - -function util.doau(pattern, data) +function M.doau(pattern, data) api.nvim_exec_autocmds('User', { pattern = pattern, data = data, }) end -return util +local ffi = require('ffi') +ffi.cdef([[ +bool os_can_exe(const char *name, char **abspath, bool use_path) +]]) + +---@param exe string +---@return string +local function exepath_ffi(exe) + local charpp = ffi.new('char*[1]') + assert(ffi.C.os_can_exe(exe, charpp, true)) + return ffi.string(charpp[0]) +end + +---@param config FmtConfig|LintConfig +---@param fname string +---@return string[] +function M.get_cmd(config, fname) + local cmd = config.args and vim.deepcopy(config.args) or {} + if config.fname then + table.insert(cmd, fname) + end + table.insert(cmd, 1, exepath_ffi(config.cmd)) + return cmd +end + +---@param startpath string +---@param patterns string[]|string? +---@param root_dir string +---@return boolean +local function find(startpath, patterns, root_dir) + return iter(M.as_table(patterns)):any(function(pattern) + return #vim.fs.find(pattern, { + upward = true, + stop = root_dir and vim.fn.fnamemodify(root_dir, ':h') or vim.env.HOME, + path = startpath, + }) > 0 + end) +end + +---@param buf number +---@param patterns string[]|string? +---@return boolean +local function ignored(buf, patterns) + local fname = api.nvim_buf_get_name(buf) + if #fname == 0 then + return false + end + + return iter(M.as_table(patterns)):any(function(pattern) + return fname:find(pattern) ~= nil + end) +end + +---@param config FmtConfig|LintConfig +---@param buf integer +---@param startpath string +---@param root_dir string +---@return boolean +function M.should_run(config, buf, startpath, root_dir) + if config.ignore_patterns and ignored(buf, config.ignore_patterns) then + return false + elseif config.ignore_error and #vim.diagnostic.get(buf, { severity = 1 }) ~= 0 then + return false + elseif config.find and not find(startpath, config.find, root_dir) then + return false + end + return true +end + +---@return string, string, string, string +function M.buf_get_info(buf) + local fname = vim.fn.fnameescape(api.nvim_buf_get_name(buf)) + local startpath = vim.fn.fnamemodify(fname, ':p:h') + local root_dir = M.get_lsp_root() + ---@diagnostic disable-next-line: undefined-field + local cwd = root_dir or vim.uv.cwd() + ---@diagnostic disable-next-line: return-type-mismatch + return fname, startpath, root_dir, cwd +end + +---@param c (FmtConfig|LintConfig)? +---@return (FmtConfig|LintConfig)? +function M.toolcopy(c) + if not c or vim.tbl_isempty(c) then + return nil + end + return { + cmd = c.cmd, + args = c.args, + fname = c.fname, + stdin = c.stdin, + fn = c.fn, + ignore_patterns = c.ignore_patterns, + ignore_error = c.ignore_error, + find = c.find, + env = c.env, + timeout = c.timeout, + parse = c.parse, + } +end + +---@param msg string +function M.report_error(msg) + vim.notify('[Guard]: ' .. msg, vim.log.levels.WARN) +end + +return M diff --git a/plugin/guard.lua b/plugin/guard.lua index a85867b..b9ae290 100644 --- a/plugin/guard.lua +++ b/plugin/guard.lua @@ -10,10 +10,9 @@ local group = require('guard.events').group vim.api.nvim_create_user_command('GuardDisable', function(opts) local arg = opts.args local bufnr = (#opts.fargs == 0) and api.nvim_get_current_buf() or tonumber(arg) - if not api.nvim_buf_is_valid(bufnr) then + if not bufnr or not api.nvim_buf_is_valid(bufnr) then return end - local bufau = api.nvim_get_autocmds({ group = group, event = 'BufWritePre', buffer = bufnr }) if #bufau ~= 0 then api.nvim_del_autocmd(bufau[1].id) @@ -23,11 +22,12 @@ end, { nargs = '?' }) vim.api.nvim_create_user_command('GuardEnable', function(opts) local arg = opts.args local bufnr = (#opts.fargs == 0) and api.nvim_get_current_buf() or tonumber(arg) - if bufnr then - local bufau = api.nvim_get_autocmds({ group = group, event = 'BufWritePre', buffer = bufnr }) - if #bufau == 0 then - require('guard.format').attach_to_buf(bufnr) - end + if not bufnr or not api.nvim_buf_is_valid(bufnr) then + return + end + local bufau = api.nvim_get_autocmds({ group = group, event = 'BufWritePre', buffer = bufnr }) + if #bufau == 0 then + require('guard.events').attach_to_buf(bufnr) end end, { nargs = '?' }) diff --git a/test/autocmd_spec.lua b/test/autocmd_spec.lua index 9f08fe3..63415a4 100644 --- a/test/autocmd_spec.lua +++ b/test/autocmd_spec.lua @@ -1,3 +1,4 @@ +---@diagnostic disable: undefined-field, undefined-global local api = vim.api local ft = require('guard.filetype') ft('lua'):fmt({ @@ -32,15 +33,15 @@ describe('autocmd module', function() au(function(opts) -- pre format au if opts.data.status == 'pending' then - api.nvim_buf_set_lines(bufnr, 0, -1, false, { - 'local a', - ' = "changed!"', + assert.are.same(opts.data.using, { + cmd = 'stylua', + args = { '-' }, + stdin = true, }) end end) require('guard.format').do_fmt(bufnr) vim.wait(500) - assert.are.same(api.nvim_buf_get_lines(bufnr, 0, -1, false), { "local a = 'changed!'" }) end) it('can trigger after formatting', function() diff --git a/test/command_spec.lua b/test/command_spec.lua new file mode 100644 index 0000000..f48607e --- /dev/null +++ b/test/command_spec.lua @@ -0,0 +1,71 @@ +require('plugin.guard') +vim.opt_global.swapfile = false +local api = vim.api +local same = assert.are.same + +describe('commands', function() + + require('guard.filetype')('lua'):fmt({ + cmd = 'stylua', + args = { '-' }, + stdin = true, + }) + require('guard').setup() + + local bufnr + before_each(function() + if bufnr then + vim.cmd('bdelete! ' .. bufnr) + end + bufnr = api.nvim_create_buf(true, false) + vim.bo[bufnr].filetype = 'lua' + api.nvim_set_current_buf(bufnr) + vim.cmd('noautocmd silent! write! /tmp/cmd_spec_test.lua') + vim.cmd('silent! edit!') + end) + + it('can call formatting manually', function() + api.nvim_buf_set_lines(bufnr, 0, -1, false, { + 'local a', + ' =42', + }) + vim.cmd('GuardFmt') + vim.wait(500) + same(api.nvim_buf_get_lines(bufnr, 0, -1, false), { 'local a = 42' }) + end) + + it('can disable auto format and enable again', function() + -- default + api.nvim_buf_set_lines(bufnr, 0, -1, false, { + 'local a', + ' =42', + }) + vim.cmd('silent! write') + vim.wait(500) + same(api.nvim_buf_get_lines(bufnr, 0, -1, false), { 'local a = 42' }) + + -- disable + vim.cmd("GuardDisable") + api.nvim_buf_set_lines(bufnr, 0, -1, false, { + 'local a', + ' =42', + }) + vim.cmd("silent! write") + vim.wait(500) + same(api.nvim_buf_get_lines(bufnr, 0, -1, false), { + 'local a', + ' =42', + }) + + -- enable + vim.cmd("GuardEnable") + -- make changes to trigger format + api.nvim_buf_set_lines(bufnr, 0, -1, false, { + 'local a', + ' =42', + }) + vim.cmd('silent! write') + vim.wait(500) + same(api.nvim_buf_get_lines(bufnr, 0, -1, false), { 'local a = 42' }) + end) +end) diff --git a/test/filetype_spec.lua b/test/filetype_spec.lua index 8ee7124..bef3fed 100644 --- a/test/filetype_spec.lua +++ b/test/filetype_spec.lua @@ -1,3 +1,4 @@ +---@diagnostic disable: undefined-field, undefined-global local ft = require('guard.filetype') local same = assert.are.same @@ -9,79 +10,79 @@ describe('filetype module', function() end) it('can register filetype with fmt config', function() - ft('c'):fmt({ - cmd = 'clang-format', - lines = { 'test', 'lines' }, + ft('lua'):fmt({ + cmd = 'stylua', + args = { '-' }, + stdin = true, }) same({ formatter = { - { cmd = 'clang-format', lines = { 'test', 'lines' } }, + { + cmd = 'stylua', + args = { '-' }, + stdin = true, + }, }, - }, ft.c) + }, ft.lua) end) it('can register fmt with many configs', function() ft('python'):fmt({ - cmd = 'tool1', - lines = { 'test' }, + cmd = 'tac', timeout = 1000, }):append({ - cmd = 'tool2', - lines = 'test', + cmd = 'cat', timeout = 1000, }) same({ formatter = { - { cmd = 'tool1', lines = { 'test' }, timeout = 1000 }, - { cmd = 'tool2', lines = 'test', timeout = 1000 }, + { cmd = 'tac', timeout = 1000 }, + { cmd = 'cat', timeout = 1000 }, }, }, ft.python) end) it('can register filetype with lint config', function() ft('python'):lint({ - cmd = 'black', - lines = { 'test', 'lines' }, + cmd = 'wc', }) same({ linter = { - { cmd = 'black', lines = { 'test', 'lines' } }, + { cmd = 'wc' }, }, }, ft.python) end) it('can register filetype with many lint config', function() ft('python'):lint({ - cmd = 'black', - lines = { 'test', 'lines' }, + cmd = 'wc', timeout = 1000, }):append({ - cmd = 'other', - lines = { 'test' }, + cmd = 'file', timeout = 1000, }) same({ linter = { - { cmd = 'black', lines = { 'test', 'lines' }, timeout = 1000 }, - { cmd = 'other', lines = { 'test' }, timeout = 1000 }, + { cmd = 'wc', timeout = 1000 }, + { cmd = 'file', timeout = 1000 }, }, }, ft.python) end) it('can register format and lint ', function() local py = ft('python') - py:fmt({ cmd = 'first', timeout = 1000 }):append({ cmd = 'second' }):append({ cmd = 'third' }) - py:lint({ cmd = 'first' }):append({ cmd = 'second' }):append({ cmd = 'third' }) + py:fmt({ cmd = 'head' }):append({ cmd = 'cat', timeout = 1000 }):append({ cmd = 'tail' }) + py:lint({ cmd = 'tac' }):append({ cmd = 'wc' }):append({ cmd = 'cat' }) same({ formatter = { - { cmd = 'first', timeout = 1000 }, - { cmd = 'second' }, - { cmd = 'third' }, + { cmd = 'head' }, + { cmd = 'cat', timeout = 1000 }, + { cmd = 'tail' }, }, linter = { - { cmd = 'first' }, - { cmd = 'second' }, - { cmd = 'third' }, + { cmd = 'tac' }, + { cmd = 'wc' }, + { cmd = 'cat' }, }, }, ft.python) end) @@ -91,25 +92,24 @@ describe('filetype module', function() ft = { c = { fmt = { - cmd = 'clang-format', - lines = { 'test', 'lines' }, + cmd = 'cat', }, }, python = { fmt = { - { cmd = 'tool-1' }, - { cmd = 'tool-2' }, + { cmd = 'tac' }, + { cmd = 'cat' }, }, lint = { - cmd = 'lint_tool_1', + cmd = 'wc', }, }, rust = { lint = { { - cmd = 'clippy', - args = { 'check' }, - stdin = true, + cmd = 'wc', + args = { '-l' }, + fname = true, }, }, }, @@ -117,60 +117,72 @@ describe('filetype module', function() }) same({ formatter = { - { cmd = 'clang-format', lines = { 'test', 'lines' } }, + { cmd = 'cat' }, }, }, ft.c) same({ formatter = { - { cmd = 'tool-1' }, - { cmd = 'tool-2' }, + { cmd = 'tac' }, + { cmd = 'cat' }, }, linter = { - { cmd = 'lint_tool_1' }, + { cmd = 'wc' }, }, }, ft.python) same({ linter = { - { cmd = 'clippy', args = { 'check' }, stdin = true }, + { cmd = 'wc', args = { '-l' }, fname = true }, }, }, ft.rust) end) it('can register a formatter for multiple filetypes simultaneously', function() ft('javascript,javascriptreact'):fmt({ - cmd = 'prettier', - args = { 'some', 'args' }, + cmd = 'cat', + args = { '-v', '-E' }, }) require('guard').setup({}) same({ - formatter = { { cmd = 'prettier', args = { 'some', 'args' } } }, + formatter = { { cmd = 'cat', args = { '-v', '-E' } } }, }, ft.javascript) same({ - formatter = { { cmd = 'prettier', args = { 'some', 'args' } } }, + formatter = { { cmd = 'cat', args = { '-v', '-E' } } }, }, ft.javascriptreact) end) it('can add extra command arguments', function() ft('c') :fmt({ - cmd = 'clang-format', - args = { '--style=Mozilla' }, + cmd = 'cat', + args = { '-n' }, stdin = true, }) - :extra('--verbose') + :extra('-s') :lint({ - cmd = 'clang-tidy', - args = { '--quiet' }, + cmd = 'wc', + args = { '-L', '1' }, parse = function() end, }) - :extra('--fix') + :extra('-l') same({ - cmd = 'clang-format', - args = { '--verbose', '--style=Mozilla' }, + cmd = 'cat', + args = { '-s', '-n' }, stdin = true, }, ft.c.formatter[1]) - same({ '--fix', '--quiet' }, ft.c.linter[1].args) + same({ '-l', '-L', '1' }, ft.c.linter[1].args) + end) + + it('can detect non executable formatters', function() + local c = ft('c') + c:fmt({ cmd = 'hjkl' }) + assert(not pcall(require('guard').setup)) + end) + + it('can detect non executable linters', function() + local c = ft('c') + c:lint({ cmd = 'hjkl' }) + assert(not pcall(require('guard').setup)) end) end) diff --git a/test/format_spec.lua b/test/format_spec.lua index 60a28ef..ceca2e9 100644 --- a/test/format_spec.lua +++ b/test/format_spec.lua @@ -1,22 +1,28 @@ +---@diagnostic disable: undefined-field, undefined-global local api = vim.api local equal = assert.equal local ft = require('guard.filetype') -ft('lua'):fmt({ - cmd = 'stylua', - args = { '-' }, - stdin = true, -}) -require('guard').setup() describe('format module', function() local bufnr before_each(function() - bufnr = api.nvim_create_buf(true, false) - end) + for k, _ in pairs(ft) do + ft[k] = nil + end - it('can format with stylua', function() + bufnr = api.nvim_create_buf(true, false) vim.bo[bufnr].filetype = 'lua' api.nvim_set_current_buf(bufnr) + vim.cmd('silent! write! /tmp/fmt_spec_test.lua') + end) + + it('can format with single formatter', function() + ft('lua'):fmt({ + cmd = 'stylua', + args = { '-' }, + stdin = true, + }) + require('guard').setup() api.nvim_buf_set_lines(bufnr, 0, -1, false, { 'local a', ' = "test"', @@ -26,4 +32,41 @@ describe('format module', function() local line = api.nvim_buf_get_lines(bufnr, 0, -1, false)[1] equal([[local a = 'test']], line) end) + + it('can format with multiple formatters', function() + ft('lua'):fmt({ + cmd = 'stylua', + args = { '-' }, + stdin = true, + }):append({ + cmd = 'tac', + args = { '-s', ' ' }, + stdin = true, + }) + require('guard').setup() + api.nvim_buf_set_lines(bufnr, 0, -1, false, { + 'local a', + ' = "test"', + }) + require('guard.format').do_fmt(bufnr) + vim.wait(500) + local lines = api.nvim_buf_get_lines(bufnr, 0, -1, false) + assert.are.same({ "'test'", '= a local ' }, lines) + end) + + it('can format with function', function() + ft('lua'):fmt({ + fn = function(buf, range, acc) + return table.concat(vim.split(acc, '\n'), '') .. vim.inspect(range) + end, + }) + api.nvim_buf_set_lines(bufnr, 0, -1, false, { + 'local a', + ' = "test"', + }) + require('guard.format').do_fmt(bufnr) + vim.wait(500) + local lines = api.nvim_buf_get_lines(bufnr, 0, -1, false) + assert.are.same({ 'local a = "test"nil' }, lines) + end) end) diff --git a/test/spawn_spec.lua b/test/spawn_spec.lua index cdc5801..da1609c 100644 --- a/test/spawn_spec.lua +++ b/test/spawn_spec.lua @@ -1,14 +1,14 @@ -local try_spawn = require('guard.spawn').try_spawn +---@diagnostic disable: undefined-field, undefined-global +local spawn = require('guard.spawn') +local same = assert.are.same describe('spawn module', function() - it('can spawn a process', function() - local opt = { - cmd = 'stylua', - } + it('can spawn executables with stdin access', function() coroutine.resume(coroutine.create(function() - try_spawn(opt) + local result = spawn.transform({ 'tac', '-s', ' ' }, { + stdin = true, + }, 'test1 test2 test3 ') + same(result, 'test3 test2 test1 ') end)) - - assert.is_true(true) end) end) diff --git a/test/util_spec.lua b/test/util_spec.lua new file mode 100644 index 0000000..8cc8726 --- /dev/null +++ b/test/util_spec.lua @@ -0,0 +1,20 @@ +---@diagnostic disable: undefined-field, undefined-global +local util = require('guard.util') +local same = assert.are.same +describe('util module', function() + it('can copy tool configs', function() + local original = { + cmd = 'stylua', + args = { '-' }, + stdin = true, + } + same(util.toolcopy(nil), nil) + local copy = util.toolcopy(original) + assert(copy) + same(copy, original) + original.cmd = 'sylua' + same(copy.cmd, 'stylua') + original = nil + same(copy.args, { '-' }) + end) +end)