diff --git a/lua/sos/_test/action.lua b/lua/sos/_test/action.lua new file mode 100644 index 0000000..10396cf --- /dev/null +++ b/lua/sos/_test/action.lua @@ -0,0 +1,46 @@ +local util = require 'sos._test.util' +local api = vim.api +local M = { buf = {} } + +api.nvim_feedkeys( + api.nvim_replace_termcodes(':new', true, true, true), + 'L', + -- 'mtx', + false +) + +function M.input(keys) + api.nvim_feedkeys( + api.nvim_replace_termcodes(keys, true, true, true), + 'L', + -- 'mtx', + false + ) + + -- util.await_schedule() + vim.defer_fn(util.coroutine_resumer(false), 100) +end + +---NOTE: May change the current buffer! +function M.trigger_save(buf) + assert.equals('n', api.nvim_get_mode().mode, 'not in normal mode') + assert.is_false(api.nvim_get_mode().blocking) + + if buf and buf ~= 0 and buf ~= api.nvim_get_current_buf() then + assert(api.nvim_buf_is_valid(buf), 'invalid buffer number: ' .. buf) + local ei = vim.o.ei + vim.o.ei = 'all' + api.nvim_set_current_buf(buf) + vim.o.ei = ei + end + + M.input ':new' +end + +function M.buf.modify() + assert.is_false(vim.bo.mod, 'buffer is modified prior to modification') + M.input 'ochanges' + assert.is_true(vim.bo.mod, 'unable to modify buffer') +end + +return M diff --git a/lua/sos/_test/assert.lua b/lua/sos/_test/assert.lua new file mode 100644 index 0000000..83f9a0a --- /dev/null +++ b/lua/sos/_test/assert.lua @@ -0,0 +1,38 @@ +local api = vim.api +local M = {} + +function M.all_bufs_saved() + assert.same( + {}, + vim.fn.getbufinfo { bufmodified = 1 }, + 'all buffers should be saved, and none modified' + ) +end + +---@param buf number +function M.saved(buf) + vim.validate { buf = { buf, { 'number' }, false } } + local file = api.nvim_buf_get_name(buf) + assert.is_false(vim.bo[buf].mod, 'buffer is still modified') + assert.same( + api.nvim_buf_get_lines(file, 0, -1, true), + vim.fn.readfile(file), + "buffer wasn't saved" + ) +end + +---@param buf number +function M.unsaved(buf) + vim.validate { buf = { buf, { 'number' }, false } } + local file = api.nvim_buf_get_name(buf) + assert.is_true(vim.bo[buf].mod, "buffer shouldn't have been saved") + local ok, content = pcall(vim.fn.readfile, file) + if not ok then return end + assert.not_same( + api.nvim_buf_get_lines(file, 0, -1, true), + content, + "buffer shouldn't have been saved" + ) +end + +return M diff --git a/lua/sos/_test/util.lua b/lua/sos/_test/util.lua index dbd6dee..c8f6ee3 100644 --- a/lua/sos/_test/util.lua +++ b/lua/sos/_test/util.lua @@ -4,7 +4,7 @@ local M = {} local api = vim.api -local uv = vim.loop +local uv = vim.uv or vim.loop local sleep = uv.sleep local co = coroutine local tmpfiles @@ -74,18 +74,20 @@ function M.bufwritemock(onwrite) }) end +---@return integer bufnr ---@return string output ----@overload fun(nvim: table, file?: string): string ----@overload fun(file?: string): string +---@overload fun(nvim: table, file?: string) +---@overload fun(file?: string) function M.silent_edit(...) local external_nvim_or_api, file = M.nvim_recv_or_api(...) - - return external_nvim_or_api.nvim_cmd({ + local out = external_nvim_or_api.nvim_cmd({ cmd = 'edit', args = { file }, magic = { file = false, bar = false }, mods = { silent = true }, }, { output = true }) + + return api.nvim_get_current_buf(), out end ---@param keys string @@ -386,7 +388,7 @@ end ---@param fn function ---@return number ns function M.time_it_once(fn) - local hrtime = vim.loop.hrtime + local hrtime = uv.hrtime local start = hrtime() fn() return hrtime() - start @@ -427,7 +429,7 @@ end ---@param cb function ---@return unknown function M.set_timeout(ms, cb) - local timer = vim.loop.new_timer() + local timer = uv.new_timer() timer:start(ms, 0, function() timer:stop() 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..32fb896 100644 --- a/lua/sos/commands.lua +++ b/lua/sos/commands.lua @@ -1,11 +1,16 @@ +local errmsg = require('sos.util').errmsg local api = vim.api -local extkeys = { action = true } +local extkeys = { [1] = true } +local M = {} -- 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,38 +24,97 @@ local function filter_extkeys(tbl) return ret end -local function Commands(parent, ret) - ret = ret or {} +---@generic T: table +---@param cmds T +---@return T +local function Commands(cmds) + local ret = {} - for k, v in pairs(parent) do - if type(v) == 'table' and v.action then - ret[k] = Command(v) - api.nvim_create_user_command(k, v.action, filter_extkeys(v)) - else - Commands(v, ret) - end + for k, v in pairs(cmds) do + ret[k] = Command(v) + api.nvim_create_user_command(k, v[1], filter_extkeys(v)) end return ret end -return Commands { - SosEnable = { - desc = 'Enable sos autosaver', - action = function() require('sos').setup { enabled = true } end, - }, +---Verifies and resolves a single buffer from a command invocation. 0 or 1 +---buffer may be specified (with current buffer as fallback) via argument or +---range (i.e. bufnr as the range). Accepts bufnr, bufname, bufname pattern, or +---shorthand (e.g. `%`). Attempts to follow the semantics of the builtin +---`:[N]buffer [bufname]` command in terms of resolving the specified buffer. +---@param info table +---@return integer? bufnr # bufnr or nil if specified buffer is invalid +function M.resolve_bufspec(info) + if #info.fargs > 1 then return errmsg 'only 1 argument is allowed' end + local buf - SosDisable = { - desc = 'Disable sos autosaver', - action = function() require('sos').setup { enabled = false } end, - }, + 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 2-part range' + elseif buf < 1 or not api.nvim_buf_is_valid(buf) then + return errmsg('invalid bufnr: ' .. 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 + + return buf +end + +return setmetatable( + Commands { + SosEnable = { + desc = 'Enable sos autosaver', + nargs = 0, + force = true, + function() require('sos').enable(true) end, + }, + + SosDisable = { + desc = 'Disable sos autosaver', + nargs = 0, + force = true, + function() require('sos').disable(true) end, + }, + + SosToggle = { + desc = 'Toggle sos autosaver', + nargs = 0, + force = true, + function() + if require('sos.config').enabled then + require('sos').disable(true) + else + require('sos').enable(true) + end + end, + }, - SosToggle = { - desc = 'Toggle sos autosaver', - action = function() - require('sos').setup { - enabled = not require('sos.config').enabled, - } - end, + SosBufToggle = { + desc = 'Toggle autosaver for buffer (default: current buffer)', + nargs = '?', + count = -1, + addr = 'buffers', + complete = 'buffer', + force = true, + function(info) + local buf = M.resolve_bufspec(info) + if buf then require('sos').toggle_buf(buf, true) end + end, + }, }, -} + { + __index = M, + } +) 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..065a28d 100644 --- a/lua/sos/impl.lua +++ b/lua/sos/impl.lua @@ -1,6 +1,6 @@ +local util = require 'sos.util' +local api, uv = vim.api, vim.uv or vim.loop local M = {} -local api = vim.api -local uv = vim.loop ---@type table M.savable_cmds = setmetatable({ @@ -15,32 +15,36 @@ 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\)$\)]] - ----@param val any ----@return boolean -local function tobool(val) return val == true or val == 1 end +local recognized_buftypes = { + [''] = true, + acwrite = true, + help = false, + nofile = false, + nowrite = false, + quickfix = false, + terminal = false, + prompt = false, +} ---@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 +83,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 +130,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 util.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 +174,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..eea8e1a 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 errmsg = require('sos.util').errmsg +local util = require 'sos.util' +local errmsg = 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,61 +99,9 @@ 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) - if vim.v.vim_did_enter == 0 or vim.v.vim_did_enter == false then +---@return boolean awaiting +local function defer_init() + if util.to_bool(vim.v.vim_did_enter) then api.nvim_create_augroup(augroup_init, { clear = true }) api.nvim_create_autocmd('VimEnter', { @@ -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 util.notify 'enabled' 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 util.notify 'disabled' 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,107 @@ 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 + +---Enables/whitelists a buffer so that it may be autosaved. This is the default +---initial state of all buffers. +--- +---NOTE: An enabled buffer that becomes modified is not necessarily guaranteed +---to be saved (e.g. it won't be saved if the `'readonly'` vim option is set). +---@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( + 'buffer %s: #%d %s', + nil, + nil, + ignored and 'disabled' or 'enabled', + buf == 0 and api.nvim_get_current_buf() or buf, + util.bufnr_to_name(buf) or '' + ) + end +end + +---Disables/blacklists a buffer so that it will not be autosaved. +---@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( + 'buffer %s: #%d %s', + nil, + nil, + ignored and 'disabled' or 'enabled', + buf == 0 and api.nvim_get_current_buf() or buf, + util.bufnr_to_name(buf) or '' + ) + 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( + 'buffer %s: #%d %s', + nil, + nil, + ignored and 'disabled' or 'enabled', + buf == 0 and api.nvim_get_current_buf() or buf, + util.bufnr_to_name(buf) or '' + ) + end +end + +---Returns `false` if `buf` is completely ignored/blacklisted for autosaving. +--- +---NOTE: An enabled buffer that becomes modified is not necessarily guaranteed +---to be saved (e.g. it won't be saved if the `'readonly'` vim option is set). +---@param buf integer +function mt.buf_enabled(buf) return not M.buf_observer:buf_ignored(buf) 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 + -- probably 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..03211c3 --- /dev/null +++ b/lua/sos/observer.lua @@ -0,0 +1,213 @@ +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) + return self:ignore_buf(buf, not self:buf_ignored(buf)) + 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 + if running then self:process_buf(buf) end + return ignore + end + + ---@param buf integer + ---@return boolean ignored whether the buffer is ignored + function instance:buf_ignored(buf) + if buf == 0 then buf = api.nvim_get_current_buf() end + assert(api.nvim_buf_is_valid(buf), 'invalid buffer number: ' .. buf) + return vim.b[buf].sos_ignore == true + 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 + + function instance:due_in() return timer:get_due_in() 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..f62b006 100644 --- a/lua/sos/util.lua +++ b/lua/sos/util.lua @@ -1,17 +1,42 @@ 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_name(buf) + local name = vim.fn.bufname(buf) + return #name > 0 and name or nil +end + +---Converts vim boolean to Lua boolean. +---@param val any +---@return boolean +function M.to_bool(val) return val == 1 or val == true 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..ce7d572 100644 --- a/perf/perf.lua +++ b/perf/perf.lua @@ -1,51 +1,117 @@ -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 +---@class (exact) sos.bench +---@field [1] function +---@field name string +---@field warmup? boolean|integer warmup iterations +---@field iterations? integer +---@field args? any[] +---@field setup? function +---@field jit? boolean + +local function time_it_once(fn, ...) + local start = uv.hrtime() + fn(...) + return uv.hrtime() - start +end + +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) - local res = {} - local i = 0 - while i < 100 do - table.insert(res, time_it_once(fn)) - i = i + 1 +local function time_it(fn, opts) + local iter = opts.iterations or 1e4 + 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 + + jit[opts.jit == false and 'off' or 'on'](fn, true) + 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 + +---@param def sos.bench +function M.bench(def) print_it(def.name, time_it(def[1], def)) end + +-- vim.print(debug.getinfo(M.bench)) -call_it(1e3, builtin) -print_it('getbufinfo()', time_it(builtin)) +M.bench { + name = 'manual()', + args = { api.nvim_list_bufs() }, + warmup = true, + manual, +} -call_it(1e3, manual) -print_it('manual()', time_it(builtin)) +M.bench { + name = 'getbufinfo()', + args = { { bufmodified = 1, bufloaded = 1 } }, + warmup = true, + vim.fn.getbufinfo, +} 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' diff --git a/tests/sos/bugs/sos_disable_spec.lua b/tests/sos/bugs/sos_disable_spec.lua index 69d8524..30278ad 100644 --- a/tests/sos/bugs/sos_disable_spec.lua +++ b/tests/sos/bugs/sos_disable_spec.lua @@ -1,6 +1,7 @@ +local sos = require 'sos' +local util = require 'sos._test.util' local api = vim.api local co = coroutine -local util = require 'sos._test.util' describe('disabling the plugin', function() it('should stop the timer and not trigger save', function() @@ -9,13 +10,15 @@ describe('disabling the plugin', function() timeout = 1000, save_on_cmd = 'all', } - local timer = __sos_autosaver__.timer + util.silent_edit(util.tmpfile()) api.nvim_buf_set_lines(0, 0, -1, true, { 'changes' }) assert.is.True(vim.bo.mod) + util.set_timeout(100, util.coroutine_resumer(true)) co.yield() - assert(timer:get_due_in() > 0, timer:get_due_in()) + assert(sos.buf_observer:due_in() > 0, sos.buf_observer:due_in()) + api.nvim_feedkeys( api.nvim_replace_termcodes( [[:SosDisable]], @@ -26,12 +29,18 @@ describe('disabling the plugin', function() 'ntx', false ) - assert.is.True(vim.bo.mod, 'buffer saved on cmd') - assert(timer:get_due_in() > 0) - util.set_timeout(timer:get_due_in() + 200, util.coroutine_resumer(true)) + + assert.is.True(vim.bo.mod, 'buffer saved on :SosDisable') + + assert(sos.buf_observer:due_in() > 0) + util.set_timeout( + sos.buf_observer:due_in() + 200, + util.coroutine_resumer(true) + ) co.yield() - assert.equal(0, timer:get_due_in()) - assert.is.True(vim.bo.mod, 'timer fired') + + assert.equal(0, sos.buf_observer:due_in()) + assert.is.True(vim.bo.mod, 'timer fired and buffer saved AFTER :SosDisable') assert.equal('', vim.v.errmsg) end) end) diff --git a/tests/sos/command/commands_spec.lua b/tests/sos/command/commands_spec.lua new file mode 100644 index 0000000..72e91c1 --- /dev/null +++ b/tests/sos/command/commands_spec.lua @@ -0,0 +1,60 @@ +local api = vim.api +local action = require 'sos._test.action' +local asrt = require 'sos._test.assert' +local sos = require 'sos' +local util = require 'sos._test.util' + +describe('command', function() + before_each(function() + util.await_vim_enter() + vim.o.aw = false + vim.o.awa = false + vim.o.confirm = false + vim.cmd 'silent %bw!' + end) + + describe(':SosBufToggle', function() + it('works', function() + util.setup_plugin() + + assert.is_true(sos.buf_enabled(0)) + local buf = util.silent_edit(util.tmpfile()) + assert.is_true(sos.buf_enabled(0)) + + action.buf.modify() + assert.is_true(sos.buf_enabled(0)) + + action.input ':silent SosBufToggle' + assert.is_false(sos.buf_enabled(0)) + + action.trigger_save() + asrt.unsaved(buf) + assert.is_false(sos.buf_enabled(buf)) + + action.input(':silent SosBufToggle ' .. buf .. '') + assert.is_true(sos.buf_enabled(buf)) + action.trigger_save(buf) + asrt.saved(buf) + assert.is_true(sos.buf_enabled(buf)) + end) + + it('retains buf status when plugin is toggled', function() + local buf = api.nvim_get_current_buf() + action.input ':SosBufToggle' + assert.is_false(sos.buf_enabled(0)) + + action.input ':SosDisable' + assert.is_false(sos.buf_enabled(0)) + + util.silent_edit(util.tmpfile()) + assert.is_true(sos.buf_enabled(0)) + + action.input ':SosBufToggle' + assert.is_false(sos.buf_enabled(0)) + + action.input ':SosEnable' + assert.is_false(sos.buf_enabled(0)) + assert.is_false(sos.buf_enabled(buf)) + end) + end) +end) diff --git a/tests/sos/command/resolve_bufspec_spec.lua b/tests/sos/command/resolve_bufspec_spec.lua new file mode 100644 index 0000000..5fc8f00 --- /dev/null +++ b/tests/sos/command/resolve_bufspec_spec.lua @@ -0,0 +1,36 @@ +local util = require 'sos._test.util' + +describe('command', function() + -- (setup or before)(function() util.await_vim_enter() end) + + describe('bufspec parser', function() + -- TODO: These should probably test the underlying arg parsing/logic + -- function instead. + describe('argument', function() + pending('accepts bufame', function() end) + pending('accepts bufame pattern', function() end) + pending('accepts bufnr', function() end) + pending('accepts bufnr via arg', function() end) + pending('rejects 0 bufnr', function() end) + pending('rejects negative bufnr', function() end) + pending('accepts % as current buffer', function() end) + pending('accepts # as alternate buffer', function() end) + pending( + 'accepts $ as bufname literally (or pattern if no such buffer)', + function() end + ) + end) + + describe('range', function() + pending('is always rejected if it contains 2 parts', function() end) + pending('accepts bufnr', function() end) + pending('rejects 0 bufnr', function() end) + pending('rejects negative bufnr', function() end) + pending('rejects non-integer', function() end) + end) + + describe('argument+range', function() + pending('is accepted, but only argument is used', function() end) + end) + end) +end)