384 lines
10 KiB
Lua
384 lines
10 KiB
Lua
local uv = vim.loop
|
|
local api = vim.api
|
|
local notify = vim.notify_once or vim.notify
|
|
local M = {}
|
|
|
|
|
|
---@class lint.Parser
|
|
---@field on_chunk fun(chunk: string)
|
|
---@field on_done fun(publish: fun(diagnostics: vim.Diagnostic[]), bufnr: number, linter_cwd: string)
|
|
|
|
|
|
---@class lint.Linter
|
|
---@field name string
|
|
---@field cmd string
|
|
---@field args? (string|fun():string)[] command arguments
|
|
---@field stdin? boolean send content via stdin. Defaults to false
|
|
---@field append_fname? boolean add current file name to the commands arguments
|
|
---@field stream? "stdout"|"stderr"|"both" result stream. Defaults to stdout
|
|
---@field ignore_exitcode? boolean if exit code != 1 should be ignored or result in a warning. Defaults to false
|
|
---@field env? table
|
|
---@field cwd? string
|
|
---@field parser lint.Parser|fun(output:string, bufnr:number, linter_cwd:string):vim.Diagnostic[]
|
|
|
|
|
|
---@class lint.LintProc
|
|
---@field bufnr integer
|
|
---@field handle uv.uv_process_t
|
|
---@field stdout uv.uv_pipe_t
|
|
---@field stderr uv.uv_pipe_t
|
|
---@field linter lint.Linter
|
|
---@field cwd string
|
|
---@field ns integer
|
|
---@field stream? "stdout"|"stderr"|"both"
|
|
---@field cancelled boolean
|
|
|
|
|
|
---@type table<string, lint.Linter|fun():lint.Linter>
|
|
M.linters = setmetatable({}, {
|
|
__index = function(_, key)
|
|
local ok, linter = pcall(require, 'lint.linters.' .. key)
|
|
if ok then
|
|
return linter
|
|
end
|
|
return nil
|
|
end,
|
|
})
|
|
|
|
|
|
M.linters_by_ft = {
|
|
text = {'vale',},
|
|
json = {'jsonlint',},
|
|
markdown = {'vale',},
|
|
rst = {'vale',},
|
|
ruby = {'ruby',},
|
|
janet = {'janet',},
|
|
inko = {'inko',},
|
|
clojure = {'clj-kondo',},
|
|
dockerfile = {'hadolint',},
|
|
terraform = {'tflint'},
|
|
}
|
|
|
|
local namespaces = setmetatable({}, {
|
|
__index = function(tbl, key)
|
|
local ns = api.nvim_create_namespace(key)
|
|
rawset(tbl, key, ns)
|
|
return ns
|
|
end
|
|
})
|
|
|
|
|
|
local function read_output(cwd, bufnr, parser, publish_fn)
|
|
return function(err, chunk)
|
|
assert(not err, err)
|
|
if chunk then
|
|
parser.on_chunk(chunk, bufnr)
|
|
else
|
|
parser.on_done(publish_fn, bufnr, cwd)
|
|
end
|
|
end
|
|
end
|
|
|
|
|
|
function M._resolve_linter_by_ft(ft)
|
|
local names = M.linters_by_ft[ft]
|
|
if names then
|
|
return names
|
|
end
|
|
local dedup_linters = {}
|
|
local filetypes = vim.split(ft, '.', { plain = true })
|
|
for _, ft_ in pairs(filetypes) do
|
|
local linters = M.linters_by_ft[ft_]
|
|
if linters then
|
|
for _, linter in ipairs(linters) do
|
|
dedup_linters[linter] = true
|
|
end
|
|
end
|
|
end
|
|
return vim.tbl_keys(dedup_linters)
|
|
end
|
|
|
|
|
|
---@class lint.LintProc
|
|
local LintProc = {}
|
|
local linter_proc_mt = {
|
|
__index = LintProc
|
|
}
|
|
|
|
|
|
function LintProc:publish(diagnostics)
|
|
-- By the time the linter is finished the user might have deleted the buffer
|
|
if api.nvim_buf_is_valid(self.bufnr) and not self.cancelled then
|
|
vim.diagnostic.set(self.ns, self.bufnr, diagnostics)
|
|
end
|
|
self.stdout:shutdown()
|
|
self.stdout:close()
|
|
self.stderr:shutdown()
|
|
self.stderr:close()
|
|
end
|
|
|
|
|
|
function LintProc:start_read()
|
|
local linter_proc = self
|
|
local publish = function(diagnostics)
|
|
linter_proc:publish(diagnostics)
|
|
end
|
|
local parser = self.linter.parser
|
|
if type(parser) == 'function' then
|
|
parser = require('lint.parser').accumulate_chunks(parser)
|
|
end
|
|
assert(
|
|
parser.on_chunk and type(parser.on_chunk == 'function'),
|
|
'Parser requires a `on_chunk` function'
|
|
)
|
|
assert(
|
|
parser.on_done and type(parser.on_done == 'function'),
|
|
'Parser requires a `on_done` function'
|
|
)
|
|
local stream = self.linter.stream
|
|
local cwd = self.cwd
|
|
local bufnr = self.bufnr
|
|
if not stream or stream == 'stdout' then
|
|
self.stdout:read_start(read_output(cwd, bufnr, parser, publish))
|
|
elseif stream == 'stderr' then
|
|
self.stderr:read_start(read_output(cwd, bufnr, parser, publish))
|
|
elseif stream == 'both' then
|
|
local parser1, parser2 = require('lint.parser').split(parser)
|
|
self.stdout:read_start(read_output(cwd, bufnr, parser1, publish))
|
|
self.stderr:read_start(read_output(cwd, bufnr, parser2, publish))
|
|
else
|
|
error('Invalid `stream` setting: ' .. stream)
|
|
end
|
|
end
|
|
|
|
|
|
function LintProc:cancel()
|
|
self.cancelled = true
|
|
local handle = self.handle
|
|
if not handle or handle:is_closing() then
|
|
return
|
|
end
|
|
|
|
-- Use sigint so the process can safely close any child processes.
|
|
-- This is mostly useful for when `cmd` is a script with a shebang.
|
|
handle:kill('sigint')
|
|
|
|
vim.wait(10000, function()
|
|
return handle:is_closing() or false
|
|
end)
|
|
|
|
if not handle:is_closing() then
|
|
-- 'sigint' didn't work, hit it with a 'sigkill'.
|
|
-- This should also kill any attached child processes since
|
|
-- handle is a process group leader (due to it being detached).
|
|
handle:kill('sigkill')
|
|
end
|
|
end
|
|
|
|
|
|
--- Return the namespace for a given linter.
|
|
---
|
|
--- Can be used to configure diagnostics for a given linter. For example:
|
|
---
|
|
--- ```lua
|
|
--- local ns = require("lint").get_namespace("my_linter_name")
|
|
--- vim.diagnostic.config({ virtual_text = true }, ns)
|
|
---
|
|
--- ```
|
|
---
|
|
---@param name string linter
|
|
function M.get_namespace(name)
|
|
return namespaces[name]
|
|
end
|
|
|
|
|
|
--- Running processes by buffer -> by linter name
|
|
---@type table<integer, table<string, lint.LintProc>> bufnr: {linter: handle}
|
|
local running_procs_by_buf = {}
|
|
|
|
|
|
--- Returns the names of the running linters
|
|
---
|
|
---@param bufnr? integer buffer for which to get the running linters. nil=all buffers
|
|
---@return string[]
|
|
function M.get_running(bufnr)
|
|
local linters = {}
|
|
if bufnr then
|
|
bufnr = bufnr == 0 and api.nvim_get_current_buf() or bufnr
|
|
local running_procs = (running_procs_by_buf[bufnr] or {})
|
|
for linter_name, _ in pairs(running_procs) do
|
|
table.insert(linters, linter_name)
|
|
end
|
|
else
|
|
for _, running_procs in pairs(running_procs_by_buf) do
|
|
for linter_name, _ in pairs(running_procs) do
|
|
table.insert(linters, linter_name)
|
|
end
|
|
end
|
|
end
|
|
return linters
|
|
end
|
|
|
|
|
|
---@param names? string|string[] name of the linter
|
|
---@param opts? {cwd?: string, ignore_errors?: boolean} options
|
|
function M.try_lint(names, opts)
|
|
assert(
|
|
vim.diagnostic,
|
|
"nvim-lint requires neovim 0.6.0+. If you're using an older version, use the `nvim-05` tag of nvim-lint'"
|
|
)
|
|
opts = opts or {}
|
|
if type(names) == "string" then
|
|
names = { names }
|
|
end
|
|
if not names then
|
|
names = M._resolve_linter_by_ft(vim.bo.filetype)
|
|
end
|
|
|
|
local lookup_linter = function(name)
|
|
local linter = M.linters[name]
|
|
assert(linter, 'Linter with name `' .. name .. '` not available')
|
|
if type(linter) == "function" then
|
|
linter = linter()
|
|
end
|
|
linter.name = linter.name or name
|
|
return linter
|
|
end
|
|
|
|
local bufnr = api.nvim_get_current_buf()
|
|
local running_procs = running_procs_by_buf[bufnr] or {}
|
|
for _, linter_name in pairs(names) do
|
|
local linter = lookup_linter(linter_name)
|
|
local proc = running_procs[linter.name]
|
|
if proc then
|
|
proc:cancel()
|
|
end
|
|
running_procs[linter.name] = nil
|
|
local ok, lintproc_or_error = pcall(M.lint, linter, opts)
|
|
if ok then
|
|
running_procs[linter.name] = lintproc_or_error
|
|
elseif not opts.ignore_errors then
|
|
notify(lintproc_or_error --[[@as string]], vim.log.levels.WARN)
|
|
end
|
|
end
|
|
running_procs_by_buf[bufnr] = running_procs
|
|
end
|
|
|
|
local function eval_fn_or_id(x)
|
|
if type(x) == 'function' then
|
|
return x()
|
|
else
|
|
return x
|
|
end
|
|
end
|
|
|
|
|
|
---@param linter lint.Linter
|
|
---@param opts? {cwd?: string, ignore_errors?: boolean}
|
|
---@return lint.LintProc|nil
|
|
function M.lint(linter, opts)
|
|
assert(linter, 'lint must be called with a linter')
|
|
local stdin = assert(uv.new_pipe(false), "Must be able to create pipe")
|
|
local stdout = assert(uv.new_pipe(false), "Must be able to create pipe")
|
|
local stderr = assert(uv.new_pipe(false), "Must be able to create pipe")
|
|
local handle
|
|
local env
|
|
local pid_or_err
|
|
local args = {}
|
|
local bufnr = api.nvim_get_current_buf()
|
|
local iswin = vim.fn.has("win32") == 1
|
|
if iswin then
|
|
linter = vim.tbl_extend("force", linter, {
|
|
cmd = "cmd.exe",
|
|
args = { "/C", linter.cmd, unpack(linter.args or {}) },
|
|
})
|
|
end
|
|
opts = opts or {}
|
|
if linter.args then
|
|
vim.list_extend(args, vim.tbl_map(eval_fn_or_id, linter.args))
|
|
end
|
|
if not linter.stdin and linter.append_fname ~= false then
|
|
table.insert(args, api.nvim_buf_get_name(bufnr))
|
|
end
|
|
if linter.env then
|
|
env = {}
|
|
if not linter.env["PATH"] then
|
|
-- Always include PATH as we need it to execute the linter command
|
|
table.insert(env, "PATH=" .. os.getenv("PATH"))
|
|
end
|
|
for k, v in pairs(linter.env) do
|
|
table.insert(env, k .. "=" .. v)
|
|
end
|
|
end
|
|
local linter_opts = {
|
|
args = args,
|
|
stdio = { stdin, stdout, stderr },
|
|
env = env,
|
|
cwd = opts.cwd or linter.cwd or vim.fn.getcwd(),
|
|
-- Linter may launch child processes so set this as a group leader and
|
|
-- manually track and kill processes as we need to.
|
|
-- Don't detach on windows since that may cause shells to
|
|
-- pop up shortly.
|
|
detached = not iswin
|
|
}
|
|
local cmd = eval_fn_or_id(linter.cmd)
|
|
assert(cmd, 'Linter definition must have a `cmd` set: ' .. vim.inspect(linter))
|
|
handle, pid_or_err = uv.spawn(cmd, linter_opts, function(code)
|
|
if handle and not handle:is_closing() then
|
|
local procs = (running_procs_by_buf[bufnr] or {})
|
|
-- Only cleanup if there has not been another procs in between
|
|
local proc = procs[linter.name] or {}
|
|
if handle == proc.handle then
|
|
procs[linter.name] = nil
|
|
if not next(procs) then
|
|
running_procs_by_buf[bufnr] = nil
|
|
end
|
|
end
|
|
handle:close()
|
|
end
|
|
if code ~= 0 and not linter.ignore_exitcode then
|
|
vim.schedule(function()
|
|
vim.notify('Linter command `' .. cmd .. '` exited with code: ' .. code, vim.log.levels.WARN)
|
|
end)
|
|
end
|
|
end)
|
|
if not handle then
|
|
stdout:close()
|
|
stderr:close()
|
|
stdin:close()
|
|
if not opts.ignore_errors then
|
|
vim.notify('Error running ' .. cmd .. ': ' .. pid_or_err, vim.log.levels.ERROR)
|
|
end
|
|
return nil
|
|
end
|
|
local state = {
|
|
bufnr = bufnr,
|
|
stdout = stdout,
|
|
stderr = stderr,
|
|
handle = handle,
|
|
linter = linter,
|
|
cwd = linter_opts.cwd,
|
|
ns = namespaces[linter.name],
|
|
cancelled = false,
|
|
}
|
|
local linter_proc = setmetatable(state, linter_proc_mt)
|
|
linter_proc:start_read()
|
|
if linter.stdin then
|
|
local lines = api.nvim_buf_get_lines(0, 0, -1, true)
|
|
for _, line in ipairs(lines) do
|
|
stdin:write(line .. '\n')
|
|
end
|
|
stdin:write('', function()
|
|
stdin:shutdown(function()
|
|
stdin:close()
|
|
end)
|
|
end)
|
|
else
|
|
stdin:close()
|
|
end
|
|
return linter_proc
|
|
end
|
|
|
|
|
|
return M
|