Skip to content

Commit 4ce85af

Browse files
committed
refactor(shell): replace function registry with LRU cache
1 parent 5c37803 commit 4ce85af

25 files changed

Lines changed: 221 additions & 73 deletions

.luarc.jsonc

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
"runtime.version": "LuaJIT",
44
"diagnostics": {
55
"enable": true,
6-
"globals": ["vim"],
6+
"globals": ["vim", "describe", "it" ],
77
"neededFileStatus": {
88
"codestyle-check": "Any"
99
},

lua/fzf-lua/core.lua

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -155,8 +155,7 @@ M.fzf_exec = function(contents, opts)
155155
-- the API accepts both tables and functions which we "stringify"
156156
-- We also send string commands as stringify is also responsible
157157
-- for multiprocess wrapping of shell commands with processing
158-
shell.clear_protected()
159-
contents = contents and shell.stringify(contents, opts, nil, true) or nil
158+
contents = contents and shell.stringify(contents, opts, nil) or nil
160159
assert(contents == nil or type(contents) == "string", "contents must be of type string")
161160
return M.fzf_wrap(contents, opts, true)
162161
end
@@ -175,7 +174,6 @@ M.fzf_live = function(contents, opts)
175174
-- utilizes fzf's 'change:reload' event or skim's "interactive" mode
176175
-- convert "reload" actions to fzf's `reload` binds
177176
-- convert "exec_silent" actions to fzf's `execute-silent` binds
178-
shell.clear_protected()
179177
if type(contents) == "string" then
180178
-- Signal to stringify_mt we are relocating <query>
181179
-- Signal to preprocess we are looking to replace {argvz}
@@ -185,7 +183,7 @@ M.fzf_live = function(contents, opts)
185183
contents = ("%s %s"):format(contents, M.fzf_query_placeholder)
186184
end
187185
end
188-
opts.fn_reload = shell.stringify(contents, opts, nil, true)
186+
opts.fn_reload = shell.stringify(contents, opts, nil)
189187
local fzf_field_index = M.fzf_field_index(opts)
190188
local cmd = M.expand_query(opts.fn_reload, fzf_field_index)
191189
contents, opts = M.setup_fzf_interactive_flags(cmd, fzf_field_index, opts)
@@ -1014,7 +1012,7 @@ local patch_shell_action = function(v, opts)
10141012
items = (zero_matched and zero_selected) and {} or items
10151013
end
10161014
v.fn(items, opts)
1017-
end, opts, field_index, true)
1015+
end, opts, field_index)
10181016
end
10191017

10201018
-- converts actions defined with "reload=true" to use fzf's `reload` bind

lua/fzf-lua/previewer/builtin.lua

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -454,7 +454,7 @@ function Previewer.base:cmdline(_)
454454
-- save last entry even if we don't display
455455
self.last_entry = entry
456456
return ""
457-
end, self.opts, "{} {q} {n}", false)
457+
end, self.opts, "{} {q} {n}")
458458
return act
459459
end
460460

@@ -481,7 +481,7 @@ function Previewer.base:zero(_)
481481
self.last_entry = nil
482482
vim.fn.delete(self._zero_lock, "d")
483483
end, self.delay)
484-
end, self.opts, "", false))
484+
end, self.opts, ""))
485485
return act
486486
end
487487

lua/fzf-lua/previewer/fzf.lua

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,7 @@ function Previewer.cmd:action(o)
8787
local act = shell.stringify_data(function(items, _, _)
8888
local entry = path.entry_to_file(items[1], self.opts)
8989
return entry.bufname or entry.path
90-
end, self.opts, self.opts.field_index_expr or "{}", false)
90+
end, self.opts, self.opts.field_index_expr or "{}")
9191
return act
9292
end
9393

lua/fzf-lua/providers/colorschemes.lua

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -457,10 +457,9 @@ M.awesome_colorschemes = function(opts)
457457
end)()
458458
end
459459

