diff --git a/lua/git.lua b/lua/git.lua index 44992ee..d8252c7 100644 --- a/lua/git.lua +++ b/lua/git.lua @@ -101,7 +101,7 @@ function Repo:start_watcher() if err or (filename ~= "index" and filename ~= "HEAD") then return end - self.refresh:call() + self.refresh() end)) self.watcher = watcher end @@ -241,7 +241,7 @@ local function refresh(buf) vim.b[buf].git_status = nil return end - repo.refresh:call() + repo.refresh() end local M = {} diff --git a/lua/linter.lua b/lua/linter.lua index 313d369..695db17 100644 --- a/lua/linter.lua +++ b/lua/linter.lua @@ -435,14 +435,11 @@ function Linter.add(bufnr, config) return end - local debouncer = util.debounce(function() - linter:run() - end, config.debounce) vim.api.nvim_create_autocmd(config.events, { buffer = linter.bufnr, - callback = function() - debouncer:call() - end, + callback = util.debounce(function() + linter:run() + end, config.debounce) --[[@as fun()]], group = linter.augroup, }) diff --git a/lua/lsp/completion/session.lua b/lua/lsp/completion/session.lua index 4979eef..81e991e 100644 --- a/lua/lsp/completion/session.lua +++ b/lua/lsp/completion/session.lua @@ -249,7 +249,7 @@ end local session = Session.new() -local dispatcher = util.debounce(function(_, trigger_kind, char) +local dispatcher = util.debounce(function(trigger_kind, char) if vim.fn.mode() ~= "i" then return end diff --git a/lua/pack.lua b/lua/pack.lua index f43d099..69781ae 100644 --- a/lua/pack.lua +++ b/lua/pack.lua @@ -1,7 +1,7 @@ local log = require("log") local util = require("util") -local config_dir = vim.fn.stdpath("config") +local plugins_dir = vim.fs.joinpath(vim.fn.stdpath("config"), "plugins") ---@param path string ---@return boolean success @@ -51,9 +51,7 @@ local function plugin_config_path(name) return end - local path = vim.fs.joinpath(config_dir, "plugins", normalized .. ".lua") - - return path + return vim.fs.joinpath(plugins_dir, normalized .. ".lua") end ---@param name string @@ -152,7 +150,7 @@ end ---@type uv.uv_fs_event_t? local watcher = nil ----@type ow.Util.Debouncer? +---@type (fun(filename: string) | ow.Util.KeyedDebouncer)? local reload = nil ---@class ow.Pack @@ -185,10 +183,8 @@ function M.watch() return end - local plugins_dir = vim.fs.joinpath(config_dir, "plugins") - watcher = assert(vim.uv.new_fs_event()) - reload = util.debounce(function(filename) + reload = util.keyed_debounce(function(filename) local path = vim.fs.joinpath(plugins_dir, filename) if not vim.uv.fs_stat(path) then return @@ -209,7 +205,7 @@ function M.watch() if not filename or not filename:match("%.lua$") then return end - reload:call(filename) + reload(filename) end)) end @@ -219,7 +215,7 @@ function M.unwatch() end if reload then - reload:cancel() + reload:close() reload = nil end diff --git a/lua/util.lua b/lua/util.lua index 5c3de14..d65157e 100644 --- a/lua/util.lua +++ b/lua/util.lua @@ -1,8 +1,8 @@ local log = require("log") -local Util = {} +local M = {} -Util.os_name = vim.uv.os_uname().sysname +M.os_name = vim.uv.os_uname().sysname ---@alias OutputStream "stdout" | "stderr" | "in_place" @@ -26,7 +26,7 @@ Util.os_name = vim.uv.os_uname().sysname --- Format buffer ---@param opts ow.FormatOptions -function Util.format(opts) +function M.format(opts) opts = { buf = opts.buf or vim.api.nvim_get_current_buf(), cmd = opts.cmd, @@ -241,7 +241,7 @@ end ---@param kt type ---@param vt type ---@return boolean -function Util.is_map(val, kt, vt) +function M.is_map(val, kt, vt) if type(val) ~= "table" then return false end @@ -263,7 +263,7 @@ end ---@param val any ---@param t? type ---@return boolean -function Util.is_list(val, t) +function M.is_list(val, t) if not vim.islist(val) then return false end @@ -285,115 +285,178 @@ end ---@param val? any ---@param t? type ---@return boolean -function Util.is_list_or_nil(val, t) +function M.is_list_or_nil(val, t) if val == nil then return true else - return Util.is_list(val, t) + return M.is_list(val, t) end end -local NIL_KEY = {} - ----@class ow.Util.Debouncer.Slot ----@field timer uv.uv_timer_t ----@field cb function ----@field id any ----@field args table - ---@class ow.Util.Debouncer ----@field private _fn fun(id: any, ...) ----@field private _delay integer ----@field private _slots table +---@field package _fn fun(...) +---@field package _delay integer +---@field package _timer uv.uv_timer_t +---@field package _gen integer +---@field package _fired_gen integer +---@field package _args? table +---@field package _cb_main fun() +---@field package _cb_uv fun() local Debouncer = {} Debouncer.__index = Debouncer ----@param fn fun(id: any, ...) +---@param fn fun(...) ---@param delay integer ---@return ow.Util.Debouncer function Debouncer.new(fn, delay) - return setmetatable({ _fn = fn, _delay = delay, _slots = {} }, Debouncer) -end - ----@param slot ow.Util.Debouncer.Slot -local function dispose(slot) - if not slot.timer:is_closing() then - slot.timer:stop() - slot.timer:close() + local self = setmetatable({ + _fn = fn, + _delay = delay, + _timer = assert(vim.uv.new_timer()), + _gen = 0, + _fired_gen = 0, + _args = nil, + }, Debouncer) + self._cb_main = vim.schedule_wrap(function() + -- Identity check: the libuv fire may have been superseded by a + -- re-arm or a cancel between the timer firing and this scheduled + -- callback running. + if self._fired_gen ~= self._gen or self._args == nil then + return + end + local args = self._args + self._args = nil + self._fn(vim.F.unpack_len(args)) + end) + self._cb_uv = function() + self._fired_gen = self._gen + self._cb_main() end + return self end ----@param id? any ----@param ... any -function Debouncer:call(id, ...) - local key = id == nil and NIL_KEY or id +function Debouncer:__call(...) + self._args = vim.F.pack_len(...) + self._gen = self._gen + 1 + self._timer:start(self._delay, 0, self._cb_uv) +end + +function Debouncer:cancel() + self._timer:stop() + self._args = nil +end + +function Debouncer:flush() + if self._args == nil then + return + end + self._timer:stop() + local args = self._args + self._args = nil + self._fn(vim.F.unpack_len(args)) +end + +---@return boolean +function Debouncer:pending() + return self._args ~= nil +end + +function Debouncer:close() + self._timer:stop() + if not self._timer:is_closing() then + self._timer:close() + end + self._args = nil +end + +---@generic F: fun(...) +---@param fn F +---@param delay integer +---@return F | ow.Util.Debouncer +function M.debounce(fn, delay) + return Debouncer.new(fn, delay) +end + +---@class ow.Util.KeyedDebouncer +---@field package _fn fun(key: T, ...) +---@field package _delay integer +---@field package _slots table +local KeyedDebouncer = {} +KeyedDebouncer.__index = KeyedDebouncer + +---@generic T +---@param fn fun(key: T, ...) +---@param delay integer +---@return ow.Util.KeyedDebouncer +function KeyedDebouncer.new(fn, delay) + return setmetatable({ + _fn = fn, + _delay = delay, + _slots = {}, + }, KeyedDebouncer) +end + +---@generic T +---@param self ow.Util.KeyedDebouncer +---@param key T +function KeyedDebouncer:__call(key, ...) local slot = self._slots[key] - local args = vim.F.pack_len(...) if not slot then - -- cb lives on the slot so restart (the else branch below) can hand - -- the same closure back to timer:start, avoiding a fresh - -- schedule_wrap allocation per call. It reads id/args from the - -- slot (not upvalues) so restarts pick up the latest args. - local timer = assert(vim.uv.new_timer()) - ---@type ow.Util.Debouncer.Slot - local new_slot - new_slot = { - timer = timer, - id = id, - args = args, - cb = vim.schedule_wrap(function() - -- Slot may have been cancelled between libuv firing and - -- this scheduled callback running. Identity-check before - -- firing. - if self._slots[key] ~= new_slot then - return - end - self._slots[key] = nil - dispose(new_slot) - self._fn(new_slot.id, vim.F.unpack_len(new_slot.args)) - end), - } - self._slots[key] = new_slot - slot = new_slot - else - slot.id = id - slot.args = args + slot = Debouncer.new(function(...) + self._fn(key, ...) + end, self._delay) + self._slots[key] = slot end - -- uv_timer_start on an already-active timer restarts it with the new - -- timeout, reusing the same handle (no per-call allocation). - assert(slot.timer:start(self._delay, 0, slot.cb)) + slot(...) end ----@param id? any -function Debouncer:cancel(id) - local key = id == nil and NIL_KEY or id +---@generic T +---@param self ow.Util.KeyedDebouncer +---@param key T +function KeyedDebouncer:cancel(key) local slot = self._slots[key] if slot then + slot:close() self._slots[key] = nil - dispose(slot) end end -function Debouncer:cancel_all() +---@generic T +---@param self ow.Util.KeyedDebouncer +---@param key T +function KeyedDebouncer:flush(key) + local slot = self._slots[key] + if slot then + slot:flush() + end +end + +---@generic T +---@param self ow.Util.KeyedDebouncer +---@param key T +---@return boolean +function KeyedDebouncer:pending(key) + local slot = self._slots[key] + return slot ~= nil and slot:pending() +end + +function KeyedDebouncer:close() for _, slot in pairs(self._slots) do - dispose(slot) + slot:close() end self._slots = {} end -Util.Debouncer = Debouncer - ---- Creates a debounced function that delays execution of `fn` until after `delay` milliseconds have ---- elapsed since the last time it was invoked. Use `d:call(id, ...)` to debounce per-id, or ---- `d:call(...)` for a single shared slot. ----@param fn fun(id: any, ...) Function to be debounced ----@param delay integer Debounce delay in milliseconds ----@return ow.Util.Debouncer -function Util.debounce(fn, delay) - return Debouncer.new(fn, delay) +---@diagnostic disable-next-line: undefined-doc-name +---@generic T, F: fun(key: T, ...) +---@param fn F +---@param delay integer +---@return F | ow.Util.KeyedDebouncer +function M.keyed_debounce(fn, delay) + return KeyedDebouncer.new(fn, delay) end -function Util.get_hl_source(name) +function M.get_hl_source(name) local hl = vim.api.nvim_get_hl(0, { name = name }) while hl.link do hl = vim.api.nvim_get_hl(0, { name = hl.link }) @@ -402,4 +465,4 @@ function Util.get_hl_source(name) return hl end -return Util +return M