diff --git a/lua/guard/events.lua b/lua/guard/events.lua index 405a49e..469bbed 100644 --- a/lua/guard/events.lua +++ b/lua/guard/events.lua @@ -1,300 +1,569 @@ -local api, uv = vim.api, vim.uv +local lib = require('guard.lib') +local Result = lib.Result +local Async = lib.Async local util = require('guard.util') -local getopt = util.getopt -local report_error = util.report_error -local au = api.nvim_create_autocmd -local iter = vim.iter +local api = vim.api +local uv = vim.uv + local M = {} + +-- Autocmd group M.group = api.nvim_create_augroup('Guard', { clear = true }) -M.user_fmt_autocmds = {} -M.user_lint_autocmds = {} +-- Track user-defined custom autocmds +M.custom_autocmds = { + formatter = {}, + linter = {}, +} -local debounce_timer = nil -local function debounced_lint(opt) - if debounce_timer then - debounce_timer:stop() - debounce_timer = nil - end - ---@diagnostic disable-next-line: undefined-field - debounce_timer = assert(uv.new_timer()) --[[uv_timer_t]] - ---@type integer - local interval = assert(tonumber(util.getopt('lint_interval'))) - debounce_timer:start(interval, 0, function() - debounce_timer:stop() - debounce_timer:close() - debounce_timer = nil - vim.schedule(function() - require('guard.lint').do_lint(opt.buf) +-- Debounce manager for lint operations +local DebounceManager = {} +DebounceManager.__index = DebounceManager + +function DebounceManager.new() + return setmetatable({ + timers = {}, -- bufnr -> timer + }, DebounceManager) +end + +function DebounceManager:debounce(bufnr, callback, delay) + return Async.try(function() + -- Cancel existing timer for this buffer + self:cancel(bufnr) + + -- Create new timer + local timer = uv.new_timer() + if not timer then + return Result.err('Failed to create timer') + end + + self.timers[bufnr] = timer + + timer:start(delay, 0, function() + timer:stop() + timer:close() + self.timers[bufnr] = nil + + vim.schedule(function() + callback() + end) end) + + return Result.ok(timer) end) end -local function lazy_debounced_lint(opt) - if getopt('auto_lint') == true then - debounced_lint(opt) +function DebounceManager:cancel(bufnr) + local timer = self.timers[bufnr] + if timer then + timer:stop() + timer:close() + self.timers[bufnr] = nil end end -local function lazy_fmt(opt) - if vim.bo[opt.buf].modified and getopt('fmt_on_save') then - require('guard.format').do_fmt(opt.buf) +function DebounceManager:cancel_all() + for bufnr, _ in pairs(self.timers) do + self:cancel(bufnr) end end ----@param opt AuOption ----@param cb function ----@return AuOption -local function maybe_fill_auoption(opt, cb) - local result = vim.deepcopy(opt, false) - result.callback = (not result.command and not result.callback) and cb or result.callback - result.group = M.group - return result +-- Global debounce manager for lint +local lint_debouncer = DebounceManager.new() + +-- Event handlers with Result +local Handlers = {} + +-- Format handler +function Handlers.format(args) + return Async.try(function() + if not api.nvim_buf_is_valid(args.buf) then + return Result.err('Invalid buffer') + end + + if vim.bo[args.buf].modified and util.getopt('fmt_on_save') then + require('guard.format').do_fmt(args.buf) + return Result.ok('Formatted') + end + + return Result.ok('Skipped') + end) end ----@param bufnr number ----@return vim.api.keyset.get_autocmds.ret[] -function M.get_format_autocmds(bufnr) - if not api.nvim_buf_is_valid(bufnr) then - return {} - end - local caus = M.user_fmt_autocmds[vim.bo[bufnr].ft] - return caus - and iter(api.nvim_get_autocmds({ group = M.group })) - :filter(function(it) - return vim.tbl_contains(caus, it.id) - end) - :totable() - or api.nvim_get_autocmds({ group = M.group, event = 'BufWritePre', buffer = bufnr }) +-- Lint handler with debounce +function Handlers.lint(args) + return Async.try(function() + if not api.nvim_buf_is_valid(args.buf) then + return Result.err('Invalid buffer') + end + + if util.getopt('auto_lint') then + local interval = util.getopt('lint_interval') or 500 + return lint_debouncer:debounce(args.buf, function() + require('guard.lint').do_lint(args.buf) + end, interval) + end + + return Result.ok('Auto lint disabled') + end) end ----@param bufnr number ----@return vim.api.keyset.get_autocmds.ret[] -function M.get_lint_autocmds(bufnr) +-- Enhanced lint handler that triggers after format +function Handlers.lint_after_format(args) + return Async.try(function() + if args.buf and args.data and args.data.status == 'done' then + return Handlers.lint({ buf = args.buf }) + end + return Result.ok('Not triggered') + end) +end + +-- Autocmd management with Result +local AutocmdManager = {} + +-- Validate buffer for autocmd attachment +function AutocmdManager.validate_buffer(bufnr) if not api.nvim_buf_is_valid(bufnr) then - return {} + return Result.err('Invalid buffer') end - local aus = api.nvim_get_autocmds({ - group = M.group, - event = { 'BufWritePost', 'BufEnter', 'TextChanged', 'InsertLeave' }, - buffer = bufnr, - }) - return vim.list_extend( - aus, - api.nvim_get_autocmds({ - group = M.group, - event = 'User', - pattern = 'GuardFmt', - }) - ) -end ----@param buf number ----@return boolean ---- We don't check ignore patterns here because users might expect ---- other formatters in the same group to run even if another ---- might ignore this file -function M.check_fmt_should_attach(buf) - -- check if it's not attached already - return #M.get_format_autocmds(buf) == 0 - -- and has an underlying file - and vim.bo[buf].buftype ~= 'nofile' + if vim.bo[bufnr].buftype == 'nofile' then + return Result.err('Buffer is nofile type') + end + + return Result.ok(bufnr) end ----@param buf number ----@param ft string ----@return boolean -function M.check_lint_should_attach(buf, ft) - if vim.bo[buf].buftype == 'nofile' then - return false - end +-- Get autocmds for a specific tool and buffer +function AutocmdManager.get_autocmds(tool_type, bufnr, events) + return AutocmdManager.validate_buffer(bufnr) + :map(function() + local ft = vim.bo[bufnr].ft + local custom_ids = M.custom_autocmds[tool_type][ft] + + -- If custom autocmds exist, return those + if custom_ids and #custom_ids > 0 then + return vim + .iter(api.nvim_get_autocmds({ group = M.group })) + :filter(function(au) + return vim.tbl_contains(custom_ids, au.id) + end) + :totable() + end + + -- Otherwise return standard autocmds + events = events + or ( + tool_type == 'formatter' and { 'BufWritePre' } + or { 'BufWritePost', 'BufEnter', 'TextChanged', 'InsertLeave', 'User' } + ) - local aus = M.get_lint_autocmds(buf) + local autocmds = {} + for _, event in ipairs(events) do + local opts = { + group = M.group, + event = event, + } - return #iter(aus) - :filter(ft == '*' and function(it) - return it.pattern == '*' - end or function(it) - return it.pattern ~= '*' + if event == 'User' then + opts.pattern = 'GuardFmt' + else + opts.buffer = bufnr + end + + vim.list_extend(autocmds, api.nvim_get_autocmds(opts)) + end + + return autocmds end) - :totable() == 0 + :unwrap_or({}) +end + +-- Check if autocmds should be attached +function AutocmdManager.should_attach(tool_type, bufnr, ft) + return AutocmdManager.validate_buffer(bufnr):and_then(function() + -- Check existing autocmds + local existing = AutocmdManager.get_autocmds(tool_type, bufnr) + + if tool_type == 'formatter' then + if #existing > 0 then + return Result.err('Formatter already attached') + end + else + -- For linters, check specific patterns + local filtered = vim + .iter(existing) + :filter(ft == '*' and function(au) + return au.pattern == '*' + end or function(au) + return au.pattern ~= '*' + end) + :totable() + + if #filtered > 0 then + return Result.err('Linter already attached') + end + end + + return Result.ok(true) + end) end ----@param buf number -function M.try_attach_fmt_to_buf(buf) - if not M.check_fmt_should_attach(buf) then - return +-- Create autocmd with proper options +function AutocmdManager.create_autocmd(event, opts) + return Async.try(function() + opts = opts or {} + opts.group = M.group + + if not opts.callback and not opts.command then + return Result.err('Autocmd requires either callback or command') + end + + local id = api.nvim_create_autocmd(event, opts) + return Result.ok(id) + end) +end + +-- Attach formatters with Result +function M.try_attach_fmt_to_buf(bufnr) + local should_attach = AutocmdManager.should_attach('formatter', bufnr) + + if should_attach:is_err() then + return should_attach end - au('BufWritePre', { - group = M.group, - buffer = buf, - callback = lazy_fmt, + + return AutocmdManager.create_autocmd('BufWritePre', { + buffer = bufnr, + callback = function(args) + local result = Handlers.format(args) + if result:is_err() and vim.g.guard_debug then + vim.notify('[Guard Debug] Format failed: ' .. result.error) + end + end, + desc = 'Guard auto-format', }) end ----@param buf number ----@param events string[] ----@param ft string -function M.try_attach_lint_to_buf(buf, events, ft) - if not M.check_lint_should_attach(buf, ft) then - return +-- Attach linters with Result +function M.try_attach_lint_to_buf(bufnr, events, ft) + local should_attach = AutocmdManager.should_attach('linter', bufnr, ft) + + if should_attach:is_err() then + return should_attach end - for _, ev in ipairs(events) do - if ev == 'User GuardFmt' then - au('User', { - group = M.group, + local results = {} + + for _, event in ipairs(events) do + local result + if event == 'User GuardFmt' then + result = AutocmdManager.create_autocmd('User', { pattern = 'GuardFmt', - callback = function(opt) - if opt.buf == buf and opt.data.status == 'done' then - lazy_debounced_lint(opt) + callback = function(args) + local lint_result = Handlers.lint_after_format(args) + if lint_result:is_err() and vim.g.guard_debug then + vim.notify('[Guard Debug] Lint after format failed: ' .. lint_result.error) end end, + desc = 'Guard lint after format', }) else - au(ev, { - group = M.group, - buffer = buf, - callback = lazy_debounced_lint, + result = AutocmdManager.create_autocmd(event, { + buffer = bufnr, + callback = function(args) + local lint_result = Handlers.lint(args) + if lint_result:is_err() and vim.g.guard_debug then + vim.notify('[Guard Debug] Lint failed: ' .. lint_result.error) + end + end, + desc = 'Guard auto-lint', }) end + + table.insert(results, result) end + + -- Check if all succeeded + local failed = vim.iter(results):find(function(r) + return r:is_err() + end) + if failed then + return failed + end + + return Result.ok(results) end ----@param ft string +-- Attach to existing buffers with Result handling function M.fmt_attach_to_existing(ft) - for _, buf in ipairs(api.nvim_list_bufs()) do - if ft == '*' or vim.bo[buf].ft == ft then - M.try_attach_fmt_to_buf(buf) + local results = {} + + for _, bufnr in ipairs(api.nvim_list_bufs()) do + if api.nvim_buf_is_valid(bufnr) and (ft == '*' or vim.bo[bufnr].ft == ft) then + table.insert(results, M.try_attach_fmt_to_buf(bufnr)) end end + + return Result.ok(results) end ----@param ft string function M.lint_attach_to_existing(ft, events) - for _, buf in ipairs(api.nvim_list_bufs()) do - if ft == '*' or vim.bo[buf].ft == ft then - M.try_attach_lint_to_buf(buf, events, ft) + local results = {} + + for _, bufnr in ipairs(api.nvim_list_bufs()) do + if api.nvim_buf_is_valid(bufnr) and (ft == '*' or vim.bo[bufnr].ft == ft) then + table.insert(results, M.try_attach_lint_to_buf(bufnr, events, ft)) end end + + return Result.ok(results) end ----@param ft string ----@param formatters FmtConfig[] +-- Validate formatters and setup FileType autocmd function M.fmt_on_filetype(ft, formatters) - -- check if all cmds executable before registering formatter - iter(formatters):any(function(config) - if type(config) == 'table' and config.cmd and vim.fn.executable(config.cmd) ~= 1 then - report_error(config.cmd .. ' not executable') - return false - end - return true + -- Validate formatters + local validation_results = vim + .iter(formatters) + :map(function(config) + if type(config) == 'table' and config.cmd then + if vim.fn.executable(config.cmd) ~= 1 then + return Result.err(config.cmd .. ' not executable') + end + end + return Result.ok(config) + end) + :totable() + + -- Check if any validation failed + local first_error = vim.iter(validation_results):find(function(r) + return r:is_err() end) - au('FileType', { - group = M.group, + if first_error then + util.report_error(first_error.error) + return first_error + end + + return AutocmdManager.create_autocmd('FileType', { + pattern = ft, + callback = function(args) + local result = M.try_attach_fmt_to_buf(args.buf) + if result:is_err() and vim.g.guard_debug then + vim.notify('[Guard Debug] Failed to attach formatter: ' .. result.error) + end + end, + desc = 'Guard formatter setup for ' .. ft, + }) +end + +-- Validate linters and setup FileType autocmd +function M.lint_on_filetype(ft, events) + -- Get linter config for validation + local ft_config = require('guard.filetype')[ft] + if not ft_config or not ft_config.linter then + return Result.err('No linter configuration for ' .. ft) + end + + -- Validate linters + local validation_results = vim + .iter(ft_config.linter) + :map(function(config) + if config.cmd and vim.fn.executable(config.cmd) ~= 1 then + return Result.err(config.cmd .. ' not executable') + end + return Result.ok(config) + end) + :totable() + + -- Report all errors + vim + .iter(validation_results) + :filter(function(r) + return r:is_err() + end) + :each(function(r) + util.report_error(r.error) + end) + + return AutocmdManager.create_autocmd('FileType', { pattern = ft, callback = function(args) - M.try_attach_fmt_to_buf(args.buf) + local result = M.try_attach_lint_to_buf(args.buf, events, ft) + if result:is_err() and vim.g.guard_debug then + vim.notify('[Guard Debug] Failed to attach linter: ' .. result.error) + end end, - desc = 'guard', + desc = 'Guard linter setup for ' .. ft, }) end ----@param config table ----@param ft string ----@param buf number -function M.maybe_default_to_lsp(config, ft, buf) +-- Custom event handling with Result +function M.fmt_attach_custom(ft, events) + return Async.try(function() + M.custom_autocmds.formatter[ft] = M.custom_autocmds.formatter[ft] or {} + + local results = {} + for _, event in ipairs(events) do + local opts = vim.deepcopy(event.opt or {}) + + -- Ensure callback + if not opts.callback and not opts.command then + opts.callback = function(args) + require('guard.format').do_fmt(args.buf) + end + end + + -- Set group and description + opts.group = M.group + opts.desc = opts.desc or ('Guard custom format for %s'):format(ft) + + local result = AutocmdManager.create_autocmd(event.name, opts) + + result:match({ + ok = function(id) + table.insert(M.custom_autocmds.formatter[ft], id) + end, + err = function(err) + table.insert(results, Result.err(err)) + end, + }) + end + + -- Return first error if any + local first_error = vim.iter(results):find(function(r) + return r:is_err() + end) + return first_error or Result.ok(M.custom_autocmds.formatter[ft]) + end) +end + +function M.lint_attach_custom(ft, config) + return Async.try(function() + M.custom_autocmds.linter[ft] = M.custom_autocmds.linter[ft] or {} + + local results = {} + for _, event in ipairs(config.events) do + local opts = vim.deepcopy(event.opt or {}) + + -- Ensure callback + if not opts.callback and not opts.command then + opts.callback = function(args) + Async.async(function() + require('guard.lint').do_lint_single(args.buf, config) + end)() + end + end + + -- Set group and description + opts.group = M.group + opts.desc = opts.desc or ('Guard custom lint for %s'):format(ft) + + local result = AutocmdManager.create_autocmd(event.name, opts) + + result:match({ + ok = function(id) + table.insert(M.custom_autocmds.linter[ft], id) + end, + err = function(err) + table.insert(results, Result.err(err)) + end, + }) + end + + -- Return first error if any + local first_error = vim.iter(results):find(function(r) + return r:is_err() + end) + return first_error or Result.ok(M.custom_autocmds.linter[ft]) + end) +end + +-- LSP integration with Result +function M.maybe_default_to_lsp(config, ft, bufnr) if config.formatter then - return + return Result.ok('Formatter already configured') end - config:fmt('lsp') - if getopt('fmt_on_save') then - if - #api.nvim_get_autocmds({ + + return Async.try(function() + config:fmt('lsp') + + if util.getopt('fmt_on_save') then + -- Check if FileType autocmd already exists + local existing = api.nvim_get_autocmds({ group = M.group, event = 'FileType', pattern = ft, - }) == 0 - then - M.fmt_on_filetype(ft, config.formatter) + }) + + if #existing == 0 then + local result = M.fmt_on_filetype(ft, config.formatter) + if result:is_err() then + return result + end + end + + return M.try_attach_fmt_to_buf(bufnr) end - M.try_attach_fmt_to_buf(buf) - end + + return Result.ok('LSP formatter configured') + end) end function M.create_lspattach_autocmd() - au('LspAttach', { - group = M.group, + return AutocmdManager.create_autocmd('LspAttach', { callback = function(args) - if not getopt('lsp_as_default_formatter') then + if not util.getopt('lsp_as_default_formatter') then return end - local client = vim.lsp.get_client_by_id(args.data.client_id) - if not client or not client:supports_method('textDocument/formatting', args.data.buf) then - return + + local result = Async.try(function() + local client = vim.lsp.get_client_by_id(args.data.client_id) + if not client then + return Result.err('LSP client not found') + end + + if not client:supports_method('textDocument/formatting', args.buf) then + return Result.err("LSP client doesn't support formatting") + end + + local ft_handler = require('guard.filetype') + local ft = vim.bo[args.buf].filetype + return M.maybe_default_to_lsp(ft_handler(ft), ft, args.buf) + end) + + if result:is_err() and vim.g.guard_debug then + vim.notify('[Guard Debug] LSP setup failed: ' .. result.error) end - local ft_handler = require('guard.filetype') - local ft = vim.bo[args.buf].filetype - M.maybe_default_to_lsp(ft_handler(ft), ft, args.buf) end, + desc = 'Guard LSP formatter setup', }) end ----@param ft string ----@param events string[] -function M.lint_on_filetype(ft, events) - iter(require('guard.filetype')[ft].linter):any(function(config) - if config.cmd and vim.fn.executable(config.cmd) ~= 1 then - report_error(config.cmd .. ' not executable') - end - return true - end) - - au('FileType', { - pattern = ft, - group = M.group, - callback = function(args) - M.try_attach_lint_to_buf(args.buf, events, ft) - end, - }) +-- Public API for getting autocmds +function M.get_format_autocmds(bufnr) + return AutocmdManager.get_autocmds('formatter', bufnr, { 'BufWritePre' }) end ----@param events EventOption[] ----@param ft string -function M.fmt_attach_custom(ft, events) - M.user_fmt_autocmds[ft] = {} - -- we don't know what autocmds are passed in, so these are attached asap - iter(events):each(function(event) - table.insert( - M.user_fmt_autocmds[ft], - api.nvim_create_autocmd( - event.name, - maybe_fill_auoption(event.opt or {}, function(opt) - require('guard.format').do_fmt(opt.buf) - end) - ) - ) - end) +function M.get_lint_autocmds(bufnr) + return AutocmdManager.get_autocmds('linter', bufnr) end ----@param config LintConfig ----@param ft string -function M.lint_attach_custom(ft, config) - M.user_lint_autocmds[ft] = {} - -- we don't know what autocmds are passed in, so these are attached asap - iter(config.events):each(function(event) - table.insert( - M.user_lint_autocmds[ft], - api.nvim_create_autocmd( - event.name, - maybe_fill_auoption(event.opt or {}, function(opt) - coroutine.resume(coroutine.create(function() - require('guard.lint').do_lint_single(opt.buf, config) - end)) - end) - ) - ) +-- Cleanup on plugin unload +function M.cleanup() + return Async.try(function() + -- Cancel all pending lint operations + lint_debouncer:cancel_all() + + -- Clear all autocmds in the group + api.nvim_clear_autocmds({ group = M.group }) + + -- Clear custom autocmd tracking + M.custom_autocmds = { + formatter = {}, + linter = {}, + } + + return Result.ok('Cleanup completed') end) end diff --git a/lua/guard/filetype.lua b/lua/guard/filetype.lua index 1b81059..711d6b9 100644 --- a/lua/guard/filetype.lua +++ b/lua/guard/filetype.lua @@ -1,199 +1,260 @@ +local lib = require('guard.lib') +local Result = lib.Result local util = require('guard.util') + local M = {} -local function get_tool(tool_type, tool_name) +-- Tool loader with Result error handling +local ToolLoader = {} + +function ToolLoader.get(tool_type, tool_name) if tool_name == 'lsp' then - return { fn = require('guard.lsp').format } + return Result.ok({ fn = require('guard.lsp').format }) end - local ok, tbl = pcall(require, 'guard-collection.' .. tool_type) + + local ok, collection = pcall(require, 'guard-collection.' .. tool_type) if not ok then - vim.notify( - ('[Guard]: "%s": needs nvimdev/guard-collection to access builtin configuration'):format( + return Result.err( + string.format( + '"%s" needs nvimdev/guard-collection to access builtin configuration', tool_name - ), - 4 + ) ) - return {} end - if not tbl[tool_name] then - vim.notify(('[Guard]: %s %s has no builtin configuration'):format(tool_type, tool_name), 4) - return {} + + if not collection[tool_name] then + return Result.err(string.format('%s "%s" has no builtin configuration', tool_type, tool_name)) end - return tbl[tool_name] + + return Result.ok(collection[tool_name]) end ----@return FmtConfig|LintConfig -local function try_as(tool_type, config) + +function ToolLoader.resolve(tool_type, config) if type(config) == 'function' then - return config - end - if type(config) == 'table' then - return config + return Result.ok(config) + elseif type(config) == 'table' then + return Result.ok(config) + elseif type(config) == 'string' then + return ToolLoader.get(tool_type, config) else - return get_tool(tool_type, config) - end -end ----@param val any ----@param expected string[] ----@return boolean -local function check_type(val, expected) - if not vim.tbl_contains(expected, type(val)) then - vim.notify( - ('[guard]: %s is %s, expected %s'):format( - vim.inspect(val), - type(val), - table.concat(expected, '/') + return Result.err( + string.format( + 'Invalid %s config type: %s (expected string/table/function)', + tool_type, + type(config) ) ) - return false end - return true end -local function box(ft) - local current - local tbl = {} - local ft_tbl = ft:find(',') and vim.split(ft, ',') or { ft } - tbl.__index = tbl +-- Filetype configuration builder +local FiletypeConfig = {} +FiletypeConfig.__index = FiletypeConfig - function tbl:ft() - return ft_tbl +function FiletypeConfig.new(filetypes) + local ft_string = type(filetypes) == 'table' and table.concat(filetypes, ',') or filetypes + local ft_list = vim.split(ft_string, ',', { trimempty = true }) + + return setmetatable({ + _filetypes = ft_list, + _original_ft = ft_string, + _current_tool = nil, + formatter = {}, + linter = {}, + }, FiletypeConfig) +end + +-- Get list of filetypes +function FiletypeConfig:filetypes() + return self._filetypes +end + +-- Generic tool setup +function FiletypeConfig:_setup_tool(tool_type, config) + local result = ToolLoader.resolve(tool_type, config) + + if result:is_err() then + vim.notify('[Guard]: ' .. result.error, vim.log.levels.WARN) + return self end - function tbl:fmt(config) - if not check_type(config, { 'table', 'string', 'function' }) then - return - end - current = 'formatter' - self.formatter = { - util.toolcopy(try_as('formatter', config)), - } - local events = require('guard.events') - for _, it in ipairs(self:ft()) do - if it ~= ft then - M[it] = box(it) - M[it].formatter = self.formatter - end + -- Set current tool type and initialize + self._current_tool = tool_type + self[tool_type] = { util.toolcopy(result.value) } - if type(config) == 'table' and config.events then - -- use user's custom events - events.fmt_attach_custom(it, config.events) + -- Setup events + self:_attach_events(tool_type, config) + + -- Copy to other filetypes if needed + if #self._filetypes > 1 then + self:_propagate_to_filetypes(tool_type) + end + + return self +end + +-- Attach events for tools +function FiletypeConfig:_attach_events(tool_type, config) + local events = require('guard.events') + + for _, ft in ipairs(self._filetypes) do + if type(config) == 'table' and config.events then + -- Custom events + if tool_type == 'formatter' then + events.fmt_attach_custom(ft, config.events) + else + events.lint_attach_custom(ft, config) + end + else + -- Default events + if tool_type == 'formatter' then + events.fmt_on_filetype(ft, self.formatter) + events.fmt_attach_to_existing(ft) else - events.fmt_on_filetype(it, self.formatter) - events.fmt_attach_to_existing(it) + local evs = util.linter_events(config) + events.lint_on_filetype(ft, evs) + events.lint_attach_to_existing(ft, evs) end end + end +end - if ft:find(',') then - M[ft] = nil +-- Propagate configuration to related filetypes +function FiletypeConfig:_propagate_to_filetypes(tool_type) + for _, ft in ipairs(self._filetypes) do + if ft ~= self._original_ft then + if not M[ft] then + M[ft] = FiletypeConfig.new(ft) + end + M[ft][tool_type] = self[tool_type] end + end + + -- Clean up composite filetype entry + if self._original_ft:find(',') then + M[self._original_ft] = nil + end +end + +-- Public API methods +function FiletypeConfig:fmt(config) + return self:_setup_tool('formatter', config) +end + +function FiletypeConfig:lint(config) + return self:_setup_tool('linter', config) +end + +function FiletypeConfig:append(config) + if not self._current_tool then + vim.notify('[Guard]: No tool selected to append to', vim.log.levels.WARN) return self end - function tbl:lint(config) - if not check_type(config, { 'table', 'string', 'function' }) then - return - end - current = 'linter' - self.linter = { - util.toolcopy(try_as('linter', config)), - } - local events = require('guard.events') - local evs = util.linter_events(config) - for _, it in ipairs(self:ft()) do - if it ~= ft then - M[it] = box(it) - M[it].linter = self.linter - end + local result = ToolLoader.resolve(self._current_tool, config) + if result:is_err() then + vim.notify('[Guard]: ' .. result.error, vim.log.levels.WARN) + return self + end - if type(config) == 'table' and config.events then - -- use user's custom events - events.lint_attach_custom(it, config) - else - events.lint_on_filetype(it, evs) - events.lint_attach_to_existing(it, evs) - end + local tool = util.toolcopy(result.value) + table.insert(self[self._current_tool], tool) + + -- Handle custom events for linters + if self._current_tool == 'linter' and type(config) == 'table' and config.events then + for _, ft in ipairs(self._filetypes) do + require('guard.events').lint_attach_custom(ft, config) end + end + + return self +end + +function FiletypeConfig:extra(...) + if not self._current_tool then return self end - function tbl:append(config) - if not check_type(config, { 'table', 'string', 'function' }) then - return - end - local c = try_as(current, config) - self[current][#self[current] + 1] = c + local tools = self[self._current_tool] + if #tools == 0 then + return self + end - if current == 'linter' and type(c) == 'table' and c.events then - for _, it in ipairs(self:ft()) do - require('guard.events').lint_attach_custom(it, config.events) - end - end + local tool = tools[#tools] + tool.args = vim.list_extend(tool.args or {}, { ... }) + return self +end +function FiletypeConfig:env(env_table) + if type(env_table) ~= 'table' or vim.tbl_count(env_table) == 0 then return self end - function tbl:extra(...) - local tool = self[current][#self[current]] - tool.args = vim.list_extend({ ... }, tool.args or {}) + if not self._current_tool then return self end - function tbl:env(env) - if not check_type(env, { 'table' }) then - return - end - if vim.tbl_count(env) == 0 then - return self - 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)) - end + local tools = self[self._current_tool] + if #tools == 0 then return self end - function tbl:key_alias(key) - local _t = { - ['lint'] = function() - self.linter = {} - return self.linter - end, - ['fmt'] = function() - self.formatter = {} - return self.formatter - end, - } - return _t[key]() - end - - function tbl:register(key, cfg) - vim.validate({ - key = { - key, - function(val) - local available = { 'lint', 'fmt' } - return vim.tbl_contains(available, val) - end, - }, - }) - local target = self:key_alias(key) - local tool_type = key == 'fmt' and 'formatter' or 'linter' - for _, item in ipairs(cfg) do - target[#target + 1] = util.toolcopy(try_as(tool_type, item)) + local tool = tools[#tools] + tool.env = env_table + return self +end + +-- Check if buffer is valid for formatting/linting +function FiletypeConfig:valid_buf(bufnr) + local bufname = vim.api.nvim_buf_get_name(bufnr) + + -- Check both formatters and linters + local check_tools = function(tools) + if not tools or #tools == 0 then + return true end + + return vim.iter(tools):all(function(tool) + if type(tool) ~= 'table' or not tool.ignore_patterns then + return true + end + + local patterns = util.as_table(tool.ignore_patterns) + return not vim.iter(patterns):any(function(pattern) + return bufname:find(pattern) ~= nil + end) + end) end - return setmetatable({}, tbl) + return check_tools(self.formatter) and check_tools(self.linter) +end + +-- Check if configuration has any tools +function FiletypeConfig:has_tools() + return (#self.formatter > 0) or (#self.linter > 0) end +-- Get all configured tools +function FiletypeConfig:get_tools(tool_type) + if tool_type then + return self[tool_type] or {} + end + + return { + formatter = self.formatter, + linter = self.linter, + } +end + +-- Module metatable for convenient access return setmetatable(M, { - __call = function(_self, ft) - if not rawget(_self, ft) then - rawset(_self, ft, box(ft)) + __call = function(_, filetypes) + local key = type(filetypes) == 'table' and table.concat(filetypes, ',') or filetypes + + if not M[key] then + M[key] = FiletypeConfig.new(filetypes) end - return _self[ft] + + return M[key] end, }) diff --git a/lua/guard/format.lua b/lua/guard/format.lua index e6042af..262b604 100644 --- a/lua/guard/format.lua +++ b/lua/guard/format.lua @@ -1,246 +1,447 @@ -local spawn = require('guard.spawn') +local lib = require('guard.lib') +local Result = lib.Result +local Async = lib.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 function save_views(bufnr) - local views = {} - for _, win in ipairs(vim.fn.win_findbuf(bufnr)) do - views[win] = api.nvim_win_call(win, vim.fn.winsaveview) - end - return views +local M = {} + +local BufferState = {} +BufferState.__index = BufferState + +function BufferState.new(bufnr) + return setmetatable({ + bufnr = bufnr, + changedtick = -1, + }, BufferState) end -local function restore_views(views) - for win, view in pairs(views) do - api.nvim_win_call(win, function() - vim.fn.winrestview(view) - end) +function BufferState:save_changedtick() + self.changedtick = api.nvim_buf_get_changedtick(self.bufnr) + return self +end + +function BufferState:is_modified() + return api.nvim_buf_get_changedtick(self.bufnr) ~= self.changedtick +end + +function BufferState:is_valid() + return api.nvim_buf_is_valid(self.bufnr) +end + +-- Format context to track the formatting session +local FormatContext = {} +FormatContext.__index = FormatContext + +function FormatContext.new(bufnr, range, mode) + local self = setmetatable({ + bufnr = bufnr, + range = range, + mode = mode, + state = BufferState.new(bufnr), + ctx = Async.context(), + }, FormatContext) + + -- Calculate row boundaries + if range then + self.start_row = range.start[1] - 1 + self.end_row = range['end'][1] + else + self.start_row = 0 + self.end_row = -1 end + + return self end -local function update_buffer(bufnr, prev_lines, new_lines, srow, erow, old_indent) - if not new_lines or #new_lines == 0 then - return +function FormatContext:get_lines() + -- Ensure we get fresh lines from buffer + return api.nvim_buf_get_lines(self.bufnr, self.start_row, self.end_row, false) +end + +function FormatContext:get_text() + local lines = self:get_lines() + -- Preserve the exact text structure including trailing newlines + return table.concat(lines, '\n') +end + +function FormatContext:cancel() + self.ctx:cancel() +end + +-- Formatter types and runners +local Formatters = {} + +-- Formatter categories: +-- 1. Pure formatters: Can process text without side effects +-- - Functions: config.fn +-- - Commands with stdin/stdout: config.cmd + config.stdin +-- 2. Impure formatters: Modify files directly +-- - Commands without stdin: config.cmd + !config.stdin +-- These MUST run last to avoid conflicts + +-- Run a formatter function (pure) +function Formatters.run_function(bufnr, range, config, input) + return Async.try(function() + return config.fn(bufnr, range, input) + end) +end + +-- Run a command formatter +function Formatters.run_command(config, input, fname, cwd) + local cmd = util.get_cmd(config, fname) + + return Async.system(cmd, { + stdin = input, -- nil for impure formatters + cwd = cwd, + env = config.env, + timeout = config.timeout, + }) +end + +-- Process and categorize formatter configurations +function Formatters.prepare_configs(ft_conf, bufnr) + local configs = util.eval(ft_conf.formatter) + + -- Filter by run conditions + configs = vim.tbl_filter(function(config) + return util.should_run(config, bufnr) + end, configs) + + -- Check executables + local errors = {} + for _, config in ipairs(configs) do + if config.cmd and vim.fn.executable(config.cmd) ~= 1 then + table.insert(errors, config.cmd .. ' not executable') + end 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 + if #errors > 0 then + return Result.err(table.concat(errors, '\n')) end - if not vim.deep_equal(new_lines, prev_lines) then - api.nvim_buf_set_lines(bufnr, srow, erow, false, new_lines) - if util.getopt('save_on_fmt') then - api.nvim_command('silent! noautocmd write!') - end - if old_indent then - vim.cmd(('silent %d,%dleft'):format(srow + 1, erow)) + -- Categorize formatters + -- Pure: process text in memory (functions or stdin/stdout commands) + -- Impure: modify files directly (commands without stdin) + local categorized = { + all = configs, + pure = {}, + impure = {}, + } + + for _, config in ipairs(configs) do + if config.fn or (config.cmd and config.stdin) then + table.insert(categorized.pure, config) + elseif config.cmd and not config.stdin then + table.insert(categorized.impure, config) end - restore_views(views) end + + return Result.ok(categorized) end -local function fail(msg) - util.doau('GuardFmt', { - status = 'failed', - msg = msg, - }) - vim.notify('[Guard]: ' .. msg, vim.log.levels.WARN) +-- Check if formatters support range formatting +function Formatters.validate_range_support(configs, range) + if not range then + return Result.ok(true) + end + + -- Range formatting requires all formatters to be pure (stdin capable) + if #configs.impure > 0 then + local impure_cmds = vim.tbl_map(function(c) + return c.cmd + end, configs.impure) + return Result.err({ + message = 'Cannot apply range formatting', + details = table.concat(impure_cmds, ', ') .. ' does not support reading from stdin', + }) + end + + return Result.ok(true) end -local function do_fmt(buf) - buf = buf or api.nvim_get_current_buf() - local ft_conf = filetype[vim.bo[buf].filetype] +-- Apply formatted text to buffer +local function apply_buffer_changes(ctx, original_lines, formatted_text) + if not formatted_text or formatted_text == '' then + return Result.ok(false) + end - if not ft_conf or not ft_conf.formatter then - util.report_error('missing config for filetype ' .. vim.bo[buf].filetype) - return + -- Split formatted text into lines, handling Windows line endings + local new_lines = vim.split(formatted_text, '\r?\n', { plain = false }) + + -- vim.split with empty string returns {""}, handle this case + if #new_lines == 1 and new_lines[1] == '' then + new_lines = {} end - -- 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] + -- Remove trailing empty line if it exists (common with formatters) + -- This matches the original behavior + if #new_lines > 0 and new_lines[#new_lines] == '' then + table.remove(new_lines) end - -- best effort indent preserving - ---@type number? - local old_indent - if mode == 'V' then - old_indent = vim.fn.indent(srow + 1) + -- Check if content actually changed by comparing line arrays + if vim.deep_equal(new_lines, original_lines) then + return Result.ok(false) end - -- init environment - ---@type FmtConfig[] - local fmt_configs = util.eval(ft_conf.formatter) - local fname, cwd = util.buf_get_info(buf) + -- Debug logging + if vim.g.guard_debug then + vim.notify( + string.format( + '[Guard Debug] Applying changes:\n' + .. ' Range: [%d, %d]\n' + .. ' Original lines: %d\n' + .. ' New lines: %d\n' + .. ' First orig line: %s\n' + .. ' First new line: %s', + ctx.start_row, + ctx.end_row, + #original_lines, + #new_lines, + vim.inspect(original_lines[1] or ''), + vim.inspect(new_lines[1] or '') + ) + ) + end - -- handle execution condition - fmt_configs = filter(function(config) - return util.should_run(config, buf) - end, fmt_configs) + api.nvim_buf_set_lines(ctx.bufnr, ctx.start_row, ctx.end_row, false, new_lines) - -- check if all cmds executable again, since user can call format manually - local all_executable = not iter(fmt_configs):any(function(config) - if config.cmd and vim.fn.executable(config.cmd) ~= 1 then - util.report_error(config.cmd .. ' not executable') - return true - end - return false - end) + -- Save if configured + if util.getopt('save_on_fmt') then + vim.cmd('silent! noautocmd write!') + end - if not all_executable then - return + -- Restore indent if needed for visual line mode + if ctx.preserve_indent and ctx.preserve_indent > 0 then + local new_end_row = ctx.start_row + #new_lines + vim.cmd(('silent %d,%dleft %d'):format(ctx.start_row + 1, new_end_row, ctx.preserve_indent)) 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 - 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') - return + return Result.ok(true) +end + +-- Send format event +local function send_event(status, data) + util.doau('GuardFmt', vim.tbl_extend('force', { status = status }, data or {})) +end + +-- Format notification with optional debug info +local function notify_error(msg, debug_info) + send_event('failed', { msg = msg }) + if debug_info and vim.g.guard_debug then + vim.notify( + string.format('[Guard]: %s\nDebug: %s', msg, vim.inspect(debug_info)), + vim.log.levels.WARN + ) + else + vim.notify('[Guard]: ' .. msg, vim.log.levels.WARN) end +end - -- actually start formatting - util.doau('GuardFmt', { - status = 'pending', - using = fmt_configs, - }) +-- Helper to create a diagnostic formatter for testing +function M._create_diagnostic_formatter() + return { + fn = function(bufnr, range, text) + local lines = vim.split(text, '\n') + local info = { + bufnr = bufnr, + range = range, + input_lines = #lines, + input_text = text, + formatted_text = text, -- Return unchanged for diagnosis + } - local prev_lines = table.concat(api.nvim_buf_get_lines(buf, srow, erow, false), '\n') - local new_lines = prev_lines - local errno = nil + if vim.g.guard_debug then + vim.notify('Diagnostic formatter: ' .. vim.inspect(info)) + end - 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 - vim.schedule(function() - if api.nvim_buf_get_changedtick(buf) ~= changedtick then - errno = { reason = 'buffer changed' } - end - end) - if errno then - return '' + return text + end, + } +end + +-- Main formatting pipeline +local format_buffer = Async.callback(function(ctx, configs, fname, cwd) + -- Get original lines and text + local original_lines = ctx:get_lines() + local original_text = table.concat(original_lines, '\n') + local formatted_text = original_text + + -- Phase 1: Run all pure formatters in sequence + -- These transform text in memory without side effects + for i, config in ipairs(configs.pure) do + -- Check buffer state before each formatter + if ctx.state:is_modified() then + return Result.err('Buffer changed during formatting') + end + + if vim.g.guard_debug then + vim.notify( + string.format( + '[Guard Debug] Running formatter %d/%d: %s', + i, + #configs.pure, + config.cmd or 'function' + ) + ) + end + + local result + if config.fn then + result = Formatters.run_function(ctx.bufnr, ctx.range, config, formatted_text) + else + result = Async.await(ctx.ctx:run(Formatters.run_command(config, formatted_text, fname, cwd))) + end + + if result:is_err() then + local err = result.error + if err.type == Async.Error.COMMAND_FAILED then + local details = err.details + return Result.err( + string.format( + '%s exited with code %d\n%s', + details.cmd, + details.code, + details.stderr or '' + ) + ) end + return result + 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), 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 + -- Update formatted text for next formatter in chain + if config.cmd then + formatted_text = result.value.stdout + else + formatted_text = result.value + end + end + + -- Final buffer state check before applying changes + if ctx.state:is_modified() then + return Result.err('Buffer changed during formatting') + end + + if not ctx.state:is_valid() then + return Result.err('Buffer no longer valid') + end + + -- Apply pure formatter changes to buffer + -- Pass original_lines (array) instead of original_text (string) + local apply_result = apply_buffer_changes(ctx, original_lines, formatted_text) + if apply_result:is_err() then + return apply_result + end + + -- Phase 2: Run impure formatters (if any) + -- These modify the file directly and must run after buffer is saved + if #configs.impure > 0 then + -- Ensure buffer is saved before running impure formatters + if apply_result.value or vim.bo[ctx.bufnr].modified then + vim.cmd('silent! write!') + end + + for i, config in ipairs(configs.impure) do + if vim.g.guard_debug then + vim.notify( + string.format( + '[Guard Debug] Running impure formatter %d/%d: %s', + i, + #configs.impure, + config.cmd + ) + ) end - end) - local co = assert(coroutine.running()) - - 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) + local result = Async.await(ctx.ctx:run(Formatters.run_command(config, nil, fname, cwd))) + + if result:is_err() then + local err = result.error + if err.type == Async.Error.COMMAND_FAILED then + local details = err.details + return Result.err( + string.format( + '%s exited with code %d\n%s', + details.cmd, + details.code, + details.stderr or '' + ) + ) end - 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 + return result end - update_buffer(buf, prev_lines, new_lines, srow, erow, old_indent) - coroutine.resume(co) - end) + end + end - -- wait until substitution is finished - coroutine.yield() + return Result.ok({ + changed = apply_result.value or #configs.impure > 0, + }) +end) - impure:fold(nil, function(_, config, _) - if errno then - return - end +-- Public API +function M.do_fmt(bufnr) + bufnr = bufnr or api.nvim_get_current_buf() - 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) + local ft = vim.bo[bufnr].filetype + local ft_conf = filetype[ft] - coroutine.yield() - end) + if not ft_conf or not ft_conf.formatter then + notify_error('missing config for filetype ' .. ft) + return + end - if errno then - fail(('%s exited with code %d\n%s'):format(errno.cmd, errno.code, errno.stderr)) - return - end + -- Determine format range and mode + local mode = api.nvim_get_mode().mode + local range = nil - -- refresh buffer - if impure and #impure:totable() > 0 then - vim.schedule(function() - api.nvim_buf_call(buf, function() - local views = save_views(buf) - api.nvim_command('silent! edit!') - restore_views(views) - end) - end) - end + if mode == 'V' or mode == 'v' then + range = util.range_from_selection(bufnr, mode) + end - util.doau('GuardFmt', { - status = 'done', - }) - if util.getopt('refresh_diagnostic') then - vim.diagnostic.show() - end - end)) + -- Create format context + local ctx = FormatContext.new(bufnr, range, mode) + + -- Prepare formatters + local prepare_result = Formatters.prepare_configs(ft_conf, bufnr) + if prepare_result:is_err() then + notify_error(prepare_result.error) + return + end + + local configs = prepare_result.value + + -- Validate range formatting support + local range_validation = Formatters.validate_range_support(configs, range) + if range_validation:is_err() then + local err = range_validation.error + notify_error(string.format('%s\n%s', err.message, err.details)) + return + end + + local fname, cwd = util.buf_get_info(bufnr) + send_event('pending', { using = configs.all }) + + -- Schedule state initialization to avoid tick change from BufWritePre + vim.schedule(function() + ctx.state:save_changedtick() + + -- Run the formatting pipeline + format_buffer(ctx, configs, fname, cwd)(function(result) + result:match({ + ok = function(value) + send_event('done', value) + if util.getopt('refresh_diagnostic') then + vim.diagnostic.show() + end + end, + err = function(err) + notify_error(err) + end, + }) + end) + end) end -return { - do_fmt = do_fmt, -} +return M diff --git a/lua/guard/lib.lua b/lua/guard/lib.lua new file mode 100644 index 0000000..633e484 --- /dev/null +++ b/lua/guard/lib.lua @@ -0,0 +1,444 @@ +local M = {} + +-- Result type for error handling +local Result = {} +Result.__index = Result +M.Result = Result + +function Result.ok(value) + return setmetatable({ ok = true, value = value, error = nil }, Result) +end + +function Result.err(error) + return setmetatable({ ok = false, value = nil, error = error }, Result) +end + +-- Chain operations on Result +function Result:and_then(func) + if not self.ok then + return self + end + + local status, result = pcall(func, self.value) + if not status then + return Result.err(result) + end + + -- If func returns a Result, return it directly + if getmetatable(result) == Result then + return result + end + + return Result.ok(result) +end + +-- Map over the success value +function Result:map(func) + if not self.ok then + return self + end + + local status, result = pcall(func, self.value) + if not status then + return Result.err(result) + end + + return Result.ok(result) +end + +-- Map over the error value +function Result:map_err(func) + if self.ok then + return self + end + + local status, result = pcall(func, self.error) + if not status then + return Result.err(result) + end + + return Result.err(result) +end + +-- Match pattern for Result +function Result:match(handlers) + if self.ok and handlers.ok then + return handlers.ok(self.value) + elseif not self.ok and handlers.err then + return handlers.err(self.error) + end +end + +-- Get value or default +function Result:unwrap_or(default) + return self.ok and self.value or default +end + +-- Get value or compute default +function Result:unwrap_or_else(func) + if self.ok then + return self.value + end + return func(self.error) +end + +-- Get value or panic +function Result:unwrap() + if self.ok then + return self.value + end + error(vim.inspect(self.error)) +end + +-- Get error or panic +function Result:unwrap_err() + if not self.ok then + return self.error + end + error('called `unwrap_err()` on an `ok` value') +end + +-- Check if is ok +function Result:is_ok() + return self.ok +end + +-- Check if is error +function Result:is_err() + return not self.ok +end + +-- Async utilities +local Async = {} +M.Async = Async + +-- Error types +Async.Error = { + COMMAND_FAILED = 'COMMAND_FAILED', + TIMEOUT = 'TIMEOUT', + CANCELLED = 'CANCELLED', + INVALID_STATE = 'INVALID_STATE', + COROUTINE_ERROR = 'COROUTINE_ERROR', +} + +-- Create structured error +function Async.error(type, message, details) + return { + type = type, + message = message, + details = details or {}, + timestamp = os.time(), + } +end + +-- Wrap a function to return a Result +function Async.wrap(func) + return function(...) + local args = { ... } + local status, result = pcall(func, unpack(args)) + if status then + return Result.ok(result) + else + return Result.err(result) + end + end +end + +-- Try/catch style error handling +function Async.try(func) + return Async.wrap(func)() +end + +-- Enhanced vim.system wrapper with timeout and cancellation +function Async.system(cmd, opts) + opts = opts or {} + return function(callback) + local progress_data = {} + local error_data = {} + local stderr_callback = opts.stderr + + -- Setup options + local system_opts = vim.deepcopy(opts) + + -- Capture stderr for progress if requested + if stderr_callback then + system_opts.stderr = function(_, data) + if data then + table.insert(error_data, data) + stderr_callback(_, data) + end + end + end + + -- Call vim.system with proper error handling + vim.system(cmd, system_opts, function(obj) + -- Success is 0 exit code + local success = obj.code == 0 + + if success then + callback(Result.ok({ + stdout = obj.stdout, + stderr = obj.stderr, + code = obj.code, + signal = obj.signal, + progress = progress_data, + })) + else + callback(Result.err({ + message = 'Command failed with exit code: ' .. obj.code, + stdout = obj.stdout, + stderr = obj.stderr, + code = obj.code, + signal = obj.signal, + progress = progress_data, + })) + end + end) + end +end + +-- Create an async context that tracks running operations +function Async.context() + local ctx = { + operations = {}, + cancelled = false, + } + + function ctx:run(promise) + if self.cancelled then + return Result.err(Async.error(Async.Error.CANCELLED, 'Context was cancelled')) + end + + local cancel_fn + local wrapped = function(callback) + if self.cancelled then + callback(Result.err(Async.error(Async.Error.CANCELLED, 'Context was cancelled'))) + return + end + + cancel_fn = promise(function(result) + self.operations[promise] = nil + callback(result) + end) + + if cancel_fn then + self.operations[promise] = cancel_fn + end + end + + return wrapped + end + + function ctx:cancel() + self.cancelled = true + for _, cancel_fn in pairs(self.operations) do + if type(cancel_fn) == 'function' then + cancel_fn() + end + end + self.operations = {} + end + + return ctx +end + +-- Await a promise and return Result +function Async.await(promise) + local co = coroutine.running() + if not co then + return Result.err( + Async.error(Async.Error.INVALID_STATE, 'Cannot await outside of an async function') + ) + end + + promise(function(result) + vim.schedule(function() + local ok, err = coroutine.resume(co, result) + if not ok then + vim.notify( + string.format('Coroutine error: %s', debug.traceback(co, err)), + vim.log.levels.ERROR + ) + end + end) + end) + + return coroutine.yield() +end + +-- Create an async function +function Async.async(func) + return function(...) + local args = { ... } + local co = coroutine.create(function() + return Async.try(function() + return func(unpack(args)) + end) + end) + + local function step(...) + local ok, value = coroutine.resume(co, ...) + if not ok then + vim.schedule(function() + vim.notify( + string.format('Async error: %s', debug.traceback(co, value)), + vim.log.levels.ERROR + ) + end) + end + return ok, value + end + + step() + end +end + +-- Create a callback-based async function +function Async.callback(func) + return function(...) + local args = { ... } + return function(callback) + coroutine.wrap(function() + local result = Async.try(function() + return func(unpack(args)) + end) + callback(result) + end)() + end + end +end + +-- Run multiple promises in parallel +function Async.all(promises) + return function(callback) + if #promises == 0 then + callback(Result.ok({})) + return + end + + local results = {} + local completed = 0 + local has_error = false + + for i, promise in ipairs(promises) do + promise(function(result) + if has_error then + return + end + + if result:is_err() then + has_error = true + callback(result) + return + end + + results[i] = result.value + completed = completed + 1 + + if completed == #promises then + callback(Result.ok(results)) + end + end) + end + end +end + +-- Run multiple promises and collect all results (including errors) +function Async.all_settled(promises) + return function(callback) + if #promises == 0 then + callback(Result.ok({})) + return + end + + local results = {} + local completed = 0 + + for i, promise in ipairs(promises) do + promise(function(result) + results[i] = result + completed = completed + 1 + + if completed == #promises then + callback(Result.ok(results)) + end + end) + end + end +end + +-- Race multiple promises +function Async.race(promises) + return function(callback) + local resolved = false + + for _, promise in ipairs(promises) do + promise(function(result) + if not resolved then + resolved = true + callback(result) + end + end) + end + end +end + +-- Create a promise that resolves after a delay +function Async.delay(ms) + return function(callback) + local timer = assert(vim.uv.new_timer()) + timer:start(ms, 0, function() + timer:stop() + timer:close() + vim.schedule(function() + callback(Result.ok(nil)) + end) + end) + end +end + +-- Convenience functions +function Async.resolve(value) + return function(callback) + vim.schedule(function() + callback(Result.ok(value)) + end) + end +end + +function Async.reject(error) + return function(callback) + vim.schedule(function() + callback(Result.err(error)) + end) + end +end + +-- Pipe multiple async operations +function Async.pipe(...) + local operations = { ... } + + return function(initial_value) + return function(callback) + local function process(index, value) + if index > #operations then + callback(Result.ok(value)) + return + end + + local op = operations[index] + op(value)(function(result) + if result:is_err() then + callback(result) + else + process(index + 1, result.value) + end + end) + end + + process(1, initial_value) + end + end +end + +return M diff --git a/lua/guard/lint.lua b/lua/guard/lint.lua index 05a6025..eaefbde 100644 --- a/lua/guard/lint.lua +++ b/lua/guard/lint.lua @@ -1,103 +1,230 @@ -local api = vim.api +local lib = require('guard.lib') +local Result = lib.Result +local Async = lib.Async local util = require('guard.util') -local spawn = require('guard.spawn') +local filetype = require('guard.filetype') +local api = vim.api local vd = vim.diagnostic -local ft = require('guard.filetype') local M = {} + local ns = api.nvim_create_namespace('Guard') local custom_ns = {} ----@param buf number? -function M.do_lint(buf) - buf = buf or api.nvim_get_current_buf() - ---@type LintConfig[] +-- Lint context for managing lint session +local LintContext = {} +LintContext.__index = LintContext - local linters = util.eval( - vim.tbl_map( - util.toolcopy, - (vim.tbl_get(ft, vim.bo[buf].filetype, 'linter') or vim.tbl_get(ft, '*', 'linter')) - ) - ) +function LintContext.new(bufnr) + return setmetatable({ + bufnr = bufnr, + ft = vim.bo[bufnr].filetype, + ctx = Async.context(), + }, LintContext) +end - linters = vim.tbl_filter(function(config) - return util.should_run(config, buf) - end, linters) +function LintContext:cancel() + self.ctx:cancel() +end - coroutine.resume(coroutine.create(function() - vd.reset(ns, buf) - vim.iter(linters):each(function(linter) - M.do_lint_single(buf, linter) - end) - end)) +-- Linter runners +local Linters = {} + +-- Run a linter function +function Linters.run_function(lines, config) + return Async.try(function() + return config.fn(lines) + end) end ----@param buf number ----@param config LintConfig -function M.do_lint_single(buf, config) - local lint = util.eval1(config) - local custom = config.events ~= nil +-- Run a linter command +function Linters.run_command(config, lines, fname, cwd) + local cmd = util.get_cmd(config, fname) - -- check run condition - local fname, cwd = util.buf_get_info(buf) - if not util.should_run(lint, buf) then - return - end + return Async.system(cmd, { + stdin = config.stdin and table.concat(lines, '\n') or nil, + cwd = cwd, + env = config.env, + timeout = config.timeout, + }) +end - local prev_lines = api.nvim_buf_get_lines(buf, 0, -1, false) - if custom and not custom_ns[config] then +-- Get or create namespace for custom event linters +local function get_namespace(config) + if config.events and not custom_ns[config] then custom_ns[config] = api.nvim_create_namespace(tostring(config)) end - local cns = custom and custom_ns[config] or ns + return config.events and custom_ns[config] or ns +end - if custom then - vd.reset(cns, buf) +-- Parse linter output into diagnostics +local function parse_diagnostics(config, output, bufnr) + if not output or output == '' then + return Result.ok({}) end - local results = {} - ---@type string - local data + return Async.try(function() + return config.parse(output, bufnr) + end) +end + +-- Apply diagnostics to buffer +local function apply_diagnostics(ctx, namespace, diagnostics, is_custom) + vim.schedule(function() + if not api.nvim_buf_is_valid(ctx.bufnr) then + return + end + + if is_custom then + -- Custom linters clear their own namespace + vd.reset(namespace, ctx.bufnr) + vd.set(namespace, ctx.bufnr, diagnostics) + else + -- Default linters merge with existing diagnostics + local existing = vd.get(ctx.bufnr) + vim.list_extend(diagnostics, existing) + vd.set(namespace, ctx.bufnr, diagnostics) + end + end) +end + +-- Run a single linter +local run_single_linter = Async.callback(function(ctx, config, lines, fname, cwd) + -- Evaluate config if it's a function + local lint_config = util.eval1(config) + + -- Check if should run + if not util.should_run(lint_config, ctx.bufnr) then + return Result.ok({ diagnostics = {}, skipped = true }) + end - if lint.cmd then - local out = spawn.transform(util.get_cmd(lint, fname), cwd, lint, prev_lines) + -- Get namespace + local namespace = get_namespace(config) + local is_custom = config.events ~= nil - -- TODO: unify this error handling logic with formatter - if type(out) == 'table' then - -- indicates error + -- Clear custom namespace diagnostics + if is_custom then + vd.reset(namespace, ctx.bufnr) + end + + -- Run linter + local output_result + if lint_config.fn then + output_result = Linters.run_function(lines, lint_config) + else + output_result = Async.await(ctx.ctx:run(Linters.run_command(lint_config, lines, fname, cwd))) + end + + -- Handle errors + if output_result:is_err() then + local err = output_result.error + if err.type == Async.Error.COMMAND_FAILED then + local details = err.details vim.notify( - '[Guard]: ' .. ('%s exited with code %d\n%s'):format(out.cmd, out.code, out.stderr), + string.format( + '[Guard]: %s exited with code %d\n%s', + details.cmd, + details.code, + details.stderr or '' + ), vim.log.levels.WARN ) - data = '' - else - data = out + return Result.ok({ diagnostics = {}, error = true }) end - else - data = lint.fn(prev_lines) + return output_result end - if #data > 0 then - results = lint.parse(data, buf) + -- Get output + local output = lint_config.cmd and output_result.value.stdout or output_result.value + + -- Parse diagnostics + local parse_result = parse_diagnostics(lint_config, output, ctx.bufnr) + if parse_result:is_err() then + vim.notify( + string.format('[Guard]: Failed to parse linter output: %s', parse_result.error), + vim.log.levels.WARN + ) + return Result.ok({ diagnostics = {}, parse_error = true }) 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) + local diagnostics = parse_result.value or {} + + -- Apply diagnostics + apply_diagnostics(ctx, namespace, diagnostics, is_custom) + + return Result.ok({ + diagnostics = diagnostics, + namespace = namespace, + is_custom = is_custom, + }) +end) + +-- Main lint function +function M.do_lint(bufnr) + bufnr = bufnr or api.nvim_get_current_buf() + + -- Get linter configurations + local ft = vim.bo[bufnr].filetype + local ft_config = filetype[ft] or filetype['*'] + + if not ft_config or not ft_config.linter or #ft_config.linter == 0 then + return + end + + -- Create lint context + local ctx = LintContext.new(bufnr) + + -- Get buffer info + local fname, cwd = util.buf_get_info(bufnr) + local lines = api.nvim_buf_get_lines(bufnr, 0, -1, false) + + -- Evaluate and filter linters + local linters = util.eval(vim.tbl_map(util.toolcopy, ft_config.linter)) + + linters = vim.tbl_filter(function(config) + return util.should_run(config, bufnr) + end, linters) + + if #linters == 0 then + return + end + + -- Clear default namespace before running linters + vd.reset(ns, bufnr) + + -- Run all linters in parallel + local promises = vim.tbl_map(function(linter) + return run_single_linter(ctx, linter, lines, fname, cwd) + end, linters) + + Async.all_settled(promises)(function(results) + -- Log any errors but don't fail the whole operation + if vim.g.guard_debug then + vim.iter(results):each(function(result) + if result:is_err() then + vim.notify('[Guard Debug] Linter failed: ' .. vim.inspect(result.error)) + end + end) + end + end) +end + +-- Run a single linter (public API for custom events) +function M.do_lint_single(bufnr, config) + bufnr = bufnr or api.nvim_get_current_buf() + + local ctx = LintContext.new(bufnr) + local fname, cwd = util.buf_get_info(bufnr) + local lines = api.nvim_buf_get_lines(bufnr, 0, -1, false) + + run_single_linter(ctx, config, lines, fname, cwd)(function(result) + if result:is_err() then + vim.notify('[Guard]: Linter failed: ' .. result.error, vim.log.levels.ERROR) end end) end ----@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? +-- Diagnostic formatting helper function M.diag_fmt(buf, lnum_start, col_start, message, severity, source, lnum_end, col_end) return { bufnr = buf, @@ -112,147 +239,171 @@ function M.diag_fmt(buf, lnum_start, col_start, message, severity, source, lnum_ } end -local severities = { - error = 1, - warning = 2, - info = 3, - style = 4, +-- Severity levels +M.severities = { + error = 1, -- vim.diagnostic.severity.ERROR + warning = 2, -- vim.diagnostic.severity.WARN + info = 3, -- vim.diagnostic.severity.INFO + style = 4, -- vim.diagnostic.severity.HINT } -M.severities = severities -local from_opts = { +-- Parser factories +local Parsers = {} + +-- Common parser options +local default_opts = { offset = 1, source = nil, - severities = severities, -} - -local regex_opts = { - regex = nil, - groups = { 'lnum', 'col', 'severity', 'code', 'message' }, + severities = M.severities, } -local json_opts = { - get_diagnostics = function(...) - return vim.json.decode(...) - end, - attributes = { - lnum = 'line', - col = 'column', - message = 'message', - code = 'code', - severity = 'severity', - }, - lines = nil, -} - -local function formulate_msg(msg, code) - return (msg or '') .. (code and ('[%s]'):format(code) or '') -end - -local function attr_value(mes, attribute) - return type(attribute) == 'function' and attribute(mes) or mes[attribute] -end +-- JSON parser factory +function Parsers.json(opts) + opts = vim.tbl_deep_extend('force', default_opts, opts or {}) + + -- Default JSON options + local json_defaults = { + get_diagnostics = vim.json.decode, + attributes = { + lnum = 'line', + col = 'column', + message = 'message', + code = 'code', + severity = 'severity', + }, + lines = false, + } ----@param nr any? ----@param off number -local function normalize(nr, off) - if not nr or nr == '' then - return 0 - else - return tonumber(nr) - off - end -end + opts = vim.tbl_deep_extend('force', json_defaults, opts) + + return function(output, bufnr) + local diagnostics = {} + local offences = {} + + -- Parse output + local ok, parsed = pcall(function() + if opts.lines then + -- Parse each line separately + for line in vim.gsplit(output, '\r?\n', { trimempty = true }) do + local offence = opts.get_diagnostics(line) + if offence then + table.insert(offences, offence) + end + end + else + -- Parse whole output + offences = opts.get_diagnostics(output) or {} + end + end) -local function json_get_offset(mes, attr, off) - return normalize(attr_value(mes, attr), off) -end + if not ok then + return {} + 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) + -- Convert to diagnostics + local attr = opts.attributes + local off = opts.offset - return function(result, buf) - local diags, offences = {}, {} + for _, offence in ipairs(offences) do + local function get_value(key) + local attribute = attr[key] + if type(attribute) == 'function' then + return attribute(offence) + else + return offence[attribute] + end + end - if opts.lines then - -- \r\n for windows compatibility - vim.tbl_map(function(line) - local offence = opts.get_diagnostics(line) - if offence then - table.insert(offences, offence) + local function normalize(value) + if not value or value == '' then + return 0 end - end, vim.split(result, '\r?\n', { trimempty = true })) - else - offences = opts.get_diagnostics(result) - end + return tonumber(value) - off + end + + local message = get_value('message') + local code = get_value('code') - local attr = opts.attributes - local off = opts.offset - vim.tbl_map(function(mes) - local message = attr_value(mes, attr.message) - local code = attr_value(mes, attr.code) table.insert( - diags, + diagnostics, M.diag_fmt( - buf, - json_get_offset(mes, attr.lnum, off), - json_get_offset(mes, attr.col, off), - formulate_msg(message, code), - opts.severities[attr_value(mes, attr.severity)], + bufnr, + normalize(get_value('lnum')), + normalize(get_value('col')), + message .. (code and (' [' .. code .. ']') or ''), + opts.severities[get_value('severity')], opts.source, - json_get_offset(mes, attr.lnum_end or attr.lnum, off), - json_get_offset(mes, attr.col_end or attr.col, off) + normalize(get_value('lnum_end') or get_value('lnum')), + normalize(get_value('col_end') or get_value('col')) ) ) - end, offences or {}) + end - return diags + return diagnostics end end -function M.from_regex(opts) - opts = vim.tbl_deep_extend('force', from_opts, opts or {}) - opts = vim.tbl_deep_extend('force', regex_opts, opts) +-- Regex parser factory +function Parsers.regex(opts) + opts = vim.tbl_deep_extend('force', default_opts, opts or {}) - return function(result, buf) - local diags, offences = {}, {} - -- \r\n for windows compatibility - local lines = vim.split(result, '\r?\n', { trimempty = true }) + -- Default regex options + local regex_defaults = { + regex = nil, + groups = { 'lnum', 'col', 'severity', 'code', 'message' }, + } + + opts = vim.tbl_deep_extend('force', regex_defaults, opts) - for _, line in ipairs(lines) do - local offence = {} + if not opts.regex then + error('regex parser requires a regex pattern') + end + return function(output, bufnr) + local diagnostics = {} + local off = opts.offset + + -- Parse each line + for line in vim.gsplit(output, '\r?\n', { trimempty = true }) do local matches = { line:match(opts.regex) } - -- regex matched if #matches == #opts.groups then + local offence = {} for i = 1, #opts.groups do offence[opts.groups[i]] = matches[i] end - table.insert(offences, offence) - end - end + local function normalize(value) + if not value or value == '' then + return 0 + end + return tonumber(value) - off + end - local off = opts.offset - vim.tbl_map(function(mes) - table.insert( - diags, - M.diag_fmt( - buf, - normalize(mes.lnum, off), - normalize(mes.col, off), - formulate_msg(mes.message, mes.code), - opts.severities[mes.severity], - opts.source, - normalize(mes.lnum_end or mes.lnum, off), - normalize(mes.col_end or mes.lnum, off) + local message = offence.message or '' + local code = offence.code + + table.insert( + diagnostics, + M.diag_fmt( + bufnr, + normalize(offence.lnum), + normalize(offence.col), + message .. (code and (' [' .. code .. ']') or ''), + opts.severities[offence.severity], + opts.source, + normalize(offence.lnum_end or offence.lnum), + normalize(offence.col_end or offence.col) + ) ) - ) - end, offences) + end + end - return diags + return diagnostics end end +M.from_json = Parsers.json +M.from_regex = Parsers.regex + return M 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