fix(util): refactor debouncer

This commit is contained in:
2026-04-19 00:40:24 +02:00
parent a4af5ce66f
commit b4721bb444
5 changed files with 156 additions and 100 deletions
+2 -2
View File
@@ -101,7 +101,7 @@ function Repo:start_watcher()
if err or (filename ~= "index" and filename ~= "HEAD") then if err or (filename ~= "index" and filename ~= "HEAD") then
return return
end end
self.refresh:call() self.refresh()
end)) end))
self.watcher = watcher self.watcher = watcher
end end
@@ -241,7 +241,7 @@ local function refresh(buf)
vim.b[buf].git_status = nil vim.b[buf].git_status = nil
return return
end end
repo.refresh:call() repo.refresh()
end end
local M = {} local M = {}
+3 -6
View File
@@ -435,14 +435,11 @@ function Linter.add(bufnr, config)
return return
end end
local debouncer = util.debounce(function()
linter:run()
end, config.debounce)
vim.api.nvim_create_autocmd(config.events, { vim.api.nvim_create_autocmd(config.events, {
buffer = linter.bufnr, buffer = linter.bufnr,
callback = function() callback = util.debounce(function()
debouncer:call() linter:run()
end, end, config.debounce) --[[@as fun()]],
group = linter.augroup, group = linter.augroup,
}) })
+1 -1
View File
@@ -249,7 +249,7 @@ end
local session = Session.new() 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 if vim.fn.mode() ~= "i" then
return return
end end
+6 -10
View File
@@ -1,7 +1,7 @@
local log = require("log") local log = require("log")
local util = require("util") 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 ---@param path string
---@return boolean success ---@return boolean success
@@ -51,9 +51,7 @@ local function plugin_config_path(name)
return return
end end
local path = vim.fs.joinpath(config_dir, "plugins", normalized .. ".lua") return vim.fs.joinpath(plugins_dir, normalized .. ".lua")
return path
end end
---@param name string ---@param name string
@@ -152,7 +150,7 @@ end
---@type uv.uv_fs_event_t? ---@type uv.uv_fs_event_t?
local watcher = nil local watcher = nil
---@type ow.Util.Debouncer? ---@type (fun(filename: string) | ow.Util.KeyedDebouncer<string>)?
local reload = nil local reload = nil
---@class ow.Pack ---@class ow.Pack
@@ -185,10 +183,8 @@ function M.watch()
return return
end end
local plugins_dir = vim.fs.joinpath(config_dir, "plugins")
watcher = assert(vim.uv.new_fs_event()) 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) local path = vim.fs.joinpath(plugins_dir, filename)
if not vim.uv.fs_stat(path) then if not vim.uv.fs_stat(path) then
return return
@@ -209,7 +205,7 @@ function M.watch()
if not filename or not filename:match("%.lua$") then if not filename or not filename:match("%.lua$") then
return return
end end
reload:call(filename) reload(filename)
end)) end))
end end
@@ -219,7 +215,7 @@ function M.unwatch()
end end
if reload then if reload then
reload:cancel() reload:close()
reload = nil reload = nil
end end
+144 -81
View File
@@ -1,8 +1,8 @@
local log = require("log") 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" ---@alias OutputStream "stdout" | "stderr" | "in_place"
@@ -26,7 +26,7 @@ Util.os_name = vim.uv.os_uname().sysname
--- Format buffer --- Format buffer
---@param opts ow.FormatOptions ---@param opts ow.FormatOptions
function Util.format(opts) function M.format(opts)
opts = { opts = {
buf = opts.buf or vim.api.nvim_get_current_buf(), buf = opts.buf or vim.api.nvim_get_current_buf(),
cmd = opts.cmd, cmd = opts.cmd,
@@ -241,7 +241,7 @@ end
---@param kt type ---@param kt type
---@param vt type ---@param vt type
---@return boolean ---@return boolean
function Util.is_map(val, kt, vt) function M.is_map(val, kt, vt)
if type(val) ~= "table" then if type(val) ~= "table" then
return false return false
end end
@@ -263,7 +263,7 @@ end
---@param val any ---@param val any
---@param t? type ---@param t? type
---@return boolean ---@return boolean
function Util.is_list(val, t) function M.is_list(val, t)
if not vim.islist(val) then if not vim.islist(val) then
return false return false
end end
@@ -285,115 +285,178 @@ end
---@param val? any ---@param val? any
---@param t? type ---@param t? type
---@return boolean ---@return boolean
function Util.is_list_or_nil(val, t) function M.is_list_or_nil(val, t)
if val == nil then if val == nil then
return true return true
else else
return Util.is_list(val, t) return M.is_list(val, t)
end end
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 ---@class ow.Util.Debouncer
---@field private _fn fun(id: any, ...) ---@field package _fn fun(...)
---@field private _delay integer ---@field package _delay integer
---@field private _slots table<any, ow.Util.Debouncer.Slot> ---@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 = {} local Debouncer = {}
Debouncer.__index = Debouncer Debouncer.__index = Debouncer
---@param fn fun(id: any, ...) ---@param fn fun(...)
---@param delay integer ---@param delay integer
---@return ow.Util.Debouncer ---@return ow.Util.Debouncer
function Debouncer.new(fn, delay) function Debouncer.new(fn, delay)
return setmetatable({ _fn = fn, _delay = delay, _slots = {} }, Debouncer) local self = setmetatable({
end _fn = fn,
_delay = delay,
---@param slot ow.Util.Debouncer.Slot _timer = assert(vim.uv.new_timer()),
local function dispose(slot) _gen = 0,
if not slot.timer:is_closing() then _fired_gen = 0,
slot.timer:stop() _args = nil,
slot.timer:close() }, 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 end
return self
end end
---@param id? any function Debouncer:__call(...)
---@param ... any self._args = vim.F.pack_len(...)
function Debouncer:call(id, ...) self._gen = self._gen + 1
local key = id == nil and NIL_KEY or id 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<T>
---@field package _fn fun(key: T, ...)
---@field package _delay integer
---@field package _slots table<T, ow.Util.Debouncer>
local KeyedDebouncer = {}
KeyedDebouncer.__index = KeyedDebouncer
---@generic T
---@param fn fun(key: T, ...)
---@param delay integer
---@return ow.Util.KeyedDebouncer<T>
function KeyedDebouncer.new(fn, delay)
return setmetatable({
_fn = fn,
_delay = delay,
_slots = {},
}, KeyedDebouncer)
end
---@generic T
---@param self ow.Util.KeyedDebouncer<T>
---@param key T
function KeyedDebouncer:__call(key, ...)
local slot = self._slots[key] local slot = self._slots[key]
local args = vim.F.pack_len(...)
if not slot then if not slot then
-- cb lives on the slot so restart (the else branch below) can hand slot = Debouncer.new(function(...)
-- the same closure back to timer:start, avoiding a fresh self._fn(key, ...)
-- schedule_wrap allocation per call. It reads id/args from the end, self._delay)
-- slot (not upvalues) so restarts pick up the latest args. self._slots[key] = slot
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
end end
-- uv_timer_start on an already-active timer restarts it with the new slot(...)
-- timeout, reusing the same handle (no per-call allocation).
assert(slot.timer:start(self._delay, 0, slot.cb))
end end
---@param id? any ---@generic T
function Debouncer:cancel(id) ---@param self ow.Util.KeyedDebouncer<T>
local key = id == nil and NIL_KEY or id ---@param key T
function KeyedDebouncer:cancel(key)
local slot = self._slots[key] local slot = self._slots[key]
if slot then if slot then
slot:close()
self._slots[key] = nil self._slots[key] = nil
dispose(slot)
end end
end end
function Debouncer:cancel_all() ---@generic T
---@param self ow.Util.KeyedDebouncer<T>
---@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<T>
---@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 for _, slot in pairs(self._slots) do
dispose(slot) slot:close()
end end
self._slots = {} self._slots = {}
end end
Util.Debouncer = Debouncer ---@diagnostic disable-next-line: undefined-doc-name
---@generic T, F: fun(key: T, ...)
--- Creates a debounced function that delays execution of `fn` until after `delay` milliseconds have ---@param fn F
--- elapsed since the last time it was invoked. Use `d:call(id, ...)` to debounce per-id, or ---@param delay integer
--- `d:call(...)` for a single shared slot. ---@return F | ow.Util.KeyedDebouncer<T>
---@param fn fun(id: any, ...) Function to be debounced function M.keyed_debounce(fn, delay)
---@param delay integer Debounce delay in milliseconds return KeyedDebouncer.new(fn, delay)
---@return ow.Util.Debouncer
function Util.debounce(fn, delay)
return Debouncer.new(fn, delay)
end end
function Util.get_hl_source(name) function M.get_hl_source(name)
local hl = vim.api.nvim_get_hl(0, { name = name }) local hl = vim.api.nvim_get_hl(0, { name = name })
while hl.link do while hl.link do
hl = vim.api.nvim_get_hl(0, { name = hl.link }) hl = vim.api.nvim_get_hl(0, { name = hl.link })
@@ -402,4 +465,4 @@ function Util.get_hl_source(name)
return hl return hl
end end
return Util return M