diff --git a/lua/sos/autocmds.lua b/lua/sos/autocmds.lua index 3d8894d..e5830d5 100644 --- a/lua/sos/autocmds.lua +++ b/lua/sos/autocmds.lua @@ -1,5 +1,4 @@ local M = {} -local commands = require 'sos.commands' local impl = require 'sos.impl' local api = vim.api local augroup = 'sos-autosaver' diff --git a/lua/sos/bufevents.lua b/lua/sos/bufevents.lua deleted file mode 100644 index 10544a3..0000000 --- a/lua/sos/bufevents.lua +++ /dev/null @@ -1,203 +0,0 @@ -local errmsg = require('sos.util').errmsg -local api = vim.api - ----An object which observes multiple buffers for changes at once. -local MultiBufObserver = {} - ----Constructor ----@param cfg sos.Config ----@param timer sos.Timer -function MultiBufObserver:new(cfg, timer) - local did_start = false - local did_destroy = false - - local instance = { - autocmds = {}, - listeners = {}, - pending_detach = {}, - buf_callback = {}, - cfg = cfg, - timer = timer, - } - - instance.on_timer = vim.schedule_wrap(instance.cfg.on_timer) - - ---Called whenever a buffer incurs a savable change (i.e. - ---writing the buffer would change the file's contents on the filesystem). - ---All this does is debounce the timer. - ---NOTE: this triggers often, so it should return quickly! - ---@param buf integer - ---@return true | nil - ---@nodiscard - function instance:on_change(buf) - if self:should_detach(buf) then return true end -- detach - local t = self.timer - local result, err, _ = t:stop() - assert(result == 0, err) - result, err, _ = t:start(self.cfg.timeout, 0, self.on_timer) - assert(result == 0, err) - end - - ---NOTE: this fires on EVERY single change of the buf - ---text, even if the text is replaced with the same text, - ---and fires on every keystroke in insert mode. - instance.buf_callback.on_lines = function(_, buf) - return instance:on_change(buf) - end - - ---TODO: Could this leak memory? A new fn/closure is created every time - ---a new observer is created. The closure references `instance`, while nvim - ---refs the closure (even after the observer is destroyed). The ref to the - ---closure isn't/can't be dropped until the next time `on_lines` triggers, - ---which may be awhile or never even. A buildup of allocated memory might - ---happen simply by disabling and enabling sos over and over again as new - ---callbacks/closures are attached and old ones aren't detached. - instance.buf_callback.on_detach = function(_, buf) - instance.listeners[buf] = nil - instance.pending_detach[buf] = nil - end - - ---Attach buffer callbacks if not already attached - ---@param buf integer - ---@return nil - function instance:attach(buf) - self.pending_detach[buf] = nil - - if self.listeners[buf] == nil then - assert( - api.nvim_buf_attach(buf, false, { - on_lines = instance.buf_callback.on_lines, - on_detach = instance.buf_callback.on_detach, - }), - '[sos.nvim]: failed to attach to buffer ' .. buf - ) - - self.listeners[buf] = true - end - end - - ---@param buf integer - ---@return boolean | nil - function instance:should_detach(buf) - -- If/once the observer has been destroyed, we want to always return - -- true here. This is because of the way that observing is - -- reenabled/restarted. Instead of trying to restart the observer (if - -- needed later on), it's probably best/easiest to simply just create - -- a fresh/new observer. In this case we want the old observer to - -- discontinue and detach all of its callbacks. `should_detach()` is - -- what notifies the callbacks to detach themselves the next time they - -- fire. Currently, the only way to detach Neovim's buffer callbacks - -- is by notifying them to return true the next time they fire, which - -- is what `should_detach()` does when it is called inside a callback - -- and returns true. - return did_destroy or self.pending_detach[buf] - end - - ---Detach buffer callbacks if not already detached - ---@param buf integer - ---@return nil - function instance:detach(buf) - if self.listeners[buf] then self.pending_detach[buf] = true end - end - - ---Attach or detach buffer callbacks if needed - ---@param buf integer - ---@return nil - function instance:process_buf(buf) - if buf == 0 then buf = api.nvim_get_current_buf() end - - if self.cfg.should_observe_buf(buf) then - if api.nvim_buf_is_loaded(buf) then self:attach(buf) end - else - self:detach(buf) - end - end - - ---Destroy this observer - ---@return nil - function instance:destroy() - self.timer:stop() - did_destroy = true - - for _, id in ipairs(self.autocmds) do - api.nvim_del_autocmd(id) - end - - self.autocmds = {} - self.listeners = {} - self.pending_detach = {} - end - - ---Begin observing buffers with this observer. - function instance:start() - assert(not did_start, 'unable to start an already running MultiBufObserver') - - assert(not did_destroy, 'unable to start a destroyed MultiBufObserver') - - did_start = true - - vim.list_extend(self.autocmds, { - api.nvim_create_autocmd('OptionSet', { - pattern = { 'buftype', 'readonly', 'modifiable' }, - desc = 'Handle buffer type and option changes', - callback = function(info) self:process_buf(info.buf) end, - }), - - -- `BufNew` event - -- does the buffer always not have a name? i.e. is the name applied later? - -- has the file been read yet? - -- assert that this triggers when a new buffer w/o name gets name via :write - -- assert that this works for every new buffer incl those with files, and - -- without - -- assert that this fires when a buf loses it's filename (renamed to "") - -- - -- After a loaded buf is changed ('mod' is changed), but not for - -- scratch buffers. No longer using `BufNew` because: - -- * it fires before buf is loaded sometimes - -- * sometimes a buf is created but not loaded (e.g. `:badd`) - api.nvim_create_autocmd('BufModifiedSet', { - pattern = '*', - desc = 'Attach buffer callbacks to listen for changes', - callback = function(info) - local buf = info.buf - local modified = vim.bo[buf].mod - - -- Can only attach if loaded. Also, an unloaded buf should - -- not be able to become modified, so this event should - -- never fire for unloaded bufs. - if not api.nvim_buf_is_loaded(buf) then - errmsg '[sos.nvim]: unexpected BufModifiedSet event on unloaded buffer' - return - end - - -- Ignore if buf was set to `nomod`, as is the case when - -- buf is written - if modified then - self:process_buf(buf) - -- Manually signal savable change because: - -- 1. Callbacks/listeners may not have been - -- attached when BufModifiedSet fired, in which - -- case they will have missed this change. - -- - -- 2. `buf` may have incurred a savable change - -- even though no text changed (see `:h - -- 'mod'`), and that is what made - -- BufModifiedSet fire. Since we're not using - -- the `on_changedtick` buf listener/callback, - -- BufModifiedSet is our only way to detect - -- this type of change. - self:on_change(buf) - end - end, - }), - }) - - for _, bufnr in ipairs(api.nvim_list_bufs()) do - self:process_buf(bufnr) - end - end - - return instance -end - -return MultiBufObserver diff --git a/lua/sos/commands.lua b/lua/sos/commands.lua index a66d26d..ce037b6 100644 --- a/lua/sos/commands.lua +++ b/lua/sos/commands.lua @@ -1,11 +1,15 @@ +local errmsg = require('sos.util').errmsg local api = vim.api -local extkeys = { action = true } +local extkeys = { [1] = true } -- TODO: types +---@class (exact) sos.Command: vim.api.keyset.user_command +---@field [1] string|function + local function Command(def) return setmetatable(def, { - __call = function(f, ...) return f.action(...) end, + __call = function(self, ...) return self[1](...) end, }) end @@ -19,13 +23,14 @@ local function filter_extkeys(tbl) return ret end +---@param parent table local function Commands(parent, ret) ret = ret or {} for k, v in pairs(parent) do - if type(v) == 'table' and v.action then + if type(v) == 'table' and v[1] then ret[k] = Command(v) - api.nvim_create_user_command(k, v.action, filter_extkeys(v)) + api.nvim_create_user_command(k, v[1], filter_extkeys(v)) else Commands(v, ret) end @@ -34,23 +39,85 @@ local function Commands(parent, ret) return ret end +-- local function assert_msg(cond, msg, ...) +-- if not cond then +-- api.nvim_notify(msg:format(...), vim.log.levels.ERROR, {}) +-- coroutine.yield(cond) +-- error 'resumed dead coroutine' +-- end +-- +-- return cond +-- end +-- +-- local function ignore_error(f) +-- return function(...) +-- local ok, e = pcall(f) +-- if not ok and e and e ~= '' then error(e) end +-- end +-- end +-- +-- local function resolve_bufspec(bufspec) end + return Commands { SosEnable = { desc = 'Enable sos autosaver', - action = function() require('sos').setup { enabled = true } end, + nargs = 0, + force = true, + function() require('sos').enable(true) end, }, SosDisable = { desc = 'Disable sos autosaver', - action = function() require('sos').setup { enabled = false } end, + nargs = 0, + force = true, + function() require('sos').disable(true) end, }, SosToggle = { desc = 'Toggle sos autosaver', - action = function() - require('sos').setup { - enabled = not require('sos.config').enabled, - } + nargs = 0, + force = true, + function() + if require('sos.config').enabled then + require('sos').disable(true) + else + require('sos').enable(true) + end + end, + }, + + SosBufToggle = { + desc = 'Toggle autosaver for buffer', + nargs = '?', + count = -1, + addr = 'buffers', + complete = 'buffer', + force = true, + function(info) + if #info.fargs > 1 then return errmsg 'only 1 argument is allowed' end + local buf + + if info.range > 0 then + -- Here we either have range and int arg, or just range. No way to + -- decipher between the two. `count` is rightmost of the two on cmdline. + buf = info.count + + if #info.fargs > 0 then + return errmsg 'only 1 arg or count is allowed, got both' + elseif info.range > 1 then + return errmsg 'only 1 arg or count is allowed, got range' + elseif buf < 1 or not api.nvim_buf_is_valid(buf) then + return errmsg('invalid buffer: ' .. buf) + end + else + local arg = info.fargs[1] + + -- Use `[$]` for `$`, otherwise we'll get highest bufnr. + buf = vim.fn.bufnr(arg == '$' and '[$]' or arg or '') + if buf < 1 then errmsg 'argument matched none or multiple buffers' end + end + + require('sos').toggle_buf(buf, true) end, }, } diff --git a/lua/sos/config.lua b/lua/sos/config.lua index de965b4..072156f 100644 --- a/lua/sos/config.lua +++ b/lua/sos/config.lua @@ -1,12 +1,13 @@ ----@class sos.Config # Plugin options passed to `setup()`. ----@field enabled boolean | nil # Whether to enable or disable the plugin. ----@field timeout integer | nil # Timeout in ms. Buffer changes debounce the timer. ----@field autowrite boolean | "all" | nil # Set and manage Vim's 'autowrite' option. ----@field save_on_cmd "all" | "some" | table | false | nil # Save all buffers before executing a command on cmdline ----@field save_on_bufleave boolean | nil # Save current buffer on `BufLeave` (see `:h BufLeave`) ----@field save_on_focuslost boolean | nil # Save all bufs when Neovim loses focus or is suspended. ----@field should_observe_buf nil | fun(buf: integer): boolean # Return true to observe/attach to buf. ----@field on_timer function # The function to call when the timer fires. +---@class sos.Config # Plugin options passed to `setup()`. +---@field enabled? boolean # Whether to enable or disable the plugin. +---@field timeout? integer # Timeout in ms. Buffer changes debounce the timer. +---@field autowrite? boolean | "all" # Set and manage Vim's 'autowrite' option. +---@field save_on_cmd? "all" | "some" | table | false # Save all buffers before executing a command on cmdline +---@field save_on_bufleave? boolean # Save current buffer on `BufLeave` (see `:h BufLeave`) +---@field save_on_focuslost? boolean # Save all bufs when Neovim loses focus or is suspended. +---@field should_observe_buf? fun(buf: integer): boolean # Return true to observe/attach to buf. +---@field on_timer? function # The function to call when the timer fires. + local defaults = { enabled = true, timeout = 20000, diff --git a/lua/sos/impl.lua b/lua/sos/impl.lua index 32ef9d9..4a639a8 100644 --- a/lua/sos/impl.lua +++ b/lua/sos/impl.lua @@ -15,32 +15,40 @@ M.savable_cmds = setmetatable({ __index = function(_tbl, key) return vim.startswith(key, 'Plenary') end, }) --- TODO: Allow user to provide custom vim regex via opts/cfg? +-- TODO: Allow user to provide custom vim regex via opts/cfg? Ignore `:set` and +-- our own commands. M.savable_cmdline = vim.regex [=[system\|:lua\|[Jj][Oo][Bb]]=] -local recognized_buftypes = - vim.regex [[\%(^$\)\|\%(^\%(acwrite\|help\|nofile\|nowrite\|quickfix\|terminal\|prompt\)$\)]] +local recognized_buftypes = { + [''] = true, + acwrite = true, + help = false, + nofile = false, + nowrite = false, + quickfix = false, + terminal = false, + prompt = false, +} ---@param val any ---@return boolean -local function tobool(val) return val == true or val == 1 end +local function to_bool(val) return val == true or val == 1 end ---@param buf integer ---@nodiscard ---@return boolean local function wanted_buftype(buf) local buftype = vim.bo[buf].bt + local wanted = recognized_buftypes[buftype] - if not recognized_buftypes:match_str(buftype) then + if wanted == nil then vim.notify_once( ('[sos.nvim]: ignoring buf with unknown buftype "%s"'):format(buftype), vim.log.levels.WARN ) - - return false end - return buftype == '' or buftype == 'acwrite' + return wanted or false end local err @@ -79,12 +87,14 @@ end ---@nodiscard ---@return boolean, string? function M.write_buf_if_needed(buf) + -- TODO: bufloaded, modifiable, acwrite pattern if vim.bo[buf].mod and vim.o.write and not vim.bo[buf].ro and api.nvim_buf_is_loaded(buf) and wanted_buftype(buf) + and not vim.b[buf].sos_ignore then local name = api.nvim_buf_get_name(buf) -- Cannot write to an empty filename @@ -124,7 +134,7 @@ function M.write_buf_if_needed(buf) -- Parent dir exists but isn't writeable. return true elseif dir_errname == 'ENOENT' then - if tobool(vim.fn.mkdir(dir, 'p')) then return write_buf(buf) end + if to_bool(vim.fn.mkdir(dir, 'p')) then return write_buf(buf) end -- Parent dir doesn't exist, failed to create it (e.g. -- perms). @@ -168,7 +178,7 @@ function M.on_timer() end end - if errs[1] ~= nil then api.nvim_err_writeln(table.concat(errs, '\n')) end + if #errs > 0 then api.nvim_err_writeln(table.concat(errs, '\n')) end end return M diff --git a/lua/sos/init.lua b/lua/sos/init.lua index 45009ec..b90ebcb 100644 --- a/lua/sos/init.lua +++ b/lua/sos/init.lua @@ -49,18 +49,33 @@ Cons TODO: Command/Fn/Opt to enable/disable locally (per buf) --]] ----@class sos.Timer ----@field start function ----@field stop function - -local M = {} -local MultiBufObserver = require 'sos.bufevents' +local MultiBufObserver = require 'sos.observer' local autocmds = require 'sos.autocmds' local cfg = require 'sos.config' +local util = require 'sos.util' local errmsg = require('sos.util').errmsg local api = vim.api -local loop = vim.loop -local augroup_init = 'sos-autosaver/init' +local augroup_init = 'sos-autosaver.init' + +---@class sos +local mt = { buf_observer = MultiBufObserver:new() } + +---@type sos +local M = setmetatable({}, { __index = mt }) + +---@param unset_ok? boolean don't error if the global is unset +---@return table? module # the current module if it was reloaded, otherwise `nil` +local function was_reloaded(unset_ok) + local m = _G.__sos_autosaver__ + assert(unset_ok or m) + return m ~= M and m or nil +end + +local function redirect_call() + local current = was_reloaded() + if current then setmetatable(M, getmetatable(current)) end + return current +end local function manage_vim_opts(config, plug_enabled) local aw = config.autowrite @@ -84,60 +99,8 @@ local function manage_vim_opts(config, plug_enabled) -- it then. end -local function start(verbose) - manage_vim_opts(cfg, true) - autocmds.refresh(cfg) - if __sos_autosaver__.buf_observer ~= nil then return end - - __sos_autosaver__.buf_observer = - MultiBufObserver:new(cfg, __sos_autosaver__.timer) - - __sos_autosaver__.buf_observer:start() - if verbose then vim.notify('[sos.nvim]: enabled', vim.log.levels.INFO) end -end - -local function stop(verbose) - manage_vim_opts(cfg, false) - autocmds.clear() - if __sos_autosaver__.buf_observer == nil then return end - __sos_autosaver__.buf_observer:destroy() - __sos_autosaver__.buf_observer = nil - if verbose then vim.notify('[sos.nvim]: disabled', vim.log.levels.INFO) end -end - --- Init the global obj --- --- The point of this is so that we can reload the plugin and persist some --- things while doing so. --- --- 1. Don't have to worry about leaking the long-lived timer (although it --- porbably destroys itself anyway when garbage collected because the --- timer userdata has a `__gc` handler in its metatable) because it --- only gets created once and only once. --- --- 2. It's not really possible/easy to detach `nvim_buf_attach` callbacks --- after reloading the plugin, and we don't want different callbacks --- with (potentially) different behavior attached to different buffers --- (e.g. the plugin is reloaded/re-sourced during development). -if __sos_autosaver__ == nil then - local t = loop.new_timer() - loop.unref(t) - __sos_autosaver__ = { - timer = t, - buf_observer = nil, - } -else - -- Plugin was reloaded somehow - rawset(cfg, 'enabled', nil) - -- Destroy the old observer - stop() - -- Cancel potential pending call (if vim hasn't entered yet) - api.nvim_create_augroup(augroup_init, { clear = true }) -end - ----@param verbose? boolean ----@return nil -local function main(verbose) +---@return boolean awaiting +local function defer_init() if vim.v.vim_did_enter == 0 or vim.v.vim_did_enter == false then api.nvim_create_augroup(augroup_init, { clear = true }) @@ -146,17 +109,35 @@ local function main(verbose) pattern = '*', desc = 'Initialize sos.nvim', once = true, - callback = function() main(false) end, + callback = function() M.setup() end, }) - return + return true end - if cfg.enabled then - start(verbose) - else - stop(verbose) - end + return false +end + +---@param verbose? boolean +function mt.enable(verbose) + cfg.enabled = true + assert(not was_reloaded()) + if defer_init() then return end + manage_vim_opts(cfg, true) + autocmds.refresh(cfg) + M.buf_observer:start(cfg) + if verbose then vim.notify('[sos.nvim]: enabled', vim.log.levels.INFO) end +end + +---@param verbose? boolean +function mt.disable(verbose) + cfg.enabled = false + assert(not was_reloaded()) + if defer_init() then return end + manage_vim_opts(cfg, false) + autocmds.clear() + M.buf_observer:stop() + if verbose then vim.notify('[sos.nvim]: disabled', vim.log.levels.INFO) end end ---Missing keys in `opts` are left untouched and will continue to use their @@ -165,7 +146,7 @@ end ---@param opts? sos.Config ---@param reset? boolean Reset all options to their defaults before applying `opts` ---@return nil -function M.setup(opts, reset) +function mt.setup(opts, reset) vim.validate { opts = { opts, 'table', true } } if reset then @@ -187,7 +168,91 @@ function M.setup(opts, reset) end end - main(true) + if not defer_init() then + if cfg.enabled then + M.enable(false) + else + M.disable(false) + end + end +end + +---@param buf integer +---@param verbose? boolean +function mt.enable_buf(buf, verbose) + local ignored = M.buf_observer:ignore_buf(buf, false) + if verbose then + util.notify( + 'buf %s: %s', + nil, + nil, + ignored and 'disabled' or 'enabled', + util.bufnr_to_bufname(buf) or buf + ) + end +end + +---@param buf integer +---@param verbose? boolean +function mt.disable_buf(buf, verbose) + local ignored = M.buf_observer:ignore_buf(buf, true) + if verbose then + util.notify( + 'buf %s: %s', + nil, + nil, + ignored and 'disabled' or 'enabled', + util.bufnr_to_bufname(buf) or buf + ) + end +end + +---@param buf integer +---@param verbose? boolean +function mt.toggle_buf(buf, verbose) + local ignored = M.buf_observer:toggle_ignore_buf(buf) + if verbose then + util.notify( + 'buf %s: %s', + nil, + nil, + ignored and 'disabled' or 'enabled', + util.bufnr_to_bufname(buf) or buf + ) + end +end + +do + require 'sos.commands' + + -- Init the global obj + -- + -- The point of this is so that we can reload the plugin and persist some + -- things while doing so. + -- + -- 1. Don't have to worry about leaking the long-lived timer (although it + -- porbably destroys itself anyway when garbage collected because the + -- timer userdata has a `__gc` handler in its metatable) because it + -- only gets created once and only once. + -- + -- 2. It's not really possible/easy to detach `nvim_buf_attach` callbacks + -- after reloading the plugin, and we don't want different callbacks + -- with (potentially) different behavior attached to different buffers + -- (e.g. the plugin is reloaded/re-sourced during development). + local old = was_reloaded(true) + + if old then + -- Plugin was reloaded somehow + rawset(cfg, 'enabled', nil) + + -- TODO: Forcefully detach buf callbacks? Emit a warning? + old.stop() + + -- Cancel potential pending call (if vim hasn't entered yet) + api.nvim_create_augroup(augroup_init, { clear = true }) + end + + _G.__sos_autosaver__ = M end return M diff --git a/lua/sos/observer.lua b/lua/sos/observer.lua new file mode 100644 index 0000000..590b1aa --- /dev/null +++ b/lua/sos/observer.lua @@ -0,0 +1,204 @@ +local api, uv = vim.api, vim.uv or vim.loop + +---An object which observes multiple buffers for changes at once. +local MultiBufObserver = {} + +---Constructor +---@return sos.MultiBufObserver +function MultiBufObserver:new() + local running = false + local timer = uv.new_timer() + uv.unref(timer) + + ---@class sos.MultiBufObserver + local instance = { + autocmds = {}, + ---@type table + listeners = {}, + ---@type table + pending_detach = {}, + } + + ---Called whenever a buffer incurs a savable change (i.e. writing the buffer + ---would change the file's contents on the filesystem). All this does is + ---debounce the timer. + --- + ---NOTE: this triggers often, so it should return quickly! + ---@param buf integer + ---@return true | nil + function instance:on_change(buf) + if not running or self.pending_detach[buf] then return true end -- detach + local result, err, _ = timer:stop() + assert(result == 0, err) + result, err, _ = timer:start(self.timeout, 0, self.on_timer) + assert(result == 0, err) + end + + ---Attach buffer callbacks if not already attached + ---@param buf integer + ---@return nil + function instance:attach(buf) + self.pending_detach[buf] = nil + + if self.listeners[buf] == nil then + assert( + api.nvim_buf_attach(buf, false, { + ---NOTE: this fires on EVERY single change of the buf text, even if + ---the text is replaced with the same text, and fires on every + ---keystroke in insert mode. + on_lines = function(_, buf) return instance:on_change(buf) end, + + ---TODO: Could this leak memory? A new fn/closure is created every + ---time a new observer is created. The closure references `instance`, + ---while nvim refs the closure (even after the observer is destroyed). + ---The ref to the closure isn't/can't be dropped until the next time + ---`on_lines` triggers, which may be awhile or never even. A buildup + ---of allocated memory might happen simply by disabling and enabling + ---sos over and over again as new callbacks/closures are attached and + ---old ones aren't detached. + on_detach = function(_, buf) + instance.listeners[buf], instance.pending_detach[buf] = nil, nil + end, + }), + '[sos.nvim]: failed to attach to buffer ' .. buf + ) + + self.listeners[buf] = true + end + end + + ---Detaches any attached buffer callbacks. + ---@param buf integer + ---@return nil + function instance:detach(buf) + if self.listeners[buf] then self.pending_detach[buf] = true end + end + + ---@param buf integer + function instance:should_observe_buf(buf) + return not vim.b[buf].sos_ignore and self.should_observe_buf_cb(buf) + end + + ---Attaches or detaches buffer callbacks as needed. + ---@param buf integer + ---@return boolean observed whether the buffer will be observed + function instance:process_buf(buf) + if buf == 0 then buf = api.nvim_get_current_buf() end + + if not self:should_observe_buf(buf) then + self:detach(buf) + elseif api.nvim_buf_is_loaded(buf) then + self:attach(buf) + return true + end + + return false + end + + ---@param buf integer + ---@return boolean ignored whether the buffer is now ignored + function instance:toggle_ignore_buf(buf) + if buf == 0 then buf = api.nvim_get_current_buf() end + return self:ignore_buf(buf, not vim.b[buf].sos_ignore) + end + + ---@param buf integer + ---@param ignore boolean + ---@return boolean ignored whether the buffer is now ignored + function instance:ignore_buf(buf, ignore) + if buf == 0 then buf = api.nvim_get_current_buf() end + assert(api.nvim_buf_is_valid(buf), 'invalid buffer number: ' .. buf) + vim.b[buf].sos_ignore = ignore or nil + self:process_buf(buf) + return ignore + end + + ---Destroy this observer + ---@return nil + function instance:stop() + timer:stop() + running = false + + for _, id in ipairs(self.autocmds) do + api.nvim_del_autocmd(id) + end + + self.autocmds = {} + end + + ---@class sos.MultiBufObserver.start.opts + ---@field [string] any + ---@field timeout integer timeout in milliseconds + ---@field on_timer function + ---@field should_observe_buf fun(buf: integer): boolean + + ---Begin observing buffers with this observer. Ok to call when already + ---running. + ---@param opts sos.MultiBufObserver.start.opts + function instance:start(opts) + self.timeout = opts.timeout + self.on_timer = vim.schedule_wrap(opts.on_timer) + self.should_observe_buf_cb = opts.should_observe_buf + if running then return end + running = true + + vim.list_extend(self.autocmds, { + api.nvim_create_autocmd('OptionSet', { + pattern = { 'buftype', 'readonly', 'modifiable' }, + desc = 'Handle buffer type and option changes', + callback = function(info) self:process_buf(info.buf) end, + }), + + -- `BufNew` event + -- does the buffer always not have a name? i.e. is the name applied later? + -- has the file been read yet? + -- assert that this triggers when a new buffer w/o name gets name via :write + -- assert that this works for every new buffer incl those with files, and + -- without + -- assert that this fires when a buf loses it's filename (renamed to "") + -- + -- After a loaded buf is changed ('mod' is changed), but not for + -- scratch buffers. No longer using `BufNew` because: + -- * it fires before buf is loaded sometimes + -- * sometimes a buf is created but not loaded (e.g. `:badd`) + api.nvim_create_autocmd('BufModifiedSet', { + pattern = '*', + desc = 'Lazily attach buffer callbacks to listen for changes', + callback = function(info) + local buf = info.buf + local modified = vim.bo[buf].mod + if buf == 0 then buf = api.nvim_get_current_buf() end + + -- Can only attach if loaded. Can only write/save if loaded. + if not api.nvim_buf_is_loaded(buf) then return end + + -- Ignore if buf was set to `nomod`, as is the case when buf is + -- written + if modified then + if self:process_buf(buf) then + -- Manually signal savable change because: + -- 1. Callbacks/listeners may not have been attached when + -- BufModifiedSet fired, in which case they will have missed + -- this change. + -- + -- 2. `buf` may have incurred a savable change even though no + -- text changed (see `:h 'mod'`), and that is what made + -- BufModifiedSet fire. Since we're not using the + -- `on_changedtick` buf listener/callback, BufModifiedSet is + -- our only way to detect this type of change. + self:on_change(buf) + end + end + end, + }), + }) + + for _, bufnr in ipairs(api.nvim_list_bufs()) do + self:process_buf(bufnr) + end + end + + return instance +end + +return MultiBufObserver diff --git a/lua/sos/plugin.lua b/lua/sos/plugin.lua deleted file mode 100644 index 2bd86bb..0000000 --- a/lua/sos/plugin.lua +++ /dev/null @@ -1 +0,0 @@ -require 'sos.commands' diff --git a/lua/sos/util.lua b/lua/sos/util.lua index 9caa468..463e893 100644 --- a/lua/sos/util.lua +++ b/lua/sos/util.lua @@ -1,17 +1,37 @@ local api = vim.api local M = {} -do - -- TODO - local msg_type = {} +---Displays an error message. +---@param fmt string +---@param ... unknown fmt arguments +---@return nil +function M.errmsg(fmt, ...) + api.nvim_err_writeln('[sos.nvim]: ' .. (fmt):format(...)) +end + +function M.notify(fmt, level, opts, ...) + vim.notify( + '[sos.nvim]: ' .. (fmt):format(...), + level or vim.log.levels.INFO, + opts or {} + ) +end + +---@param buf integer +---@return string? +function M.bufnr_to_bufname(buf) + local name = vim.fn.bufname(buf) + return #name > 0 and name or nil +end - ---Display an error message - ---@param msg string - ---@param how? "n" | "no" - ---@return nil - function M.errmsg(msg, how) - return (msg_type[how] or api.nvim_err_writeln)('[sos.nvim]: ' .. msg) +function M.getbufs() + local bufs = {} + for _, buf in ipairs(api.nvim_list_bufs()) do + if vim.bo[buf].mod and api.nvim_buf_is_loaded(buf) then + table.insert(bufs, buf) + end end + return bufs end return M diff --git a/perf/perf.lua b/perf/perf.lua index 84c6a57..9c5b90f 100644 --- a/perf/perf.lua +++ b/perf/perf.lua @@ -1,51 +1,123 @@ -local api = vim.api +local api, uv = vim.api, vim.uv or vim.loop +local M = {} -local function time_it_once(fn) - local start = vim.loop.hrtime() - fn() - return vim.loop.hrtime() - start +local function time_it_once(fn, ...) + local start = uv.hrtime() + fn(...) + return uv.hrtime() - start end -local function time_it(fn) - local res = {} - local i = 0 - while i < 100 do - table.insert(res, time_it_once(fn)) - i = i + 1 +local function fmtnum(n) + if type(n) ~= 'string' then n = string.format('%d', math.floor(n)) end + + local i, res = #n, {} + repeat + table.insert(res, 1, n:sub(math.max(i - 2, 1), i)) + i = i - 3 + until i < 1 + + return table.concat(res, ',') +end + +local function time_it(fn, opts) + local iter = opts.iterations or 1000 + local setup = opts.setup + local warmup = opts.warmup + + local res = require 'table.new'(iter, 0) + local retval = setup and { setup() } or {} + local args = opts.args or retval + + local function run(iterations) + collectgarbage 'restart' + collectgarbage 'collect' + collectgarbage 'stop' + for _ = 1, iterations do + table.insert(res, time_it_once(fn, unpack(args))) + end + end + + if warmup then + run(type(warmup) == 'number' and warmup or iter) + require 'table.clear'(res) end - local sum = 0 - i = 0 + + run(iter) + + local count, sum = 0, 0 for _, x in ipairs(res) do sum = sum + x - i = i + 1 + count = count + 1 end - return sum / i -end -local function call_it(times, fn) - local i = 0 - while i < times do - fn() - i = i + 1 - end + collectgarbage 'restart' + collectgarbage 'collect' + return sum / count end -local builtin_args = { bufmodified = 1 } -local function builtin() return vim.fn.getbufinfo(builtin_args) end - -local function manual() - local filtered = {} +local nvim_get_option_value = api.nvim_get_option_value +local function manual(bufs) + local filtered = require 'table.new'(#bufs, 0) + -- local o = { buf = 0 } for _, buf in ipairs(api.nvim_list_bufs()) do - if vim.bo[buf].mod then table.insert(filtered, buf) end + -- o.buf = buf + -- if api.nvim_get_option_value('mod', { buf = buf }) then + if vim.o.write then table.insert(filtered, buf) end + -- if vim.bo[buf].mod then table.insert(filtered, buf) end + -- if math.random() > 0.5 then table.insert(filtered, buf) end end + -- local bufs = api.nvim_list_bufs() + -- local j = 1 + -- for i = 1, #bufs do + -- local buf = bufs[i] + -- + -- if vim.bo[buf].mod then + -- bufs[j] = buf + -- j = j + 1 + -- end + -- end + return filtered end -local function print_it(lbl, time) print(lbl .. ' took ' .. time .. 'ns on avg') end +local function print_it(label, time) + -- if not label then debug.getinfo() end + print(label .. ' took ' .. fmtnum(time) .. 'ns (average)') + return time +end + +function M.bench(def) print_it(def.name, time_it(def[1], def)) end + +-- vim.print(debug.getinfo(M.bench)) + +jit.off(manual, true) + +M.bench { + name = 'manual()', + args = { api.nvim_list_bufs() }, + warmup = true, + manual, +} -call_it(1e3, builtin) -print_it('getbufinfo()', time_it(builtin)) +M.bench { + name = 'getbufinfo()', + args = { { bufmodified = 1, bufloaded = 1 } }, + warmup = true, + vim.fn.getbufinfo, +} -call_it(1e3, manual) -print_it('manual()', time_it(builtin)) +-- collectgarbage 'restart' +-- collectgarbage 'collect' +-- collectgarbage 'stop' +-- +-- -- call_it(1e3, manual) +-- print_it('manual()', time_it(manual)) +-- +-- collectgarbage 'collect' +-- collectgarbage 'stop' +-- +-- -- call_it(1e3, builtin) +-- print_it('getbufinfo()', time_it(builtin)) +-- +-- collectgarbage 'restart' diff --git a/plugin/sos.lua b/plugin/sos.lua index 7df6d87..c2de531 100644 --- a/plugin/sos.lua +++ b/plugin/sos.lua @@ -1 +1 @@ -require 'sos.plugin' +require 'sos'