460-
local prev_act_id
461460
if opts.live_preview then
462461
opts.fzf_opts["--preview-window"] = "nohidden:right:0"
463-
opts.preview, prev_act_id = shell.stringify_data(function(sel)
462+
opts.preview = shell.stringify_data(function(sel)
464463
if opts.live_preview and sel then
465464
local dbkey, idx = sel[1]:match("^(.-):(%d+):")
466465
if opts._adm:downloaded(dbkey) then
@@ -479,10 +478,6 @@ M.awesome_colorschemes = function(opts)
479478
end, opts, "{}")
480479
end
481480

482-
opts._fn_pre_fzf = function()
483-
if prev_act_id then shell.set_protected(prev_act_id) end
484-
end
485-
486481
opts.winopts = opts.winopts or {}
487482
opts.winopts.on_close = function()
488483
-- reset color scheme if live_preview is enabled

lua/fzf-lua/shell.lua

Lines changed: 92 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -5,43 +5,103 @@ local libuv = require "fzf-lua.libuv"
55
local base64 = require "fzf-lua.lib.base64"
66
local serpent = require "fzf-lua.lib.serpent"
77

8-
local M = {}
8+
---@class fzf-lua.lru
9+
---@field max_size integer
10+
---@field max_id integer
11+
---@field last_id integer
12+
---@field mru integer[]
13+
---@field store any[]
14+
---@field lookup table<string, integer>
15+
local LRU = {}
16+
17+
function LRU:new(size)
18+
local obj = {
19+
max_size = size,
20+
-- Arbitrary, should be big enough to assert
21+
-- when accessing an evicted item
22+
max_id = size * 20,
23+
last_id = 0,
24+
mru = {},
25+
store = {},
26+
lookup = {},
27+
}
28+
setmetatable(obj, self)
29+
self.__index = self
30+
return obj
31+
end
932

10-
-- Circular buffer used to store registered function IDs
11-
-- set max length to 10, ATM most actions used by a single
12-
-- provider are 2 (`live_grep` with `multiprocess=false`)
13-
-- and 4 (`git_status` with preview and 3 reload binds)
14-
-- we can always increase if we need more
15-
local _MAX_LEN = 50
16-
local _index = 0
17-
local _registry = {}
18-
local _protected = {}
33+
---@param value any
34+
---@return integer, integer new element id and evicted (if evicted)
35+
function LRU:set(value)
36+
local store_idx, evicted_id
37+
local id = self.last_id + 1
38+
if id > self.max_id then id = 1 end
39+
if #self.store >= self.max_size then
40+
-- Remove the last element in the MRU table
41+
evicted_id = tostring(table.remove(self.mru))
42+
store_idx = self.lookup[evicted_id]
43+
self.lookup[evicted_id] = nil
44+
else
45+
store_idx = #self.store + 1
46+
end
47+
-- New id is always at the top of the MRU
48+
table.insert(self.mru, 1, id)
49+
self.store[store_idx] = value
50+
self.lookup[tostring(id)] = store_idx
51+
self.last_id = id
52+
return id, tonumber(evicted_id)
53+
end
1954

20-
function M.register_func(fn)
21-
repeat
22-
_index = _index % _MAX_LEN + 1
23-
until not _protected[_index]
24-
_registry[_index] = fn
25-
return _index
55+
---@return integer
56+
function LRU:len()
57+
return #self.store
2658
end
2759

28-
function M.get_func(id)
29-
return _registry[id]
60+
---@param id integer
61+
---@return any
62+
function LRU:get(id)
63+
local store_idx = self.lookup[tostring(id)]
64+
assert(store_idx, string.format("get nonexistent id %d", id))
65+
self.mru = (function()
66+
-- Remove all previous occurances of the id in the MRU
67+
-- and insert the current id at the top
68+
-- TODO: more efficient way?
69+
local ret = {}
70+
table.insert(ret, id)
71+
for _, x in ipairs(self.mru) do
72+
if x ~= id then
73+
table.insert(ret, x)
74+
end
75+
end
76+
return ret
77+
end)()
78+
return self.store[store_idx]
3079
end
3180

32-
function M.set_protected(id)
33-
_protected[id] = true
34-
assert(_MAX_LEN > utils.tbl_count(_protected))
81+
-- Cache should be able to hold all function callbacks of a single picker
82+
-- max cache size of 50 should be more than enough, we don't want it to be
83+
-- too big as this will prevent clearing of referecnces to "opts" which
84+
-- prevents garabage collection from freeing the resources
85+
local function new_cache() return LRU:new(50) end
86+
local _cache = new_cache()
87+
88+
local M = {}
89+
90+
-- Export LRU for testing
91+
M.LRU = LRU
92+
93+
function M.register_func(fn)
94+
return (_cache:set(fn))
3595
end
3696

37-
function M.clear_protected()
38-
_protected = {}
97+
function M.get_func(id)
98+
return _cache:get(id)
3999
end
40100

41-
function M.clear_registry()
42-
_index = 0
43-
_registry = {}
44-
_protected = {}
101+
-- NOTE: CI ONLY - DO NOT USE
102+
-- new cache is equal to cleared cache
103+
function M.clear_cache()
104+
_cache = new_cache()
45105
end
46106

47107
-- creates a new address to listen to messages from actions. This is important
@@ -246,7 +306,7 @@ end
246306
---Fzf field index expression, e.g. "{+}" (selected), "{q}" (query)
247307
---@param fzf_field_index string?
248308
---@return string, integer?
249-
M.stringify = function(contents, opts, fzf_field_index, protect)
309+
M.stringify = function(contents, opts, fzf_field_index)
250310
assert(contents, "must supply contents")
251311

252312
-- TODO: should we let this assert?
@@ -422,27 +482,19 @@ M.stringify = function(contents, opts, fzf_field_index, protect)
422482
end
423483
end, fzf_field_index or "", opts.debug)
424484

