Skip to content

Commit 3d1762b

Browse files
committed
perf(picker): picker streaming is cumulative on static query
1 parent 7dec77b commit 3d1762b

5 files changed

Lines changed: 206 additions & 48 deletions

File tree

lua/fuzzy/match.lua

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,8 @@ end
116116

117117
function Match:_clean_context()
118118
if self._state.buffer then
119-
-- clear the buffer to avoid holding references to old data, do return them to the pool, from where we can easily pull them back
119+
-- clear the buffer to avoid holding references to old data, do return them to the pool, from where we can easily pull
120+
-- them back
120121
for i, value in ipairs(self._state.buffer) do
121122
if i == 1 then
122123
utils.fill_table(
@@ -250,7 +251,11 @@ end
250251
--- Returns true if the matcher is considered finalized and no longer processing data in-flight, and there is a valid set of data present for consumption produced by the matcher
251252
--- @return boolean True if the matcher has finalized and matched results data is ready to be consumed by the client
252253
function Match:isvalid()
253-
return not self:running() and self.results ~= nil and #self.results > 0 and self.results[1] ~= nil and #self.results[1] >= 0
254+
return not self:running()
255+
and self.results ~= nil
256+
and #self.results > 0
257+
and self.results[1] ~= nil
258+
and #self.results[1] >= 0
254259
end
255260

256261
--- Checks if the match has finalized and has matched any entries that are ready for consumption
@@ -323,6 +328,8 @@ function Match:match(list, pattern, callback, transform)
323328
end
324329
end
325330

331+
-- ensure we drop the old results first, these will be re-referenced only when the matching process finishes after this matching process
332+
-- that is currently being started now
326333
if self.results then
327334
self.results = nil
328335
end
@@ -362,14 +369,14 @@ function Match:match(list, pattern, callback, transform)
362369

363370
if not self._state.chunks then
364371
-- chunks are reused to avoid frequent allocations, they represent the part of the whole source list currently being processed
365-
-- for matches
372+
-- for matches, the chunks are first filled from the source list and then used in the matchfuzzy call
366373
local size = self._options.step
367374
self._state.chunks = Pool.obtain(size)
368375
end
369376

370377
if not self._state.buffer then
371-
-- prepare buffer for storing intermediate results, start from step-sized buffers,
372-
-- and allow Match.merge to grow as needed.
378+
-- prepare buffer for storing intermediate results, start from step-sized buffers, the finaly buffer will likely contain much less
379+
-- items than the initial list.
373380
local size = math.min(#list, self._options.step or 1)
374381
self._state.buffer = {
375382
Pool.obtain(size),

lua/fuzzy/picker.lua

Lines changed: 184 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
1+
local Registry = require("fuzzy.registry")
12
local Stream = require("fuzzy.stream")
23
local Select = require("fuzzy.select")
34
local Match = require("fuzzy.match")
4-
local Registry = require("fuzzy.registry")
5+
local Pool = require("fuzzy.pool")
56

67
local utils = require("fuzzy.utils")
78
local path = require("fuzzy.path")
@@ -182,12 +183,14 @@ function Picker:_close_picker()
182183
self.select:close()
183184
self.match:destroy()
184185
self.stream:destroy()
186+
self:_running_match()
185187
self:_caching_display()
186188
end
187189

188190
function Picker:_cancel_picker()
189191
self.select:close()
190192
self.match:destroy()
193+
self:_running_match()
191194
if self.stream:running() then
192195
self.stream:destroy()
193196
self:_caching_display()
@@ -199,6 +202,7 @@ end
199202
function Picker:_clear_picker()
200203
self.select:clear()
201204
self.match:destroy()
205+
self:_running_match()
202206
if self.stream:running() then
203207
self.stream:destroy()
204208
self:_caching_display()
@@ -360,10 +364,12 @@ function Picker:_input_prompt()
360364
if not self.stream:isvalid() then
361365
return
362366
end
367+
-- the picker is ensured to be valid
363368
data = assert(self.stream.results)
364369
elseif self:_is_static() then
365370
data = assert(self._state.content)
366371
else
372+
-- at this point what is that here !
367373
assert(false, "unknown picker state")
368374
end
369375

@@ -401,6 +407,7 @@ function Picker:_input_prompt()
401407
"%d/%d", #data, #data
402408
))
403409
end
410+
self:_running_match()
404411
end
405412
end)
406413
end
@@ -566,6 +573,110 @@ function Picker:_compute_headers(headers, actions)
566573
return vim.list_extend(headers, action_headers)
567574
end
568575

