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 ---@generic T
55 ---@param t T[]
56 ---@param cmp fun(T): boolean
57 ---@return integer?
58 local function findi(t, cmp)
59 for i = 1, #t do
60 if t[i] and cmp(t[i]) then
61 return i
62 end
63 end
64 end
65
66 return function(lst, input)
67 local it = 0
68 return vim.iter(sorters):fold({}, function(oldmatches, sorter)
69 it = it + 1
70 ---@type artio.Picker.match[]
71 local newmatches = sorter(lst, input)
72
73 return vim.iter(newmatches):fold(strat == "intersect" and {} or oldmatches, function(matches, newmatch)
74 local oldmatchidx = findi(oldmatches, function(v)
75 return v[1] == newmatch[1]
76 end)
77
78 if oldmatchidx then
79 local oldmatch = oldmatches[oldmatchidx]
80 local next = mergematches(oldmatch, newmatch)
81 if strat == "intersect" then
82 matches[#matches + 1] = next
83 else
84 matches[oldmatchidx] = next
85 end
86 elseif strat == "combine" or it == 1 then
87 matches[#matches + 1] = newmatch
88 end
89 return matches
90 end)
91 end)
92 end
93end
94
95---@type artio.Picker.sorter
96artio.fuzzy_sorter = function(lst, input)
97 if not lst or #lst == 0 then
98 return {}
99 end
100
101 if not input or #input == 0 then
102 return vim.tbl_map(function(v)
103 return { v.id, {}, 0 }
104 end, lst)
105 end
106
107 local matches = vim.fn.matchfuzzypos(lst, input, { key = "text" })
108
109 local items = {}
110 for i = 1, #matches[1] do
111 items[#items + 1] = { matches[1][i].id, matches[2][i], matches[3][i] }
112 end
113 return items
114end
115
116---@type artio.Picker.sorter
117artio.pattern_sorter = function(lst, input)
118 local match = string.match(input, "^/[^/]*/")
119 local pattern = match and string.match(match, "^/([^/]*)/$")
120
121 return vim
122 .iter(lst)
123 :map(function(v)
124 if pattern and not string.match(v.text, pattern) then
125 return
126 end
127
128 return { v.id, {}, 0 }
129 end)
130 :totable()
131end
132
133---@type artio.Picker.sorter
134artio.sorter = artio.mergesorters("intersect", artio.pattern_sorter, function(lst, input)
135 input = string.gsub(input, "^/[^/]*/", "")
136 return artio.fuzzy_sorter(lst, input)
137end)
138
139---@generic T
140---@param items T[] Arbitrary items
141---@param opts vim.ui.select.Opts Additional options
142---@param on_choice fun(item: T|nil, idx: integer|nil)
143---@param start_opts? artio.Picker.config
144artio.select = function(items, opts, on_choice, start_opts)
145 return artio.generic(
146 items,
147 vim.tbl_deep_extend("force", {
148 prompt = opts.prompt,
149 on_close = function(_, idx)
150 return on_choice(items[idx], idx)
151 end,
152 format_item = opts.format_item and function(item)
153 return opts.format_item(item)
154 end or nil,
155 }, start_opts or {})
156 )
157end
158
159---@generic T
160---@param items T[]
161---@param props artio.Picker.config
162artio.generic = function(items, props)
163 return artio.pick(vim.tbl_deep_extend("force", {
164 fn = artio.sorter,
165 items = items,
166 }, props))
167end
168
169---@param ... artio.Picker.config
170artio.pick = function(...)
171 local Picker = require("artio.picker")
172 return Picker:new(...):open()
173end
174
175---@param fn artio.Picker.action
176---@param scheduled_fn? artio.Picker.action
177artio.wrap = function(fn, scheduled_fn)
178 return function()
179 local Picker = require("artio.picker")
180 local current = Picker.active_picker
181 if not current or current.closed then
182 return
183 end
184
185 -- whether to accept key inputs
186 if coroutine.status(current.co) ~= "suspended" then
187 return
188 end
189
190 pcall(fn, current)
191
192 if scheduled_fn == nil then
193 return
194 end
195 vim.schedule(function()
196 pcall(scheduled_fn, current)
197 end)
198 end
199end
200
201return artio