425-
-- "protect" a function ptr, cleared when opening a new picker in `core.fzf()`
426-
-- protected functions include init (content) commands and acrions (reload
427-
-- and execute-silent), previewer trigger commands are excluded (zero event
428-
-- and preview callback)
429-
if protect then
430-
M.set_protected(id)
431-
end
432-
433485
return cmd, id
434486
end
435487

436-
M.stringify_cmd = function(fn, opts, fzf_field_index, protect)
488+
M.stringify_cmd = function(fn, opts, fzf_field_index)
437489
assert(type(fn) == "function", "fn must be of type function")
438490
return M.stringify(fn, {
439491
__stringify_cmd = true,
440492
PidObject = utils.pid_object("__stringify_cmd_pid", opts),
441493
debug = opts.debug,
442-
}, fzf_field_index, protect)
494+
}, fzf_field_index)
443495
end
444496

445-
M.stringify_data = function(fn, opts, fzf_field_index, protect)
497+
M.stringify_data = function(fn, opts, fzf_field_index)
446498
assert(type(fn) == "function", "fn must be of type function")
447499
return M.stringify(function(cb, _, ...)
448500
local ret = fn(...)
@@ -454,7 +506,7 @@ M.stringify_data = function(fn, opts, fzf_field_index, protect)
454506
cb(tostring(ret))
455507
end
456508
cb(nil)
457-
end, { debug = opts.debug }, fzf_field_index, protect)
509+
end, { debug = opts.debug }, fzf_field_index)
458510
end
459511

460512
return M

