diff --git a/lua/preview/compiler.lua b/lua/preview/compiler.lua index 5480d57..3e8de12 100644 --- a/lua/preview/compiler.lua +++ b/lua/preview/compiler.lua @@ -3,102 +3,45 @@ local M = {} local diagnostic = require('preview.diagnostic') local log = require('preview.log') ----@class preview.BufState ----@field watching boolean ----@field process? table ----@field is_reload? boolean ----@field provider? string ----@field output? string ----@field viewer? table ----@field viewer_open? boolean ----@field open_watcher? uv.uv_fs_event_t ----@field debounce? uv.uv_timer_t ----@field bwp_autocmd? integer ----@field unload_autocmd? integer +---@type table +local active = {} ----@type table -local state = {} +---@type table +local watching = {} + +---@type table +local opened = {} + +---@type table +local last_output = {} + +---@type table +local viewer_procs = {} + +---@type table +local open_watchers = {} + +local debounce_timers = {} local DEBOUNCE_MS = 500 ----@param bufnr integer ----@return preview.BufState -local function get_state(bufnr) - if not state[bufnr] then - state[bufnr] = { watching = false } - end - return state[bufnr] -end - ---@param bufnr integer local function stop_open_watcher(bufnr) - local s = state[bufnr] - if not (s and s.open_watcher) then - return + local w = open_watchers[bufnr] + if w then + w:stop() + w:close() + open_watchers[bufnr] = nil end - s.open_watcher:stop() - s.open_watcher:close() - s.open_watcher = nil end ---@param bufnr integer local function close_viewer(bufnr) - local s = state[bufnr] - if not (s and s.viewer) then - return - end - s.viewer:kill('sigterm') - s.viewer = nil -end - ----@param bufnr integer ----@param name string ----@param provider preview.ProviderConfig ----@param ctx preview.Context ----@param output string -local function handle_errors(bufnr, name, provider, ctx, output) - local errors_mode = provider.errors - if errors_mode == nil then - errors_mode = 'diagnostic' - end - if not (provider.error_parser and errors_mode) then - return - end - if errors_mode == 'diagnostic' then - diagnostic.set(bufnr, name, provider.error_parser, output, ctx) - elseif errors_mode == 'quickfix' then - local ok, diags = pcall(provider.error_parser, output, ctx) - if ok and diags and #diags > 0 then - local items = {} - for _, d in ipairs(diags) do - table.insert(items, { - bufnr = bufnr, - lnum = d.lnum + 1, - col = d.col + 1, - text = d.message, - type = d.severity == vim.diagnostic.severity.WARN and 'W' or 'E', - }) - end - vim.fn.setqflist(items, 'r') - local win = vim.fn.win_getid() - vim.cmd.cwindow() - vim.fn.win_gotoid(win) - end - end -end - ----@param bufnr integer ----@param provider preview.ProviderConfig -local function clear_errors(bufnr, provider) - local errors_mode = provider.errors - if errors_mode == nil then - errors_mode = 'diagnostic' - end - if errors_mode == 'diagnostic' then - diagnostic.clear(bufnr) - elseif errors_mode == 'quickfix' then - vim.fn.setqflist({}, 'r') - vim.cmd.cwindow() + local obj = viewer_procs[bufnr] + if obj then + local kill = obj.kill + kill(obj, 'sigterm') + viewer_procs[bufnr] = nil end end @@ -111,23 +54,7 @@ local function do_open(bufnr, output_file, open_config) elseif type(open_config) == 'table' then local open_cmd = vim.list_extend({}, open_config) table.insert(open_cmd, output_file) - log.dbg('opening viewer for buffer %d: %s', bufnr, table.concat(open_cmd, ' ')) - local proc - proc = vim.system( - open_cmd, - {}, - vim.schedule_wrap(function() - local s = state[bufnr] - if s and s.viewer == proc then - log.dbg('viewer exited for buffer %d, resetting viewer_open', bufnr) - s.viewer = nil - s.viewer_open = nil - else - log.dbg('viewer exited for buffer %d (stale proc, ignoring)', bufnr) - end - end) - ) - get_state(bufnr).viewer = proc + viewer_procs[bufnr] = vim.system(open_cmd) end end @@ -163,30 +90,10 @@ local function resolve_reload_cmd(provider, ctx) return nil end ----@param bufnr integer ----@param s preview.BufState -local function stop_watching(bufnr, s) - s.watching = false - M.stop(bufnr) - stop_open_watcher(bufnr) - close_viewer(bufnr) - s.viewer_open = nil - if s.bwp_autocmd then - vim.api.nvim_del_autocmd(s.bwp_autocmd) - s.bwp_autocmd = nil - end - if s.debounce then - s.debounce:stop() - s.debounce:close() - s.debounce = nil - end -end - ---@param bufnr integer ---@param name string ---@param provider preview.ProviderConfig ---@param ctx preview.Context ----@param opts? {oneshot?: boolean} function M.compile(bufnr, name, provider, ctx, opts) opts = opts or {} @@ -202,9 +109,7 @@ function M.compile(bufnr, name, provider, ctx, opts) vim.cmd('silent! update') end - local s = get_state(bufnr) - - if s.process then + if active[bufnr] then log.dbg('killing existing process for buffer %d before recompile', bufnr) M.stop(bufnr) end @@ -222,7 +127,7 @@ function M.compile(bufnr, name, provider, ctx, opts) end if output_file ~= '' then - s.output = output_file + last_output[bufnr] = output_file end local reload_cmd @@ -250,20 +155,74 @@ function M.compile(bufnr, name, provider, ctx, opts) return end stderr_acc[#stderr_acc + 1] = data - handle_errors(bufnr, name, provider, ctx, table.concat(stderr_acc)) + local errors_mode = provider.errors + if errors_mode == nil then + errors_mode = 'diagnostic' + end + if provider.error_parser and errors_mode then + local output = table.concat(stderr_acc) + if errors_mode == 'diagnostic' then + diagnostic.set(bufnr, name, provider.error_parser, output, ctx) + elseif errors_mode == 'quickfix' then + local ok, diags = pcall(provider.error_parser, output, ctx) + if ok and diags and #diags > 0 then + local items = {} + for _, d in ipairs(diags) do + table.insert(items, { + bufnr = bufnr, + lnum = d.lnum + 1, + col = d.col + 1, + text = d.message, + type = d.severity == vim.diagnostic.severity.WARN and 'W' or 'E', + }) + end + vim.fn.setqflist(items, 'r') + local win = vim.fn.win_getid() + vim.cmd.cwindow() + vim.fn.win_gotoid(win) + end + end + end end), }, vim.schedule_wrap(function(result) - local cs = state[bufnr] - if cs and cs.process == obj then - cs.process = nil + if active[bufnr] and active[bufnr].obj == obj then + active[bufnr] = nil end if not vim.api.nvim_buf_is_valid(bufnr) then return end + if result.code ~= 0 then log.dbg('long-running process failed for buffer %d (exit code %d)', bufnr, result.code) - handle_errors(bufnr, name, provider, ctx, (result.stdout or '') .. (result.stderr or '')) + local errors_mode = provider.errors + if errors_mode == nil then + errors_mode = 'diagnostic' + end + if provider.error_parser and errors_mode then + local output = (result.stdout or '') .. (result.stderr or '') + if errors_mode == 'diagnostic' then + diagnostic.set(bufnr, name, provider.error_parser, output, ctx) + elseif errors_mode == 'quickfix' then + local ok, diagnostics = pcall(provider.error_parser, output, ctx) + if ok and diagnostics and #diagnostics > 0 then + local items = {} + for _, d in ipairs(diagnostics) do + table.insert(items, { + bufnr = bufnr, + lnum = d.lnum + 1, + col = d.col + 1, + text = d.message, + type = d.severity == vim.diagnostic.severity.WARN and 'W' or 'E', + }) + end + vim.fn.setqflist(items, 'r') + local win = vim.fn.win_getid() + vim.cmd.cwindow() + vim.fn.win_gotoid(win) + end + end + end vim.api.nvim_exec_autocmds('User', { pattern = 'PreviewCompileFailed', data = { @@ -277,7 +236,7 @@ function M.compile(bufnr, name, provider, ctx, opts) end) ) - if provider.open and not opts.oneshot and not s.viewer_open and output_file ~= '' then + if provider.open and not opts.oneshot and not opened[bufnr] and output_file ~= '' then local pre_stat = vim.uv.fs_stat(output_file) local pre_mtime = pre_stat and pre_stat.mtime.sec or 0 local out_dir = vim.fn.fnamemodify(output_file, ':h') @@ -285,7 +244,7 @@ function M.compile(bufnr, name, provider, ctx, opts) stop_open_watcher(bufnr) local watcher = vim.uv.new_fs_event() if watcher then - s.open_watcher = watcher + open_watchers[bufnr] = watcher watcher:start( out_dir, {}, @@ -293,12 +252,8 @@ function M.compile(bufnr, name, provider, ctx, opts) if err or vim.fn.fnamemodify(filename or '', ':t') ~= out_name then return end - local cs = state[bufnr] - if not cs then - return - end - if cs.viewer_open then - log.dbg('watcher fired for buffer %d but viewer already open', bufnr) + if opened[bufnr] then + stop_open_watcher(bufnr) return end if not vim.api.nvim_buf_is_valid(bufnr) then @@ -307,27 +262,41 @@ function M.compile(bufnr, name, provider, ctx, opts) end local new_stat = vim.uv.fs_stat(output_file) if not (new_stat and new_stat.mtime.sec > pre_mtime) then - log.dbg( - 'watcher fired for buffer %d but mtime not newer (%d <= %d)', - bufnr, - new_stat and new_stat.mtime.sec or 0, - pre_mtime - ) return end - log.dbg('watcher opening viewer for buffer %d', bufnr) - cs.viewer_open = true + stop_open_watcher(bufnr) stderr_acc = {} - clear_errors(bufnr, provider) + local errors_mode = provider.errors + if errors_mode == nil then + errors_mode = 'diagnostic' + end + if errors_mode == 'diagnostic' then + diagnostic.clear(bufnr) + elseif errors_mode == 'quickfix' then + vim.fn.setqflist({}, 'r') + vim.cmd.cwindow() + end do_open(bufnr, output_file, provider.open) + opened[bufnr] = true end) ) end end - s.process = obj - s.provider = name - s.is_reload = true + active[bufnr] = { obj = obj, provider = name, output_file = output_file, is_reload = true } + + vim.api.nvim_create_autocmd('BufUnload', { + buffer = bufnr, + once = true, + callback = function() + M.stop(bufnr) + stop_open_watcher(bufnr) + if not provider.detach then + close_viewer(bufnr) + end + last_output[bufnr] = nil + end, + }) vim.api.nvim_exec_autocmds('User', { pattern = 'PreviewCompileStarted', @@ -349,18 +318,31 @@ function M.compile(bufnr, name, provider, ctx, opts) local obj obj = vim.system( cmd, - { cwd = cwd, env = provider.env }, + { + cwd = cwd, + env = provider.env, + }, vim.schedule_wrap(function(result) - local cs = state[bufnr] - if cs and cs.process == obj then - cs.process = nil + if active[bufnr] and active[bufnr].obj == obj then + active[bufnr] = nil end if not vim.api.nvim_buf_is_valid(bufnr) then return end + + local errors_mode = provider.errors + if errors_mode == nil then + errors_mode = 'diagnostic' + end + if result.code == 0 then log.dbg('compilation succeeded for buffer %d', bufnr) - clear_errors(bufnr, provider) + if errors_mode == 'diagnostic' then + diagnostic.clear(bufnr) + elseif errors_mode == 'quickfix' then + vim.fn.setqflist({}, 'r') + vim.cmd.cwindow() + end vim.api.nvim_exec_autocmds('User', { pattern = 'PreviewCompileSuccess', data = { bufnr = bufnr, provider = name, output = output_file }, @@ -371,21 +353,42 @@ function M.compile(bufnr, name, provider, ctx, opts) r.inject(output_file) r.broadcast() end - cs = state[bufnr] if provider.open and not opts.oneshot - and cs - and not cs.viewer_open + and not opened[bufnr] and output_file ~= '' and vim.uv.fs_stat(output_file) then - cs.viewer_open = true do_open(bufnr, output_file, provider.open) + opened[bufnr] = true end else log.dbg('compilation failed for buffer %d (exit code %d)', bufnr, result.code) - handle_errors(bufnr, name, provider, ctx, (result.stdout or '') .. (result.stderr or '')) + if provider.error_parser and errors_mode then + local output = (result.stdout or '') .. (result.stderr or '') + if errors_mode == 'diagnostic' then + diagnostic.set(bufnr, name, provider.error_parser, output, ctx) + elseif errors_mode == 'quickfix' then + local ok, diagnostics = pcall(provider.error_parser, output, ctx) + if ok and diagnostics and #diagnostics > 0 then + local items = {} + for _, d in ipairs(diagnostics) do + table.insert(items, { + bufnr = bufnr, + lnum = d.lnum + 1, + col = d.col + 1, + text = d.message, + type = d.severity == vim.diagnostic.severity.WARN and 'W' or 'E', + }) + end + vim.fn.setqflist(items, 'r') + local win = vim.fn.win_getid() + vim.cmd.cwindow() + vim.fn.win_gotoid(win) + end + end + end vim.api.nvim_exec_autocmds('User', { pattern = 'PreviewCompileFailed', data = { @@ -399,9 +402,19 @@ function M.compile(bufnr, name, provider, ctx, opts) end) ) - s.process = obj - s.provider = name - s.is_reload = false + active[bufnr] = { obj = obj, provider = name, output_file = output_file } + + vim.api.nvim_create_autocmd('BufUnload', { + buffer = bufnr, + once = true, + callback = function() + M.stop(bufnr) + if not provider.detach then + close_viewer(bufnr) + end + last_output[bufnr] = nil + end, + }) vim.api.nvim_exec_autocmds('User', { pattern = 'PreviewCompileStarted', @@ -411,37 +424,39 @@ end ---@param bufnr integer function M.stop(bufnr) - local s = state[bufnr] - if not s then - return - end - local obj = s.process - if not obj then + local proc = active[bufnr] + if not proc then return end log.dbg('stopping process for buffer %d', bufnr) - obj:kill('sigterm') + ---@type fun(self: table, signal: string|integer) + local kill = proc.obj.kill + kill(proc.obj, 'sigterm') local timer = vim.uv.new_timer() if timer then timer:start(5000, 0, function() timer:close() - local cs = state[bufnr] - if cs and cs.process == obj then - obj:kill('sigkill') - cs.process = nil + if active[bufnr] and active[bufnr].obj == proc.obj then + kill(proc.obj, 'sigkill') + active[bufnr] = nil end end) end end function M.stop_all() - for bufnr, s in pairs(state) do - stop_watching(bufnr, s) - if s.unload_autocmd then - vim.api.nvim_del_autocmd(s.unload_autocmd) - end - state[bufnr] = nil + for bufnr, _ in pairs(active) do + M.stop(bufnr) + end + for bufnr, _ in pairs(watching) do + M.unwatch(bufnr) + end + for bufnr, _ in pairs(open_watchers) do + stop_open_watcher(bufnr) + end + for bufnr, _ in pairs(viewer_procs) do + close_viewer(bufnr) end require('preview.reload').stop() end @@ -452,77 +467,76 @@ end ---@param ctx_builder fun(bufnr: integer): preview.Context function M.toggle(bufnr, name, provider, ctx_builder) local is_longrunning = type(provider.reload) == 'table' or type(provider.reload) == 'function' - local s = get_state(bufnr) - if s.watching then - local output = s.output - if not s.viewer_open and provider.open and output and vim.uv.fs_stat(output) then - log.dbg('toggle reopen viewer for buffer %d', bufnr) - s.viewer_open = true - do_open(bufnr, output, provider.open) - else - log.dbg('toggle off for buffer %d', bufnr) - stop_watching(bufnr, s) + if is_longrunning then + if active[bufnr] then + M.stop(bufnr) vim.notify('[preview.nvim]: watching stopped', vim.log.levels.INFO) + else + M.compile(bufnr, name, provider, ctx_builder(bufnr)) + vim.notify('[preview.nvim]: watching with "' .. name .. '"', vim.log.levels.INFO) end return end - log.dbg('toggle on for buffer %d', bufnr) - s.watching = true - - if s.unload_autocmd then - vim.api.nvim_del_autocmd(s.unload_autocmd) + if watching[bufnr] then + M.unwatch(bufnr) + vim.notify('[preview.nvim]: watching stopped', vim.log.levels.INFO) + return end - s.unload_autocmd = vim.api.nvim_create_autocmd('BufUnload', { + + local au_id = vim.api.nvim_create_autocmd('BufWritePost', { + buffer = bufnr, + callback = function() + if debounce_timers[bufnr] then + debounce_timers[bufnr]:stop() + else + debounce_timers[bufnr] = vim.uv.new_timer() + end + debounce_timers[bufnr]:start( + DEBOUNCE_MS, + 0, + vim.schedule_wrap(function() + local ctx = ctx_builder(bufnr) + M.compile(bufnr, name, provider, ctx) + end) + ) + end, + }) + + watching[bufnr] = au_id + log.dbg('watching buffer %d with provider "%s"', bufnr, name) + vim.notify('[preview.nvim]: watching with "' .. name .. '"', vim.log.levels.INFO) + + vim.api.nvim_create_autocmd('BufUnload', { buffer = bufnr, once = true, callback = function() - M.stop(bufnr) + M.unwatch(bufnr) stop_open_watcher(bufnr) if not provider.detach then close_viewer(bufnr) end - state[bufnr] = nil + opened[bufnr] = nil end, }) - if not is_longrunning then - s.bwp_autocmd = vim.api.nvim_create_autocmd('BufWritePost', { - buffer = bufnr, - callback = function() - local ds = state[bufnr] - if not ds then - return - end - if ds.debounce then - ds.debounce:stop() - else - ds.debounce = vim.uv.new_timer() - end - ds.debounce:start( - DEBOUNCE_MS, - 0, - vim.schedule_wrap(function() - M.compile(bufnr, name, provider, ctx_builder(bufnr)) - end) - ) - end, - }) - log.dbg('watching buffer %d with provider "%s"', bufnr, name) - end - - vim.notify('[preview.nvim]: watching with "' .. name .. '"', vim.log.levels.INFO) M.compile(bufnr, name, provider, ctx_builder(bufnr)) end ---@param bufnr integer function M.unwatch(bufnr) - local s = state[bufnr] - if not s then + local au_id = watching[bufnr] + if not au_id then return end - stop_watching(bufnr, s) + vim.api.nvim_del_autocmd(au_id) + if debounce_timers[bufnr] then + debounce_timers[bufnr]:stop() + debounce_timers[bufnr]:close() + debounce_timers[bufnr] = nil + end + watching[bufnr] = nil log.dbg('unwatched buffer %d', bufnr) end @@ -571,8 +585,7 @@ end ---@param bufnr integer ---@return boolean function M.open(bufnr, open_config) - local s = state[bufnr] - local output = s and s.output + local output = last_output[bufnr] if not output then log.dbg('no last output file for buffer %d', bufnr) return false @@ -588,20 +601,26 @@ end ---@param bufnr integer ---@return preview.Status function M.status(bufnr) - local s = state[bufnr] - if not s then - return { compiling = false, watching = false } + local proc = active[bufnr] + if proc then + return { + compiling = not proc.is_reload, + watching = watching[bufnr] ~= nil or proc.is_reload == true, + provider = proc.provider, + output_file = proc.output_file, + } end - return { - compiling = s.process ~= nil and not s.is_reload, - watching = s.watching, - provider = s.provider, - output_file = s.output, - } + return { compiling = false, watching = watching[bufnr] ~= nil } end M._test = { - state = state, + active = active, + watching = watching, + opened = opened, + last_output = last_output, + debounce_timers = debounce_timers, + viewer_procs = viewer_procs, + open_watchers = open_watchers, } return M diff --git a/spec/compiler_spec.lua b/spec/compiler_spec.lua index 5b12cfb..cd1dd9f 100644 --- a/spec/compiler_spec.lua +++ b/spec/compiler_spec.lua @@ -8,13 +8,8 @@ describe('compiler', function() compiler = require('preview.compiler') end) - local function process_done(bufnr) - local s = compiler._test.state[bufnr] - return not s or s.process == nil - end - describe('compile', function() - it('spawns a process and tracks it in state', function() + it('spawns a process and tracks it in active table', function() local bufnr = helpers.create_buffer({ 'hello' }, 'text') vim.api.nvim_buf_set_name(bufnr, '/tmp/preview_test.txt') vim.bo[bufnr].modified = false @@ -28,16 +23,15 @@ describe('compiler', function() } compiler.compile(bufnr, 'echo', provider, ctx) - local s = compiler._test.state[bufnr] - assert.is_not_nil(s) - assert.is_not_nil(s.process) - assert.are.equal('echo', s.provider) + local active = compiler._test.active + assert.is_not_nil(active[bufnr]) + assert.are.equal('echo', active[bufnr].provider) vim.wait(2000, function() - return process_done(bufnr) + return active[bufnr] == nil end, 50) - assert.is_nil(compiler._test.state[bufnr].process) + assert.is_nil(active[bufnr]) helpers.delete_buffer(bufnr) end) @@ -67,7 +61,7 @@ describe('compiler', function() assert.is_true(fired) vim.wait(2000, function() - return process_done(bufnr) + return compiler._test.active[bufnr] == nil end, 50) helpers.delete_buffer(bufnr) @@ -130,7 +124,7 @@ describe('compiler', function() vim.notify = orig assert.is_true(notified) - assert.is_true(process_done(bufnr)) + assert.is_nil(compiler._test.active[bufnr]) helpers.delete_buffer(bufnr) end) @@ -192,7 +186,7 @@ describe('compiler', function() compiler.compile(bufnr, 'falsecmd', provider, ctx) vim.wait(2000, function() - return process_done(bufnr) + return compiler._test.active[bufnr] == nil end, 50) assert.is_false(parser_called) @@ -224,7 +218,7 @@ describe('compiler', function() compiler.compile(bufnr, 'qfcmd', provider, ctx) vim.wait(2000, function() - return process_done(bufnr) + return compiler._test.active[bufnr] == nil end, 50) local qflist = vim.fn.getqflist() @@ -261,7 +255,7 @@ describe('compiler', function() compiler.compile(bufnr, 'truecmd', provider, ctx) vim.wait(2000, function() - return process_done(bufnr) + return compiler._test.active[bufnr] == nil end, 50) assert.are.equal(0, #vim.fn.getqflist()) @@ -304,7 +298,7 @@ describe('compiler', function() compiler.stop(bufnr) vim.wait(2000, function() - return process_done(bufnr) + return compiler._test.active[bufnr] == nil end, 50) helpers.delete_buffer(bufnr) @@ -335,12 +329,11 @@ describe('compiler', function() } compiler.compile(bufnr, 'testprov', provider, ctx) - local s = compiler._test.state[bufnr] - assert.is_not_nil(s) - assert.are.equal('/tmp/preview_test_open.pdf', s.output) + assert.is_not_nil(compiler._test.last_output[bufnr]) + assert.are.equal('/tmp/preview_test_open.pdf', compiler._test.last_output[bufnr]) vim.wait(2000, function() - return process_done(bufnr) + return compiler._test.active[bufnr] == nil end, 50) helpers.delete_buffer(bufnr) @@ -348,7 +341,7 @@ describe('compiler', function() end) describe('toggle', function() - it('starts watching and sets watching flag', function() + it('registers autocmd and tracks in watching table', function() local bufnr = helpers.create_buffer({ 'hello' }, 'text') vim.api.nvim_buf_set_name(bufnr, '/tmp/preview_test_watch.txt') @@ -358,7 +351,7 @@ describe('compiler', function() end compiler.toggle(bufnr, 'echo', provider, ctx_builder) - assert.is_true(compiler.status(bufnr).watching) + assert.is_not_nil(compiler._test.watching[bufnr]) helpers.delete_buffer(bufnr) end) @@ -373,10 +366,10 @@ describe('compiler', function() end compiler.toggle(bufnr, 'echo', provider, ctx_builder) - assert.is_true(compiler.status(bufnr).watching) + assert.is_not_nil(compiler._test.watching[bufnr]) compiler.toggle(bufnr, 'echo', provider, ctx_builder) - assert.is_false(compiler.status(bufnr).watching) + assert.is_nil(compiler._test.watching[bufnr]) helpers.delete_buffer(bufnr) end) @@ -396,10 +389,10 @@ describe('compiler', function() end compiler.toggle(bufnr, 'echo', provider, ctx_builder) - assert.is_true(compiler.status(bufnr).watching) + assert.is_not_nil(compiler._test.watching[bufnr]) compiler.stop_all() - assert.is_false(compiler.status(bufnr).watching) + assert.is_nil(compiler._test.watching[bufnr]) helpers.delete_buffer(bufnr) end)