telescope-frecency.nvim/lua/frecency/finder.lua
JINNOUCHI Yasushi 58c0089414
fix!: register realpath for consistency (#240)
Now it uses realpath for registering and validating DB. This means, if you have entries that has filenames differing only for case, it can deal with them as they exist.

Before this, it has miscalculated scores for such cases. For example, in case you have `/path/to/foo.lua` and `/path/to/Foo.lua`, it registers entries for each file. Now it detects accurate filename for the specified one, and removes it in validation.

* test: separate logic for utils

* fix!: register realpath for consistency

* refactor: convert fs module from class

* refactor: move db initialization phase to start()

* fix: run database:start() truly asynchronously

* fix: call each functions with async wrapping

* refactor: add types for args in command

* fix: run register() synchronously

Because vim.api.nvim_* cannot be used in asynchronous functions.

* docs: add note for calling setup() twice

* fix: run non-fast logic on next tick
2024-08-15 17:40:03 +09:00

328 lines
9.3 KiB
Lua

local config = require "frecency.config"
local fs = require "frecency.fs"
local os_util = require "frecency.os_util"
local log = require "frecency.log"
local Job = require "plenary.job"
local async = require "plenary.async" --[[@as FrecencyPlenaryAsync]]
---@class FrecencyFinder
---@field config FrecencyFinderConfig
---@field closed boolean
---@field entries FrecencyEntry[]
---@field scanned_entries FrecencyEntry[]
---@field entry_maker FrecencyEntryMakerInstance
---@field path? string
---@field private database FrecencyDatabase
---@field private rx FrecencyPlenaryAsyncControlChannelRx
---@field private tx FrecencyPlenaryAsyncControlChannelTx
---@field private scan_rx FrecencyPlenaryAsyncControlChannelRx
---@field private scan_tx FrecencyPlenaryAsyncControlChannelTx
---@field private need_scan_db boolean
---@field private need_scan_dir boolean
---@field private seen table<string, boolean>
---@field private process VimSystemObj?
---@field private recency FrecencyRecency
---@field private state FrecencyState
local Finder = {}
---@class FrecencyFinderConfig
---@field chunk_size? integer default: 1000
---@field ignore_filenames? string[] default: {}
---@field sleep_interval? integer default: 50
---@param database FrecencyDatabase
---@param entry_maker FrecencyEntryMakerInstance
---@param need_scandir boolean
---@param path string?
---@param recency FrecencyRecency
---@param state FrecencyState
---@param finder_config? FrecencyFinderConfig
---@return FrecencyFinder
Finder.new = function(database, entry_maker, need_scandir, path, recency, state, finder_config)
local tx, rx = async.control.channel.mpsc()
local scan_tx, scan_rx = async.control.channel.mpsc()
local self = setmetatable({
config = vim.tbl_extend("force", { chunk_size = 1000, sleep_interval = 50 }, finder_config or {}),
closed = false,
database = database,
entry_maker = entry_maker,
path = path,
recency = recency,
state = state,
seen = {},
entries = {},
scanned_entries = {},
need_scan_db = true,
need_scan_dir = need_scandir and path,
rx = rx,
tx = tx,
scan_rx = scan_rx,
scan_tx = scan_tx,
}, {
__index = Finder,
---@param self FrecencyFinder
__call = function(self, ...)
return self:find(...)
end,
})
if self.config.ignore_filenames then
for _, name in ipairs(self.config.ignore_filenames or {}) do
self.seen[name] = true
end
end
return self
end
---@param epoch? integer
---@return nil
function Finder:start(epoch)
local ok
if config.workspace_scan_cmd ~= "LUA" and self.need_scan_dir then
---@type string[][]
local cmds = config.workspace_scan_cmd and { config.workspace_scan_cmd }
or { { "fdfind", "-Htf", "-E", ".git" }, { "fd", "-Htf", "-E", ".git" }, { "rg", "-.g", "!.git", "--files" } }
for _, c in ipairs(cmds) do
ok = self:scan_dir_cmd(c)
if ok then
log.debug("scan_dir_cmd: " .. vim.inspect(c))
break
end
end
end
async.void(function()
-- NOTE: return to the main loop to show the main window
async.util.scheduler()
for _, file in ipairs(self:get_results(self.path, epoch)) do
file.path = os_util.normalize_sep(file.path)
local entry = self.entry_maker(file)
self.tx.send(entry)
end
self.tx.send(nil)
if self.need_scan_dir and not ok then
log.debug "scan_dir_lua"
async.util.scheduler()
self:scan_dir_lua()
end
end)()
end
---@param cmd string[]
---@return boolean
function Finder:scan_dir_cmd(cmd)
local function stdout(err, chunk)
if not self.closed and not err and chunk then
for name in chunk:gmatch "[^\n]+" do
local cleaned = name:gsub("^%./", "")
local fullpath = os_util.join_path(self.path, cleaned)
local entry = self.entry_maker { id = 0, count = 0, path = fullpath, score = 0 }
self.scan_tx.send(entry)
end
end
end
local function on_exit()
self.process = nil
self:close()
self.scan_tx.send(nil)
end
local ok
if vim.system then
---@diagnostic disable-next-line: assign-type-mismatch
ok, self.process = pcall(vim.system, cmd, {
cwd = self.path,
text = true,
stdout = stdout,
}, on_exit)
else
-- for Neovim v0.9.x
ok, self.process = pcall(function()
local args = {}
for i, arg in ipairs(cmd) do
if i > 1 then
table.insert(args, arg)
end
end
log.debug { cmd = cmd[1], args = args }
local job = Job:new {
cwd = self.path,
command = cmd[1],
args = args,
on_stdout = stdout,
on_exit = on_exit,
}
job:start()
return job.handle
end)
end
if not ok then
self.process = nil
end
return ok
end
---@async
---@return nil
function Finder:scan_dir_lua()
local count = 0
for name in fs.scan_dir(self.path) do
if self.closed then
break
end
local fullpath = os_util.join_path(self.path, name)
local entry = self.entry_maker { id = 0, count = 0, path = fullpath, score = 0 }
self.scan_tx.send(entry)
count = count + 1
if count % self.config.chunk_size == 0 then
async.util.sleep(self.config.sleep_interval)
end
end
self.scan_tx.send(nil)
end
---@async
---@param _ string
---@param process_result fun(entry: FrecencyEntry): nil
---@param process_complete fun(): nil
---@return nil
function Finder:find(_, process_result, process_complete)
if self:process_table(process_result, self.entries) then
return
end
if self.need_scan_db then
if self:process_channel(process_result, self.entries, self.rx) then
return
end
self.need_scan_db = false
end
-- HACK: This is needed for heavy workspaces to show up entries immediately.
async.util.scheduler()
if self:process_table(process_result, self.scanned_entries) then
return
end
if self.need_scan_dir then
if self:process_channel(process_result, self.scanned_entries, self.scan_rx, #self.entries) then
return
end
self.need_scan_dir = false
end
process_complete()
end
---@param process_result fun(entry: FrecencyEntry): nil
---@param entries FrecencyEntry[]
---@return boolean?
function Finder:process_table(process_result, entries)
for _, entry in ipairs(entries) do
if process_result(entry) then
return true
end
end
end
---@async
---@param process_result fun(entry: FrecencyEntry): nil
---@param entries FrecencyEntry[]
---@param rx FrecencyPlenaryAsyncControlChannelRx
---@param start_index? integer
---@return boolean?
function Finder:process_channel(process_result, entries, rx, start_index)
-- HACK: This is needed for small workspaces that it shows up entries fast.
async.util.sleep(self.config.sleep_interval)
local index = #entries > 0 and entries[#entries].index or start_index or 0
local count = 0
while true do
local entry = rx.recv()
if not entry then
break
elseif not self.seen[entry.filename] then
self.seen[entry.filename] = true
index = index + 1
entry.index = index
table.insert(entries, entry)
if process_result(entry) then
return true
end
end
count = count + 1
if count % self.config.chunk_size == 0 then
self:reflow_results()
end
end
end
---@param workspace? string
---@param epoch? integer
---@return FrecencyFile[]
function Finder:get_results(workspace, epoch)
log.debug { workspace = workspace or "NONE" }
local start_fetch = os.clock()
local files = self.database:get_entries(workspace, epoch)
log.debug(("it takes %f seconds in fetching entries"):format(os.clock() - start_fetch))
local start_results = os.clock()
local elapsed_recency = 0
for _, file in ipairs(files) do
local start_recency = os.clock()
file.score = file.ages and self.recency:calculate(file.count, file.ages) or 0
file.ages = nil
elapsed_recency = elapsed_recency + (os.clock() - start_recency)
end
log.debug(("it takes %f seconds in calculating recency"):format(elapsed_recency))
log.debug(("it takes %f seconds in making results"):format(os.clock() - start_results))
local start_sort = os.clock()
table.sort(files, function(a, b)
return a.score > b.score or (a.score == b.score and a.path > b.path)
end)
log.debug(("it takes %f seconds in sorting"):format(os.clock() - start_sort))
return files
end
function Finder:close()
self.closed = true
if self.process then
self.process:kill(9)
end
end
---@async
---@return nil
function Finder:reflow_results()
local picker = self.state:get()
if not picker then
return
end
async.util.scheduler()
local function reflow()
local bufnr = picker.results_bufnr
local win = picker.results_win
if not bufnr or not win or not vim.api.nvim_buf_is_valid(bufnr) or not vim.api.nvim_win_is_valid(win) then
return
end
picker:clear_extra_rows(bufnr)
if picker.sorting_strategy == "descending" then
local manager = picker.manager
if not manager then
return
end
local worst_line = picker:get_row(manager:num_results())
local wininfo = vim.fn.getwininfo(win)[1]
local bottom = vim.api.nvim_buf_line_count(bufnr)
if not self.reflowed or worst_line > wininfo.botline then
self.reflowed = true
vim.api.nvim_win_set_cursor(win, { bottom, 0 })
end
end
end
if vim.in_fast_event() then
reflow()
else
vim.schedule(reflow)
end
end
return Finder