tests/cache_spec.lua

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
local helpers = require("fzf-lua.test.helpers")
2+
local assert = helpers.assert
3+
4+
local fzf = require("fzf-lua")
5+
local LRU = fzf.shell.LRU
6+
7+
describe("Testing cache module", function()
8+
local cache
9+
local cached_fun = function(id) return function() return "CACHED" .. tostring(id) end end
10+
11+
it("new", function()
12+
local size = 50
13+
cache = LRU:new(size)
14+
assert.is.same(cache.max_size, size)
15+
end)
16+
17+
it("first", function()
18+
local id = cache:set(cached_fun(1))
19+
assert.is.same(id, 1)
20+
assert.is.same(cache:get(1)(), "CACHED1")
21+
end)
22+
23+
it("half store bubble", function()
24+
for _ = 1, 12 do cache:set(function() end) end
25+
cache:set(cached_fun(14))
26+
for _ = 1, 11 do cache:set(function() end) end
27+
-- After inserting 25 elements the last element should be at the top
28+
-- so basically the MRU is sorted in reverse order
29+
assert.is.same(cache:len(), 25)
30+
for i = 1, 25 do
31+
assert.is.same(cache.mru[i], 25 - i + 1)
32+
end
33+
-- In reveerse order func14 is at [12] and fun1 is at [25]
34+
assert.is.same(cache:len(), #cache.mru)
35+
assert.is.same(cache.mru[12], 14)
36+
assert.is.same(cache.mru[25], 1)
37+
assert.is.same(cache:get(14)(), "CACHED14")
38+
assert.is.same(cache:len(), #cache.mru)
39+
-- After bubbling func14 should be moved to [1]
40+
-- and rest of the elements should be shifted
41+
assert.is.same(cache.mru[1], 14)
42+
assert.is.same(cache.mru[11], 16)
43+
assert.is.same(cache.mru[12], 15)
44+
assert.is.same(cache.mru[13], 13)
45+
assert.is.same(cache.mru[25], 1)
46+
end)
47+
48+
it("full store", function()
49+
-- Fill in the remaining 25 items
50+
for _ = 1, 25 do cache:set(function() end) end
51+
assert.is.same(cache:len(), 50)
52+
-- New elements should take the first 25 slots
53+
-- No element should be evicted at this point
54+
for i = 1, 25 do
55+
local _, evicted_id = assert.is.same(cache.mru[i], 50 - i + 1)
56+
assert.is.same(evicted_id, nil)
57+
end
58+
-- Previous elements are shifted by 25
59+
assert.is.same(cache.mru[1 + 25], 14)
60+
assert.is.same(cache.mru[11 + 25], 16)
61+
assert.is.same(cache.mru[12 + 25], 15)
62+
assert.is.same(cache.mru[13 + 25], 13)
63+
assert.is.same(cache.mru[25 + 25], 1)
64+
end)
65+
66+
it("full store bubble", function()
67+
-- Calling `:get()` bubbles the item in the MRU
68+
assert.is.same(cache:get(14)(), "CACHED14")
69+
assert.is.same(cache:len(), #cache.mru)
70+
assert.is.same(cache.mru[1], 14)
71+
assert.is.same(cache.mru[50], 1)
72+
-- After bubbling func1, func14 shifts downward
73+
assert.is.same(cache:get(1)(), "CACHED1")
74+
assert.is.same(cache:len(), #cache.mru)
75+
assert.is.same(cache.mru[1], 1)
76+
assert.is.same(cache.mru[2], 14)
77+
assert.is.same(cache.mru[50], 2)
78+
end)
79+
80+
it("eviction", function()
81+
-- Store a new function, should have an incremental id
82+
-- func2 is evicted as it's at the bottom of the MRU
83+
local id, evicted_id = cache:set(cached_fun(51))
84+
assert.is.same(cache:len(), #cache.mru)
85+
assert.is.same(id, 51)
86+
assert.is.same(evicted_id, 2)
87+
-- func51 gets the top spot at the MRU
88+
assert.is.same(cache.mru[1], 51)
89+
assert.is.same(cache.mru[2], 1)
90+
assert.is.same(cache.mru[3], 14)
91+
assert.is.same(cache.mru[50], 3)
92+
end)
93+
94+
it("yet another bubble", function()
95+
assert.is.same(cache:get(14)(), "CACHED14")
96+
assert.is.same(cache:len(), #cache.mru)
97+
assert.is.same(cache.mru[1], 14)
98+
assert.is.same(cache.mru[2], 51)
99+
assert.is.same(cache.mru[3], 1)
100+
assert.is.same(cache.mru[50], 3)
101+
end)
102+
103+
end)

tests/screenshots/tests-api_spec.lua---api---fzf_exec---rg---1-+-args-{-1-}

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
01|
33
02|~
44
03|~ ╭─────────────────────────────────────────────────╮
5-
04|~ │> 120/120
5+
04|~ │> 121/121
66
05|~ │──────────────────────────────────────────────── │
77
06|~ │▌ LICENSE ││
88
07|~ │ Makefile ││

tests/screenshots/tests-api_spec.lua---api---fzf_exec---rg---1-+-args-{-false-}

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
01|
33
02|~
44
03|~ ╭─────────────────────────────────────────────────╮
5-
04|~ │> 121/121
5+
04|~ │> 122/122
66
05|~ │──────────────────────────────────────────────── │
77
06|~ │▌ [DEBUG] [st] rg --files -g "!.git" --sort=path││
88
07|~ │ LICENSE ││

0 commit comments

Comments
 (0)