Skip to content

Commit 6ed93bd

Browse files
committed
fix(api): add more robust api state detection for stream/match
1 parent 2ae9407 commit 6ed93bd

4 files changed

Lines changed: 100 additions & 51 deletions

File tree

lua/fuzzy/match.lua

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -247,6 +247,18 @@ function Match:_match_worker()
247247
end
248248
end
249249

250+
--- 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
251+
--- @return boolean True if the matched results data is ready to be consumed by the client
252+
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+
end
255+
256+
--- Checks if the match has matched any entries ready for consumption
257+
--- @return boolean True if the matcher has no entries matched, false otherwise.
258+
function Match:isempty()
259+
return self:isvalid() and #self.results[1] == 0
260+
end
261+
250262
--- Checks if there is an ongoing matching operation, i.e., if the timer is active, which indicates that matching is in progress.
251263
--- @return boolean True if a matching operation is currently running, false otherwise.
252264
function Match:running()

lua/fuzzy/picker.lua

Lines changed: 40 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -320,8 +320,7 @@ function Picker:_input_prompt()
320320
-- in interactive mode we need to restart the stream with the new query, so that the command can produce results
321321
-- based on the query, for example when using find -name <query>, we do not perform fuzzy matching on the stream
322322
-- in interactive mode, this is done on demand in the second stage
323-
local content = self._state.content
324-
assert(type(content) ~= "table")
323+
assert(self:_is_dynamic())
325324

