diff --git a/lua/csvview/buf.lua b/lua/csvview/buf.lua new file mode 100644 index 0000000..9c46544 --- /dev/null +++ b/lua/csvview/buf.lua @@ -0,0 +1,70 @@ +--- Buffer utilities +local M = {} + +--- Resolve bufnr +---@param bufnr integer| nil +---@return integer +function M.resolve_bufnr(bufnr) + if not bufnr or bufnr == 0 then + return vim.api.nvim_get_current_buf() + else + return bufnr + end +end + +--- Watch buffer-update events +---@param bufnr integer +---@param callbacks vim.api.keyset.buf_attach +---@return fun() detach_bufevent +function M.attach(bufnr, callbacks) + local detached = false + local function wrap_buf_attach_handler(cb) + if not cb then + return nil + end + + return function(...) + if detached then + return true -- detach + end + + return cb(...) + end + end + + local function attach_events() + vim.api.nvim_buf_attach(bufnr, false, { + on_lines = wrap_buf_attach_handler(callbacks.on_lines), + on_bytes = wrap_buf_attach_handler(callbacks.on_bytes), + on_changedtick = wrap_buf_attach_handler(callbacks.on_changedtick), + on_reload = wrap_buf_attach_handler(callbacks.on_reload), + on_detach = wrap_buf_attach_handler(callbacks.on_detach), + }) + end + + -- Attach to buffer + attach_events() + + -- Re-register events on `:e` + local buf_event_auid = vim.api.nvim_create_autocmd({ "BufReadPost" }, { + callback = function() + attach_events() + if callbacks.on_reload then + callbacks.on_reload("reload", bufnr) + end + end, + buffer = bufnr, + }) + + -- detach + return function() + if detached then + return + end + + vim.api.nvim_del_autocmd(buf_event_auid) + detached = true + end +end + +return M diff --git a/lua/csvview/buffer_event.lua b/lua/csvview/buffer_event.lua deleted file mode 100644 index 54f3348..0000000 --- a/lua/csvview/buffer_event.lua +++ /dev/null @@ -1,41 +0,0 @@ -local M = {} - ----@class CsvView.BufferEvents ----@field on_lines fun(event: "lines", bufnr: integer, changedtick: integer, first: integer, last: integer, last_updated: integer, byte_count: integer, deleted_codepoints: integer, deleted_codeunits: integer): boolean | nil Return `true` to detach from buffer events. ----@field on_reload fun(event: "reload", bufnr: integer) : boolean | nil Return `true` to detach from buffer events. - ----Register buffer events. This will attach to the buffer and listen for events. ----When the buffer is reloaded, the events will be re-registered. ----@param bufnr integer ----@param events CsvView.BufferEvents -function M.register(bufnr, events) - -- Re-register events on `:e` - vim.b[bufnr].csvview_update_auid = vim.api.nvim_create_autocmd({ "BufReadPost" }, { - callback = function() - M.register(bufnr, events) - events.on_reload("reload", bufnr) - end, - buffer = bufnr, - once = true, - }) - - -- Attach to buffer - vim.api.nvim_buf_attach(bufnr, false, { - on_lines = function(...) - return events.on_lines(...) - end, - on_reload = function(...) - return events.on_reload(...) - end, - }) -end - ----Unregister buffer events. ----This will detach from the buffer and stop listening for events. ----@param bufnr integer -function M.unregister(bufnr) - vim.api.nvim_del_autocmd(vim.b[bufnr].csvview_update_auid) - vim.b[bufnr].csvview_update_auid = nil -end - -return M diff --git a/lua/csvview/util.lua b/lua/csvview/errors.lua similarity index 62% rename from lua/csvview/util.lua rename to lua/csvview/errors.lua index ddf31d7..d1f09af 100644 --- a/lua/csvview/util.lua +++ b/lua/csvview/errors.lua @@ -1,24 +1,30 @@ +--- Error handling utilities local M = {} +--- @class CsvView.Error +--- @field err string error message +--- @field stacktrace? string error stacktrace +--- @field [string] any additional context data + --- Wrap error with stacktrace for `xpcall` ----@param err string | table ----@return table +---@param err string|CsvView.Error|nil +---@return CsvView.Error function M.wrap_stacktrace(err) if type(err) == "table" then - return err + return vim.tbl_deep_extend("keep", err, { stacktrace = debug.traceback("", 2) }) else return { err = err, stacktrace = debug.traceback("", 2) } end end --- Propagate error with context ----@param err any ----@param context table | nil +---@param err string|CsvView.Error|nil +---@param context table| nil function M.error_with_context(err, context) - if type(err) == "table" then - err = vim.tbl_deep_extend("keep", err, context or {}) - elseif type(err) == "string" then + if type(err) == "string" then err = vim.tbl_deep_extend("keep", { err = err }, context or {}) + elseif type(err) == "table" then + err = vim.tbl_deep_extend("keep", err, context or {}) end error(err, 0) end @@ -27,23 +33,22 @@ end ---@param tbl table ---@param key string ---@return any -function M.tbl_remove_key(tbl, key) +local function tbl_remove_key(tbl, key) local value = tbl[key] ---@type any tbl[key] = nil ---@type any return value end --- Print error message ----@param header string ----@param err string | table +--- @type fun(header: string, err: string|CsvView.Error|nil) M.print_structured_error = vim.schedule_wrap(function(header, err) --- @type string local msg if type(err) == "table" then -- extract error message and stacktrace - local stacktrace = M.tbl_remove_key(err, "stacktrace") or "No stacktrace available" - local err_msg = M.tbl_remove_key(err, "err") or "An unspecified error occurred" + local stacktrace = tbl_remove_key(err, "stacktrace") or "No stacktrace available" + local err_msg = tbl_remove_key(err, "err") or "An unspecified error occurred" -- format error message msg = string.format( diff --git a/lua/csvview/init.lua b/lua/csvview/init.lua index 14b1fc4..e788d7a 100644 --- a/lua/csvview/init.lua +++ b/lua/csvview/init.lua @@ -1,15 +1,20 @@ local M = {} +local CsvView = require("csvview.view").View +local get_view = require("csvview.view").get +local attach_view = require("csvview.view").attach +local detach_view = require("csvview.view").detach +local setup_view = require("csvview.view").setup + local CsvViewMetrics = require("csvview.metrics") -local buffer_event = require("csvview.buffer_event") +local buf = require("csvview.buf") local config = require("csvview.config") -local view = require("csvview.view") --- check if csv table view is enabled ---@param bufnr integer ---@return boolean function M.is_enabled(bufnr) - return view.get(bufnr) ~= nil + return get_view(bufnr) ~= nil end --- enable csv table view @@ -24,37 +29,32 @@ function M.enable(bufnr, opts) return end - -- Calculate metrics and attach view. + local detach_bufevent_handle --- @type fun() local metrics = CsvViewMetrics:new(bufnr, opts) - metrics:compute_buffer(function() - view.attach(bufnr, metrics, opts) + local view = CsvView:new(bufnr, metrics, opts, function() + detach_bufevent_handle() + metrics:clear() end) - -- Register buffer events. - buffer_event.register(bufnr, { + -- Register buffer-update events. + detach_bufevent_handle = buf.attach(bufnr, { on_lines = function(_, _, _, first, last, last_updated) - -- detach if disabled - if not M.is_enabled(bufnr) then - return true - end - - -- Recalculate only the difference. metrics:update(first, last, last_updated) end, - on_reload = function() - -- detach if disabled - if not M.is_enabled(bufnr) then - return true - end - - -- Recalculate all fields. - view.detach(bufnr) + view:clear() + metrics:clear() + view:lock() metrics:compute_buffer(function() - view.attach(bufnr, metrics, opts) + view:unlock() end) end, }) + + -- Calculate metrics and attach view. + metrics:compute_buffer(function() + attach_view(bufnr, view) + end) end --- disable csv table view @@ -66,9 +66,7 @@ function M.disable(bufnr) return end - -- Unregister buffer events and detach view. - buffer_event.unregister(bufnr) - view.detach(bufnr) + detach_view(bufnr) end --- toggle csv table view @@ -87,7 +85,7 @@ end ---@param opts CsvView.Options? function M.setup(opts) config.setup(opts) - view.setup() + setup_view() end return M diff --git a/lua/csvview/parser.lua b/lua/csvview/parser.lua index 3890314..5509237 100644 --- a/lua/csvview/parser.lua +++ b/lua/csvview/parser.lua @@ -1,5 +1,5 @@ local M = {} -local util = require("csvview.util") +local errors = require("csvview.errors") ---@class Csvview.Parser.Callbacks ---@field on_line fun(lnum:integer,is_comment:boolean,fields:string[]) the callback to be called for each line @@ -145,9 +145,9 @@ local function iter(startlnum, endlnum, bufnr, opts, cb) -- iterate lines local parsed_num = 0 for i = startlnum, endlnum do - local ok, err = xpcall(parse_line, util.wrap_stacktrace, i) + local ok, err = xpcall(parse_line, errors.wrap_stacktrace, i) if not ok then - util.error_with_context(err, { lnum = i }) + errors.error_with_context(err, { lnum = i }) end -- yield every chunksize @@ -178,16 +178,16 @@ function M.iter_lines_async(bufnr, startlnum, endlnum, cb, opts) -- create coroutine to iterate lines local co = coroutine.create(function() ---@async - local ok, err = xpcall(iter, util.wrap_stacktrace, startlnum, endlnum, bufnr, opts, cb) + local ok, err = xpcall(iter, errors.wrap_stacktrace, startlnum, endlnum, bufnr, opts, cb) if not ok then - util.error_with_context(err, { startlnum = startlnum, endlnum = endlnum }) + errors.error_with_context(err, { startlnum = startlnum, endlnum = endlnum }) end end) local function resume_co() local ok, err = coroutine.resume(co) if not ok then - util.print_structured_error("CsvView Error parsing buffer", err) + errors.print_structured_error("CsvView Error parsing buffer", err) elseif coroutine.status(co) ~= "dead" then vim.schedule(resume_co) end diff --git a/lua/csvview/view.lua b/lua/csvview/view.lua index ea6cacb..f1bd2b0 100644 --- a/lua/csvview/view.lua +++ b/lua/csvview/view.lua @@ -1,5 +1,5 @@ local EXTMARK_NS = vim.api.nvim_create_namespace("csv_extmark") -local util = require("csvview.util") +local errors = require("csvview.errors") --- Get end column of line ---@param winid integer window id @@ -11,25 +11,29 @@ local function end_col(winid, lnum) end --- @class CsvView.View ---- @field bufnr integer ---- @field metrics CsvView.Metrics ---- @field extmarks integer[] ---- @field opts CsvView.Options +--- @field public bufnr integer +--- @field public metrics CsvView.Metrics +--- @field public opts CsvView.Options +--- @field private _extmarks integer[] +--- @field private _on_dispose function? +--- @field private _locked boolean local View = {} --- create new view ---@param bufnr integer ---@param metrics CsvView.Metrics ---@param opts CsvView.Options +---@param on_dispose? fun() ---@return CsvView.View -function View:new(bufnr, metrics, opts) +function View:new(bufnr, metrics, opts, on_dispose) self.__index = self local obj = {} obj.bufnr = bufnr obj.metrics = metrics - obj.extmarks = {} obj.opts = opts + obj._extmarks = {} + obj._on_dispose = on_dispose return setmetatable(obj, self) end @@ -41,7 +45,7 @@ end ---@param border boolean function View:_align_left(lnum, offset, padding, field, border) if padding > 0 then - self.extmarks[#self.extmarks + 1] = + self._extmarks[#self._extmarks + 1] = vim.api.nvim_buf_set_extmark(self.bufnr, EXTMARK_NS, lnum - 1, offset + field.len, { virt_text = { { string.rep(" ", padding) } }, virt_text_pos = "inline", @@ -69,7 +73,7 @@ end ---@param border boolean function View:_align_right(lnum, offset, padding, field, border) if padding > 0 then - self.extmarks[#self.extmarks + 1] = vim.api.nvim_buf_set_extmark(self.bufnr, EXTMARK_NS, lnum - 1, offset, { + self._extmarks[#self._extmarks + 1] = vim.api.nvim_buf_set_extmark(self.bufnr, EXTMARK_NS, lnum - 1, offset, { virt_text = { { string.rep(" ", padding) } }, virt_text_pos = "inline", right_gravity = false, @@ -100,7 +104,7 @@ function View:render_column_index_header(lnum) virt[#virt + 1] = { "," } end end - self.extmarks[#self.extmarks + 1] = vim.api.nvim_buf_set_extmark(self.bufnr, EXTMARK_NS, lnum - 1, 0, { + self._extmarks[#self._extmarks + 1] = vim.api.nvim_buf_set_extmark(self.bufnr, EXTMARK_NS, lnum - 1, 0, { virt_lines = { virt }, virt_lines_above = true, }) @@ -110,7 +114,7 @@ end ---@param lnum integer 1-indexed lnum ---@param offset integer 0-indexed byte offset function View:_highlight_delimiter(lnum, offset) - self.extmarks[#self.extmarks + 1] = vim.api.nvim_buf_set_extmark(self.bufnr, EXTMARK_NS, lnum - 1, offset, { + self._extmarks[#self._extmarks + 1] = vim.api.nvim_buf_set_extmark(self.bufnr, EXTMARK_NS, lnum - 1, offset, { hl_group = "CsvViewDelimiter", end_col = offset + 1, }) @@ -120,7 +124,7 @@ end ---@param lnum integer 1-indexed lnum ---@param offset integer 0-indexed byte offset function View:_render_border(lnum, offset) - self.extmarks[#self.extmarks + 1] = vim.api.nvim_buf_set_extmark(self.bufnr, EXTMARK_NS, lnum - 1, offset, { + self._extmarks[#self._extmarks + 1] = vim.api.nvim_buf_set_extmark(self.bufnr, EXTMARK_NS, lnum - 1, offset, { virt_text = { { "│", "CsvViewDelimiter" } }, virt_text_pos = "overlay", }) @@ -128,12 +132,19 @@ end --- clear view function View:clear() - for _ = 1, #self.extmarks do - local id = table.remove(self.extmarks) + for _ = 1, #self._extmarks do + local id = table.remove(self._extmarks) vim.api.nvim_buf_del_extmark(self.bufnr, EXTMARK_NS, id) end end +function View:dispose() + self:clear() + if self._on_dispose then + self._on_dispose() + end +end + --- Render field in line ---@param lnum integer 1-indexed lnum ---@param column_index 1-indexed column index @@ -167,7 +178,7 @@ function View:_render_line(lnum, winid) if line.is_comment then -- highlight comment line - self.extmarks[#self.extmarks + 1] = vim.api.nvim_buf_set_extmark(self.bufnr, EXTMARK_NS, lnum - 1, 0, { + self._extmarks[#self._extmarks + 1] = vim.api.nvim_buf_set_extmark(self.bufnr, EXTMARK_NS, lnum - 1, 0, { hl_group = "CsvViewComment", end_col = end_col(winid, lnum), }) @@ -177,9 +188,9 @@ function View:_render_line(lnum, winid) -- render fields local offset = 0 for column_index, field in ipairs(line.fields) do - local ok, err = xpcall(self._render_field, util.wrap_stacktrace, self, lnum, column_index, field, offset) + local ok, err = xpcall(self._render_field, errors.wrap_stacktrace, self, lnum, column_index, field, offset) if not ok then - util.error_with_context(err, { lnum = lnum, column_index = column_index }) + errors.error_with_context(err, { lnum = lnum, column_index = column_index }) end offset = offset + field.len + 1 end @@ -194,13 +205,29 @@ function View:render(top_lnum, bot_lnum, winid) --- render all fields in ranges for lnum = top_lnum, bot_lnum do - local ok, err = xpcall(self._render_line, util.wrap_stacktrace, self, lnum, winid) + local ok, err = xpcall(self._render_line, errors.wrap_stacktrace, self, lnum, winid) if not ok then - util.error_with_context(err, { lnum = lnum }) + errors.error_with_context(err, { lnum = lnum }) end end end +--- Lock view rendering +function View:lock() + self._locked = true +end + +--- Unlock view rendering +function View:unlock() + self._locked = false +end + +--- check if view rendering is locked +---@return boolean +function View:is_locked() + return self._locked +end + ------------------------------------------------------- -- module exports ------------------------------------------------------- @@ -212,14 +239,13 @@ M._views = {} --- attach view for buffer ---@param bufnr integer ----@param metrics CsvView.Metrics ----@param opts CsvView.Options -function M.attach(bufnr, metrics, opts) +---@param view CsvView.View +function M.attach(bufnr, view) if M._views[bufnr] then vim.notify("csvview: already attached for this buffer.") return end - M._views[bufnr] = View:new(bufnr, metrics, opts) + M._views[bufnr] = view vim.cmd([[redraw!]]) end @@ -229,8 +255,7 @@ function M.detach(bufnr) if not M._views[bufnr] then return end - M._views[bufnr]:clear() - M._views[bufnr].metrics:clear() + M._views[bufnr]:dispose() M._views[bufnr] = nil end @@ -261,15 +286,20 @@ function M.setup() -- return false -- end + -- do not render when locked + if view:is_locked() then + return false + end + -- clear last rendered. view:clear() -- render with current window range. local top = vim.fn.line("w0", winid) local bot = vim.fn.line("w$", winid) - local ok, err = xpcall(view.render, util.wrap_stacktrace, view, top, bot, winid) + local ok, err = xpcall(view.render, errors.wrap_stacktrace, view, top, bot, winid) if not ok then - util.print_structured_error("CsvView Rendering Stopped with Error", err) + errors.print_structured_error("CsvView Rendering Stopped with Error", err) M.detach(bufnr) end