minimal extui fuzzy finder for neovim
1local function lzrq(modname)
2 return setmetatable({}, {
3 __index = function(_, key)
4 return require(modname)[key]
5 end,
6 })
7end
8
9local config = lzrq("artio.config")
10
11local artio = {}
12
13---@param cfg? artio.config
14artio.setup = function(cfg)
15 cfg = cfg or {}
16 config.set(cfg)
17end
18
19---@param a integer[]
20---@param ... integer[]
21---@return integer[]
22local function mergehl(a, ...)
23 local hl_lists = { a, ... }
24
25 local t = vim.iter(hl_lists):fold({}, function(hls, hl_list)
26 for i = 1, #hl_list do
27 hls[hl_list[i]] = true
28 end
29 return hls
30 end)
31 return vim.tbl_keys(t)
32end
33
34---@param a artio.Picker.match
35---@param b artio.Picker.match
36---@return artio.Picker.match
37local function mergematches(a, b)
38 return { a[1], mergehl(a[2], b[2]), a[3] + b[3] }
39end
40
41---@param strat 'combine'|'intersect'|'base'
42--- combine:
43--- a, b -> a + ab + b
44--- intersect:
45--- a, b -> ab
46--- base:
47--- a, b -> a + ab
48---@param a artio.Picker.sorter
49---@param ... artio.Picker.sorter
50---@return artio.Picker.sorter
51function artio.mergesorters(strat, a, ...)
52 local sorters = { a, ... } ---@type artio.Picker.sorter[]
53
54 return function(lst, input)
55 return vim.iter(ipairs(sorters)):fold({}, function(oldmatches, it, sorter)
56 ---@type artio.Picker.matches
57 local newmatches = sorter(lst, input)
58
59 return vim
60 .iter(pairs(newmatches))
61 :fold(strat == "intersect" and {} or oldmatches, function(matches, idx, newmatch)
62 local oldmatch = oldmatches[idx]
63 if oldmatch then
64 local next = mergematches(oldmatch, newmatch)
65 matches[idx] = next
66 elseif strat == "combine" or it == 1 then
67 matches[idx] = newmatch
68 end
69 return matches
70 end)
71 end)
72 end
73end
74
75---@type artio.Picker.sorter
76artio.fuzzy_sorter = function(lst, input)
77 if not lst or #lst == 0 then
78 return {}
79 end
80
81 if not input or #input == 0 then
82 return vim.iter(lst):fold({}, function(acc, v)
83 acc[v.id] = { v.id, {}, 0 }
84 return acc
85 end)
86 end
87
88 local matches = vim.fn.matchfuzzypos(lst, input, { key = "text" })
89
90 local items = {}
91 for i = 1, #matches[1] do
92 items[matches[1][i].id] = { matches[1][i].id, matches[2][i], matches[3][i] }
93 end
94 return items
95end
96
97---@type artio.Picker.sorter
98artio.pattern_sorter = function(lst, input)
99 local match = string.match(input, "^/[^/]*/")
100 local pattern = match and string.match(match, "^/([^/]*)/$")
101
102 return vim.iter(lst):fold({}, function(acc, v)
103 if pattern and not string.match(v.text, pattern) then
104 return acc
105 end
106
107 acc[v.id] = { v.id, {}, 0 }
108 return acc
109 end)
110end
111
112--- the default sorter provides support for pattern matching. a `/.../` match
113--- at the start of the input will limit the fuzzy sorter to items matching the
114--- pattern. if you want to use `/.../` in your fuzzy matches, make sure to
115--- escape it by starting the input with an empty space (` /.../`). fuzzy
116--- sorting will be done on the input with the pattern removed.
117---@type artio.Picker.sorter
118artio.sorter = artio.mergesorters("intersect", artio.pattern_sorter, function(lst, input)
119 input = string.gsub(input, "^/[^/]*/", "")
120 return artio.fuzzy_sorter(lst, input)
121end)
122
123---@generic T
124---@param items T[] Arbitrary items
125---@param opts vim.ui.select.Opts Additional options
126---@param on_choice fun(item: T|nil, idx: integer|nil)
127---@param start_opts? artio.Picker.config
128artio.select = function(items, opts, on_choice, start_opts)
129 return artio.generic(
130 items,
131 vim.tbl_deep_extend(
132 "force",
133 {
134 on_close = function(_, idx)
135 artio.schedule(function()
136 on_choice(items[idx], idx)
137 end)
138 end,
139 on_quit = function()
140 artio.schedule(function()
141 on_choice(nil, nil)
142 end)
143 end,
144 },
145 opts or {}, -- opts.prompt, opts.format_item
146 start_opts or {}
147 )
148 )
149end
150
151---@generic T
152---@param items T[]
153---@param props artio.Picker.config
154artio.generic = function(items, props)
155 return artio.pick(vim.tbl_deep_extend("force", {
156 fn = artio.sorter,
157 items = items,
158 }, props))
159end
160
161---@param ... artio.Picker.config
162artio.pick = function(...)
163 local Picker = require("artio.picker")
164 return Picker:new(...):open()
165end
166
167---@param f fun(): any
168artio.schedule = function(f)
169 local Picker = require("artio.picker")
170 local current = Picker.active_picker
171 if not current or not current.closed then
172 return
173 end
174
175 vim.schedule(function()
176 vim.defer_fn(f, 10)
177 vim.wait(1000, function()
178 return coroutine.status(current.co) == "dead"
179 end)
180 end)
181end
182
183artio.resume = function()
184 local picker = require("artio.picker").active_picker
185 if not picker or not picker.closed then
186 return
187 end
188 picker:resume()
189end
190
191---@param fn artio.Picker.action
192---@param scheduled_fn? artio.Picker.action
193artio.wrap = function(fn, scheduled_fn)
194 return function()
195 local Picker = require("artio.picker")
196 local current = Picker.active_picker
197 if not current or current.closed then
198 return
199 end
200
201 -- whether to accept key inputs
202 if coroutine.status(current.co) ~= "suspended" then
203 return
204 end
205
206 pcall(fn, current)
207
208 if scheduled_fn == nil then
209 return
210 end
211 artio.schedule(function()
212 pcall(scheduled_fn, current)
213 end)
214 end
215end
216
217return artio