576+
function Picker:_running_match(matching, query)
577+
-- Maintain a running accumulator of match results while a stream is active; it is a
578+
-- {matches, positions, scores} triplet that grows per chunk, resets on query change,
579+
-- and is released when matching is nil.
580+
local state = assert(self._state)
581+
if matching == nil and state.matching then
582+
local match_state = state.matching
583+
local function release_table(tbl)
584+
for i, value in ipairs(tbl) do
585+
if not Pool.is_pooled(value) then
586+
utils.fill_table(
587+
value,
588+
i == 1 and utils.EMPTY_STRING
589+
or (i == 2 and utils.EMPTY_TABLE or 0)
590+
)
591+
goto continue
592+
end
593+
if i == 1 then
594+
utils.fill_table(
595+
value,
596+
utils.EMPTY_STRING
597+
)
598+
elseif i == 2 then
599+
utils.fill_table(
600+
value,
601+
utils.EMPTY_TABLE
602+
)
603+
else
604+
utils.fill_table(
605+
value, 0
606+
)
607+
end
608+
Pool._return(value)
609+
::continue::
610+
end
611+
end
612+
613+
if match_state.buffer then
614+
release_table(match_state.buffer)
615+
match_state.buffer = nil
616+
end
617+
if match_state.accum then
618+
release_table(match_state.accum)
619+
match_state.accum = nil
620+
end
621+
match_state.query = nil
622+
state.matching = nil
623+
return matching
624+
elseif matching ~= nil then
625+
if not state.matching then
626+
state.matching = {
627+
accum = nil,
628+
buffer = nil,
629+
query = query,
630+
debounced = nil,
631+
}
632+
end
633+
if state.matching.query ~= query then
634+
self:_running_match()
635+
state.matching = {}
636+
end
637+
local match_state = state.matching
638+
match_state.query = assert(query)
639+
640+
if not match_state.accum or #match_state.accum == 0 then
641+
local obtain_table = Pool.prime_min ~= nil
642+
and #matching[1] >= Pool.prime_min
643+
match_state.accum = {
644+
obtain_table and Pool.obtain(#matching[1]) or {},
645+
obtain_table and Pool.obtain(#matching[2]) or {},
646+
obtain_table and Pool.obtain(#matching[3]) or {},
647+
}
648+
for i = 1, 3, 1 do
649+
utils.resize_table(
650+
match_state.accum[i],
651+
#matching[i], nil
652+
)
653+
for j = 1, #matching[i], 1 do
654+
match_state.accum[i][j] = matching[i][j]
655+
end
656+
end
657+
else
658+
if not match_state.buffer then
659+
local obtain_table = Pool.prime_min ~= nil
660+
and #matching[1] >= Pool.prime_min
661+
match_state.buffer = {
662+
obtain_table and Pool.obtain(#matching[1]) or {},
663+
obtain_table and Pool.obtain(#matching[2]) or {},
664+
obtain_table and Pool.obtain(#matching[3]) or {},
665+
}
666+
end
667+
local result, _ = utils.timed_call(Match.merge,
668+
match_state.buffer, match_state.accum, matching
669+
)
670+
match_state.buffer = match_state.accum
671+
match_state.accum = assert(result)
672+
assert(#result[1] == #result[2])
673+
assert(#result[2] == #result[3])
674+
end
675+
return assert(match_state.accum)
676+
end
677+
return matching
678+
end
679+
569680
function Picker:_caching_display(init)
570681
local state = assert(self._state)
571682
if not init and state.caching then
@@ -625,9 +736,10 @@ function Picker:_ticking_counter(init)
625736
end
626737

627738
function Picker:_flush_interactive()
628-
-- Interactive stream flush: stream is the source of truth (no fuzzy match), render accumulated results and status, debounce to coalesce very fast flushes.
629-
return utils.debounce_callback(self._options.stream_debounce, function(_, all)
630-
if all == nil then
739+
-- Interactive stream flush: stream is the source of truth (no fuzzy match), render accumulated results and status, debounce to coalesce
740+
-- very fast flushes.
741+
return utils.debounce_callback(self._options.stream_debounce, function(buf, all)
742+
if buf == nil and all == nil then
631743
-- Stream finished: if it produced nothing, explicitly render an empty list, then signal completion.
632744
if not self.stream:isvalid() or self.stream:isempty() then
633745
self.select:list(
@@ -636,7 +748,8 @@ function Picker:_flush_interactive()
636748
)
637749
self.select:status("0/0")
638750
end
639-
-- Final nil list marks end of streaming: Select stops incremental updates and treats the list as stable until new data arrives (or re-open).
751+
-- Final nil list marks end of streaming: Select stops incremental updates and treats the list as stable until new data arrives
752+
-- (or re-open).
640753
self.select:list(nil, nil)
641754
else
642755
-- Streaming in progress: show all accumulated results so far.
@@ -649,9 +762,10 @@ function Picker:_flush_interactive()
649762
end
650763

651764
function Picker:_flush_direct()
652-
-- Direct stream flush (non-interactive): match per chunk and merge into accumulator when query is present, render all results when empty, debounce to coalesce rapid flushes.
653-
return utils.debounce_callback(self._options.stream_debounce, function(_, all)
654-
if all == nil then
765+
-- Direct stream flush (non-interactive): match per chunk and merge into accumulator when query is present, render all results when
766+
-- empty, debounce to coalesce rapid flushes.
767+
return utils.debounce_callback(self._options.stream_debounce, function(buf, all)
768+
if buf == nil and all == nil then
655769
-- Stream finished: if it produced nothing, explicitly render an empty list, then signal to the list renderer for content
656770
-- delivery completion. there is also a case
657771
if not self.stream:isvalid() or self.stream:isempty() then
@@ -670,39 +784,78 @@ function Picker:_flush_direct()
670784
-- case more input arrives this call will be de-bounced by the future calls and effectively be a no-op which is okay
671785
local options = self.select:options()
672786
local query = self.select:query()
673-
options.prompt_input(query)
787+
788+
local match_state = self._state.matching
789+
local should_match = type(query) == "string" and #query > 0
790+
and (not match_state or match_state.query ~= query)
791+
792+
-- ensure that there is new something to match, in case the query has changed since the last time the running match was done, we
793+
-- can safely queue the new query for matching, and clea the running match.
794+
if should_match == true then
795+
options.prompt_input(query)
796+
self:_running_match(nil)
797+
end
674798
else
675799
-- Pull the current state query, sync we need to have an overview of the query value always, every time new accumulation of items
676800
-- arrives from the stream we have to re-roder them all and re-match them in accordance to the query
677801
local query = self.select:query()
678802

679-
-- If the query changed we have to do a match on all stream entries thus far, to reflect the matching state of these entries
680-
-- while the stream is still emitting
681803
if #all > 0 and type(query) == "string" and #query > 0 then
682-
self.match:match(all, query, function(matching)
683-
if matching == nil then
684-
if not self.match:isvalid() or self.match:isempty() then
685-
self.select:list(
686-
utils.EMPTY_TABLE,
687-
utils.EMPTY_TABLE
688-
)
689-
self.select:status("0/0")
804+
if not self._state.matching then
805+
self._state.matching = {}
806+
end
807+
local match_state = self._state.matching
808+
if match_state.debounced == nil then
809+
match_state.debounced = utils.debounce_callback(
810+
self._options.match_debounce,
811+
function(mode, list, match_query, total)
812+
local current = self.select:query()
813+
if current ~= match_query or current == "" then
814+
return
815+
end
816+
self.match:match(list, match_query, function(matching)
817+
if matching == nil then
818+
if not self.match:isvalid() or self.match:isempty() then
819+
local current_state = self._state.matching
820+
local accum = current_state and current_state.accum
821+
if not accum or #accum[1] == 0 then
822+
self.select:list(
823+
utils.EMPTY_TABLE,
824+
utils.EMPTY_TABLE
825+
)
826+
self.select:status("0/0")
827+
self.select:list(nil, nil)
828+
end
829+
end
830+
else
831+
if mode == "all" then self:_running_match() end
832+
matching = self:_running_match(matching, match_query)
833+
self.select:list(matching[1], matching[2])
834+
self.select:status(string.format(
835+
"%d/%d", #matching[1], total
836+
))
837+
end
838+
end, self._state.display)
690839
end
691-
self.select:list(nil, nil)
692-
else
693-
self.select:list(matching[1], matching[2])
694-
self.select:status(string.format(
695-
"%d/%d", #matching[1], #all
696-
))
697-
end
698-
end, self._state.display)
840+
)
841+
end
842+
843+
-- If the query changed we have to do a match on all stream entries thus far, to reflect the matching state of these entries
844+
-- while the stream is still emitting
845+
local query_changed = not match_state or match_state.query ~= query
846+
if query_changed and #all > 0 then
847+
match_state.debounced("all", all, query, #all)
848+
elseif buf and #buf > 0 then
849+
match_state.debounced("buf", buf, query, #all)
850+
end
699851
else
700852
-- No query, render all of the results as-is and reset any running accumulator, as it will have become
701853
-- invalid anyway with an empty query
702854
self.select:list(all, nil)
703855
self.select:status(string.format(
704856
"%d/%d", #all, #all
705857
))
858+
self:_running_match()
706859
end
707860
end
708861
end)
@@ -886,6 +1039,7 @@ end
8861039
--- @field headers? table[]|nil a list of headers to display in the picker, each header must be a list of tuples, where each tuple is a pair of a string and a highlight group name, the string is the text to display, and the highlight group name is the highlight group to use for displaying the text, for example: { {"<c-n>", "PickerHeaderActionKey"}, {"::", "PickerHeaderActionSeparator"}, {"next", "PickerHeaderActionLabel"} }.
8871040
--- @field match_limit? number|nil the maximum number of matches to keep, nil means no limit.
8881041
--- @field match_timer? number the time in milliseconds to wait before flushing the matching results, this is useful when dealing with large result sets.
1042+
--- @field match_debounce? number the time in milliseconds to debounce streaming match starts, this is useful to avoid re-matching the full list on every flush.
8891043
--- @field match_step? number the number of entries to process in each matching step, this is useful when dealing with large result sets.
8901044
--- @field stream_type? "lines"|"bytes" whether the stream produces lines or bytes, when lines is used the stream will be split on newlines, when bytes is used the stream will be split on byte size.
8911045
--- @field stream_step? number the number of bytes or lines to read in each streaming step, this is useful when dealing with large result sets.
@@ -913,6 +1067,7 @@ function Picker.new(opts)
9131067
highlighters = { opts.highlighters, "table", true },
9141068
match_limit = { opts.match_limit, { "number", "nil" }, true },
9151069
match_timer = { opts.match_timer, "number", true },
1070+
match_debounce = { opts.match_debounce, { "number", "nil" }, true },
9161071
match_step = { opts.match_step, "number", true },
9171072
stream_step = { opts.stream_step, "number", true },
9181073
stream_type = { opts.stream_type, { "string", "nil" }, true, { "lines", "bytes" } },
@@ -940,6 +1095,7 @@ function Picker.new(opts)
9401095
highlighters = {},
9411096
match_limit = nil,
9421097
match_timer = 30,
1098+
match_debounce = 15,
9431099
match_step = 65536,
9441100
stream_step = 131072,
9451101
stream_type = "lines",
@@ -954,6 +1110,7 @@ function Picker.new(opts)
9541110
}, opts)
9551111

9561112
local is_lines = opts.stream_type == "lines"
1113+
assert(opts.stream_step >= opts.match_step)
9571114

9581115
local self = setmetatable({
9591116
match = nil,

lua/fuzzy/pickers/files.lua

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,7 @@ function M.open_files_picker(opts)
125125
decorators = decorators,
126126
highlighters = {
127127
Select.RegexHighlighter.new({
128-
{ "^.+/", "Directory" },
128+
{ "^.+/", "Directory" },
129129
{ "[^/]+$", "Identifier" },
130130
}),
131131
},

0 commit comments

Comments
 (0)