326325
if type(query) == "string" and #query > 0 then
327326
-- when interactive string it means that the string is an argument placeholder, that should be replaced
@@ -355,15 +354,26 @@ function Picker:_input_prompt()
355354
-- the interactive stage, that might have been matched
356355
self:_hide_stage()
357356
self:_clear_stage()
358-
elseif not self.stream:running() then
357+
else
358+
local data = nil
359+
if self:_is_dynamic() then
360+
if not self.stream:isvalid() then
361+
return
362+
end
363+
data = assert(self.stream.results)
364+
elseif self:_is_static() then
365+
data = assert(self._state.content)
366+
else
367+
assert(false, "unknown picker state")
368+
end
369+
359370
-- when there is a query we need to match against it, in this scenario the picker is non-interactive, there are
360371
-- two options, either it was configured to use a stream or a user provided table of entries - strings, or other
361372
-- tables
362-
local data = assert(self.stream.results or self._state.content)
363373
if #data > 0 and type(query) == "string" and #query > 0 then
364374
self.match:match(data, query, function(matching)
365375
if matching == nil then
366-
if not self.match.results or #self.match.results == 0 or #self.match.results[1] == 0 then
376+
if not self.match:isvalid() or self.match:isempty() then
367377
self.select:list(
368378
utils.EMPTY_TABLE,
369379
utils.EMPTY_TABLE
@@ -405,11 +415,11 @@ function Picker:_create_stage()
405415
if query == nil then
406416
self:_close_stage()
407417
self:_close_picker()
408-
elseif self.stream.results ~= nil then
418+
elseif self.stream:isvalid() then
409419
if #self.stream.results > 0 and type(query) == "string" and #query > 0 then
410420
stage.match:match(self.stream.results, query, function(matching)
411421
if matching == nil then
412-
if not stage.match.results or #stage.match.results == 0 or #stage.match.results[1] == 0 then
422+
if not stage.match:isvalid() and stage.match:isempty() then
413423
stage.select:list(
414424
utils.EMPTY_TABLE,
415425
utils.EMPTY_TABLE
@@ -507,7 +517,7 @@ function Picker:_toggle_stage()
507517
self:_hide_picker()
508518
stage.select:open()
509519

510-
if stage.select:isempty() and self.stream.results then
520+
if stage.select:isempty() and self.stream:isvalid() then
511521
stage.select:list(
512522
self.stream.results,
513523
nil -- no highlights
@@ -619,7 +629,7 @@ function Picker:_flush_interactive()
619629
return utils.debounce_callback(self._options.stream_debounce, function(_, all)
620630
if all == nil then
621631
-- Stream finished: if it produced nothing, explicitly render an empty list, then signal completion.
622-
if not self.stream.results or #self.stream.results == 0 then
632+
if not self.stream:isvalid() then
623633
self.select:list(
624634
utils.EMPTY_TABLE,
625635
utils.EMPTY_TABLE
@@ -644,16 +654,23 @@ function Picker:_flush_direct()
644654
if all == nil then
645655
-- Stream finished: if it produced nothing, explicitly render an empty list, then signal to the list renderer for content
646656
-- delivery completion. there is also a case
647-
if not self.stream.results or #self.stream.results == 0 then
657+
if not self.stream:isvalid() then
648658
self.select:list(
649659
utils.EMPTY_TABLE,
650660
utils.EMPTY_TABLE
651661
)
652662
self.select:status("0/0")
653663
end
664+
654665
-- Final nil list marks end of streaming: Select stops incremental updates and treats the list as stable until
655666
-- new data arrives (or re-open).
656667
self.select:list(nil, nil)
668+
669+
-- manually prime the input for matching, in case a query has arrived right at the end of the stream, attempt to run a match, in
670+
-- case more input arrives this call will be de-bounced by the future calls and effectively be a no-op which is okay
671+
local options = self.select:options()
672+
local query = self.select:query()
673+
options.prompt_input(query)
657674
else
658675
-- Pull the current state query, sync we need to have an overview of the query value always, every time new accumulation of items
659676
-- arrives from the stream we have to re-roder them all and re-match them in accordance to the query
@@ -664,7 +681,7 @@ function Picker:_flush_direct()
664681
if #all > 0 and type(query) == "string" and #query > 0 then
665682
self.match:match(all, query, function(matching)
666683
if matching == nil then
667-
if not self.match.results or #self.match.results == 0 or #self.match.results[1] == 0 then
684+
if not self.match:isvalid() or self.match:isempty() then
668685
self.select:list(
669686
utils.EMPTY_TABLE,
670687
utils.EMPTY_TABLE
@@ -707,6 +724,14 @@ function Picker:_is_valid()
707724
)
708725
end
709726

727+
function Picker:_is_dynamic()
728+
return type(self._state.content) ~= "table"
729+
end
730+
731+
function Picker:_is_static()
732+
return type(self._state.content) == "table"
733+
end
734+
710735
function Picker:_is_interactive()
711736
local interactive = self._options.interactive
712737
return interactive ~= nil and interactive ~= false
@@ -797,11 +822,11 @@ function Picker:open()
797822
end
798823
self.select:open()
799824

800-
if type(self._state.content) ~= "table" then
825+
if self:_is_dynamic() then
801826
-- when a string or a function is provided the content is expected to be a command that produces output, or a function
802827
-- that produces output by calling a callback method for each entry in the stream
803828
assert(type(self._state.content) == "string" or type(self._state.content) == "function")
804-
if (needs_rerun or not self.stream.results) and not self:_is_interactive() then
829+
if (needs_rerun or not self.stream:isvalid()) and not self:_is_interactive() then
805830
-- in case we need to re-run the picker or the stream itself was destroyed, having no-results implies
806831
-- that stream requires to be re-started, to obtain results and then fill the select interface
807832
self.stream:start(self._state.content, {
@@ -811,7 +836,7 @@ function Picker:open()
811836
callback = self:_flush_direct(),
812837
transform = self._options.stream_map,
813838
})
814-
elseif self.stream.results and self.select:isempty() then
839+
elseif self.stream:isvalid() and self.select:isempty() then
815840
-- in case there are valid results in the stream, just fill the select interface, having results here
816841
-- means the stream was not destroyed and it still persistent.
817842
self.select:list(
@@ -949,7 +974,7 @@ function Picker.new(opts)
949974
if self:_is_interactive() then
950975
-- ensure that picker is not marked interactive when a table
951976
-- static content is provided for the picker, otherwise fail
952-
assert(type(self._options.content) ~= "table")
977+
assert(self:_is_dynamic())
953978
end
954979

955980
self.match = Match.new({

lua/fuzzy/select.lua

Lines changed: 23 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -2419,6 +2419,28 @@ function Select:send_locliset(callback)
24192419
self:send_fixlist("loclist", callback)
24202420
end
24212421

2422+
--- Checks if the selection interface is currently open, this is determined by checking if both the prompt and list windows are valid.
2423+
--- @return boolean True if the selection interface is open, false otherwise.
2424+
function Select:isopen()
2425+
local prompt = self.prompt_window and vim.api.nvim_win_is_valid(self.prompt_window)
2426+
local list = self.list_window and vim.api.nvim_win_is_valid(self.list_window)
2427+
return list ~= nil and prompt ~= nil and list and prompt
2428+
end
2429+
2430+
--- Checks if the selection interface is valid, meaning that it has been initialized/opened at least once and has not been destroyed by calling the `close` method which would invalidate its curent state
2431+
--- @return boolean True if the select interface is still valid, false otherwise.
2432+
function Select:isvalid()
2433+
local prompt = self.prompt_buffer and vim.api.nvim_buf_is_valid(self.prompt_buffer)
2434+
local list = self.list_buffer and vim.api.nvim_buf_is_valid(self.list_buffer)
2435+
return list ~= nil and prompt ~= nil and list and prompt
2436+
end
2437+
2438+
--- Checks if the selection interface is showing any entries, the entries can be rendered or set using the `list` method, which renders the specified entries into the list.
2439+
--- @return boolean True if the list is empty, false otherwise.
2440+
function Select:isempty()
2441+
return not self._state.entries or #self._state.entries == 0
2442+
end
2443+
24222444
--- Gets the current query from the prompt input. The query is updated on each input change in the prompt. It is not extracted directly from the prompt buffer on-demand but is captured with each input change using TextChangedI and TextChanged autocmd events.
24232445
--- @return string The current query string, the query will never be nil, otherwise that represents invalid state of the selection interface.
24242446
function Select:query()
@@ -2447,28 +2469,6 @@ function Select:clear()
24472469
self:_clear_view(false)
24482470
end
24492471

2450-
--- Checks if the selection interface is showing any entries, the entries can be rendered or set using the `list` method, which renders the specified entries into the list.
2451-
--- @return boolean True if the list is empty, false otherwise.
2452-
function Select:isempty()
2453-
return not self._state.entries or #self._state.entries == 0
2454-
end
2455-
2456-
--- Checks if the selection interface is currently open, this is determined by checking if both the prompt and list windows are valid.
2457-
--- @return boolean True if the selection interface is open, false otherwise.
2458-
function Select:isopen()
2459-
local prompt = self.prompt_window and vim.api.nvim_win_is_valid(self.prompt_window)
2460-
local list = self.list_window and vim.api.nvim_win_is_valid(self.list_window)
2461-
return list ~= nil and prompt ~= nil and list and prompt
2462-
end
2463-
2464-
--- Checks if the selection interface is valid, meaning that it has been initialized/opened at least once and has not been destroyed by calling the `close` method which would invalidate its curent state
2465-
--- @return boolean True if the select interface is still valid, false otherwise.
2466-
function Select:isvalid()
2467-
local prompt = self.prompt_buffer and vim.api.nvim_buf_is_valid(self.prompt_buffer)
2468-
local list = self.list_buffer and vim.api.nvim_buf_is_valid(self.list_buffer)
2469-
return list ~= nil and prompt ~= nil and list and prompt
2470-
end
2471-
24722472
-- Render a list of entries in the list window, with optional highlighting positions. If entries is nil, it will re-render the current list fully with existing entries and positions. Sending nil for both parameters signals to the interface that the streaming of entries is complete and it should finalize the rendering. This method is optimized to handle large lists of entries efficiently by rendering only the visible portion of the list and updating it as needed around the cursor position. The list is incrementally updated as new entries are provided, allowing for a smooth and responsive user experience even with large datasets.
24732473
-- @param entries? any[]|string[]|nil The list of entries to display.
24742474
-- @param positions? integer[][]|nil The list of positions for highlighting
@@ -2927,7 +2927,7 @@ end
29272927
--- @class SelectOptions
29282928
--- @inlinedoc
29292929
--- @field prompt_list? boolean|fun()|any[] Whether to show the list window or a function to provide initial entries.
2930-
--- @field prompt_input? boolean|fun() Whether to show the input prompt, when function is provided it is used as the input callback.
2930+
--- @field prompt_input? boolean|fun(string?) Whether to show the input prompt, when function is provided it is used as the input callback and the query is passed to it.
29312931
--- The callback may return a list of entries directly, or a table with `entries` and optional `positions` for synchronous updates.
29322932
--- @field prompt_headers? table|string|nil Headers to display above the prompt input, can be a string or a table of strings or string/highlight pairs, where each inner table represents a block of header entries.
29332933
--- @field prompt_query? string|nil Initial query to populate the prompt input with. If provided, the prompt input must also be enabled. The query will be set in the prompt input and the input callback will be invoked with this initial query.

lua/fuzzy/stream.lua

Lines changed: 25 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -340,6 +340,18 @@ function Stream:_stop_streaming()
340340
end
341341
end
342342

343+
--- Returns true if the stream is considered finalized and no longer processing data in-flight, and there is a valid set of data present for consumption produced by the stream
344+
--- @return boolean True if the stream results data is ready to be consumed by the client
345+
function Stream:isvalid()
346+
return not self:running() and self.results ~= nil and #self.results >= 0
347+
end
348+
349+
--- Checks if the stream has accumulated any entries ready for consumption
350+
--- @return boolean True if the matcher has no entries accumulated, false otherwise.
351+
function Stream:isempty()
352+
return self:isvalid() and #self.results == 0
353+
end
354+
343355
--- Returns true if the stream is started or is already running, i.e. has been started and not yet stopped, exited or aborted, false otherwise
344356
--- @return boolean True if the stream is started or running, false otherwise
345357
function Stream:running()
@@ -353,19 +365,6 @@ function Stream:options()
353365
return assert(self._options)
354366
end
355367

356-
-- Destroys the stream and any pending state that is currently being allocated into the stream, note that this is done automatically for
357-
-- ephemeral streams when a new stream is re-started the resources for the previous ones are invalidated
358-
function Stream:destroy()
359-
self:_close_stream()
360-
self:_destroy_stream()
361-
end
362-
363-
--- Stops the stream if it is running and finalizes the current buffered results without destroying them
364-
function Stream:stop()
365-
self:_close_stream()
366-
self:_stop_streaming()
367-
end
368-
369368
--- Waits for the stream to finish, or until the timeout is reached
370369
--- @param timeout integer|nil The maximum time to wait in milliseconds, defaults to the timeout specified in the options, or utils.MAX_TIMEOUT
371370
--- @return string[]|nil The results accumulated so far, or nil if the timeout was reached
@@ -381,6 +380,19 @@ function Stream:wait(timeout)
381380
return self.results
382381
end
383382

383+
-- Destroys the stream and any pending state that is currently being allocated into the stream, note that this is done automatically for
384+
-- ephemeral streams when a new stream is re-started the resources for the previous ones are invalidated
385+
function Stream:destroy()
386+
self:_close_stream()
387+
self:_destroy_stream()
388+
end
389+
390+
--- Stops the stream if it is running and finalizes the current buffered results without destroying them
391+
function Stream:stop()
392+
self:_close_stream()
393+
self:_stop_streaming()
394+
end
395+
384396
--- @class StreamStartOpts
385397
--- @field cwd? string The current working directory to run the command in, defaults to the current working directory of Neovim
386398
--- @field args? string[] The arguments to pass to the command, defaults to an empty table

0 commit comments

Comments
 (0)