diff --git a/doc/telescope-frecency.txt b/doc/telescope-frecency.txt index 0d0008d..671edb5 100644 --- a/doc/telescope-frecency.txt +++ b/doc/telescope-frecency.txt @@ -326,14 +326,15 @@ This will be called by |telescope.nvim| for its initialization. You can also call this to initialize this plugin separated from |telescope.nvim|'s initialization phase. -This is useful when you want to load |telescope.nvim| lazily, but want to +This is useful when you want to load |telescope.nvim| lazily and want to register opened files as soon as Neovim has started. Example configuration for |lazy.nvim| is below. >lua { "nvim-telescope/telescope-frecency.nvim", main = "frecency", - ---@type FrecencyOpts + -- `opts` property calls the plugin's setup() function. + -- In this case, this calls frecency.setup(). opts = { db_safe_mode = false, }, @@ -358,7 +359,10 @@ register opened files as soon as Neovim has started. Example configuration for telescope.load_extension "frecency" end, }, - +< +This function does nothing when it is called for the second times and later. +If you want to set another configuration, use +|telescope-frecency-configuration-config.setup()|. ============================================================================== CONFIGURATION *telescope-frecency-configuration* diff --git a/lua/frecency/config.lua b/lua/frecency/config.lua index 22bd729..e6e41d9 100644 --- a/lua/frecency/config.lua +++ b/lua/frecency/config.lua @@ -26,6 +26,7 @@ local os_util = require "frecency.os_util" ---@class FrecencyConfig: FrecencyRawConfig ---@field ext_config FrecencyRawConfig +---@field private cached_ignore_regexes? string[] ---@field private values FrecencyRawConfig local Config = {} @@ -79,6 +80,7 @@ Config.new = function() workspaces = true, } return setmetatable({ + cached_ignore_regexes = {}, ext_config = {}, values = Config.default_values, }, { @@ -138,6 +140,17 @@ Config.get = function() return config.values end +---@return string[] +Config.ignore_regexes = function() + if not config.cached_ignore_regexes then + config.cached_ignore_regexes = vim.tbl_map(function(pattern) + local regex = vim.pesc(pattern):gsub("%%%*", ".*"):gsub("%%%?", ".") + return "^" .. regex .. "$" + end, config.ignore_patterns) + end + return config.cached_ignore_regexes +end + ---@param ext_config any ---@return nil Config.setup = function(ext_config) @@ -174,6 +187,7 @@ Config.setup = function(ext_config) workspace_scan_cmd = { opts.workspace_scan_cmd, { "s", "t" }, true }, workspaces = { opts.workspaces, "t" }, } + config.cached_ignore_regexes = nil config.ext_config = ext_config config.values = opts end diff --git a/lua/frecency/database.lua b/lua/frecency/database.lua index 36eca5a..569e25d 100644 --- a/lua/frecency/database.lua +++ b/lua/frecency/database.lua @@ -1,6 +1,7 @@ local Table = require "frecency.database.table" local FileLock = require "frecency.file_lock" local config = require "frecency.config" +local fs = require "frecency.fs" local watcher = require "frecency.watcher" local log = require "frecency.log" local async = require "plenary.async" --[[@as FrecencyPlenaryAsync]] @@ -13,48 +14,73 @@ local Path = require "plenary.path" --[[@as FrecencyPlenaryPath]] ---@field score number ---@field timestamps integer[] +---@alias FrecencyDatabaseVersion "v1" + ---@class FrecencyDatabase ----@field tx FrecencyPlenaryAsyncControlChannelTx ----@field private file_lock FrecencyFileLock ----@field private filename string ----@field private fs FrecencyFS +---@field private _file_lock FrecencyFileLock +---@field private file_lock_rx async fun(): ... +---@field private file_lock_tx fun(...): nil ---@field private tbl FrecencyDatabaseTable ----@field private version "v1" +---@field private version FrecencyDatabaseVersion +---@field private watcher_rx FrecencyPlenaryAsyncControlChannelRx +---@field private watcher_tx FrecencyPlenaryAsyncControlChannelTx local Database = {} ----@param fs FrecencyFS ---@return FrecencyDatabase -Database.new = function(fs) +Database.new = function() local version = "v1" - local self = setmetatable({ - fs = fs, + local file_lock_tx, file_lock_rx = async.control.channel.oneshot() + local watcher_tx, watcher_rx = async.control.channel.mpsc() + return setmetatable({ + file_lock_rx = file_lock_rx, + file_lock_tx = file_lock_tx, tbl = Table.new(version), version = version, + watcher_rx = watcher_rx, + watcher_tx = watcher_tx, }, { __index = Database }) - self.filename = (function() +end + +---@async +---@return string +function Database:filename() + local file_v1 = "file_frecency.bin" + + ---@async + ---@return string + local function filename_v1() -- NOTE: for backward compatibility -- If the user does not set db_root specifically, search DB in -- $XDG_DATA_HOME/nvim in addition to $XDG_STATE_HOME/nvim (default value). - local file = "file_frecency.bin" - local db = Path.new(config.db_root, file) - if not config.ext_config.db_root and not db:exists() then - local old_location = Path.new(vim.fn.stdpath "data", file) - if old_location:exists() then - return old_location.filename + local db = Path.new(config.db_root, file_v1).filename + if not config.ext_config.db_root and not fs.exists(db) then + local old_location = Path.new(vim.fn.stdpath "data", file_v1).filename + if fs.exists(old_location) then + return old_location end end - return db.filename - end)() - self.file_lock = FileLock.new(self.filename) - local rx - self.tx, rx = async.control.channel.mpsc() - self.tx.send "load" - watcher.watch(self.filename, function() - self.tx.send "load" + return db + end + + if self.version == "v1" then + return filename_v1() + else + error(("unknown version: %s"):format(self.version)) + end +end + +---@async +---@return nil +function Database:start() + local target = self:filename() + self.file_lock_tx(FileLock.new(target)) + self.watcher_tx.send "load" + watcher.watch(target, function() + self.watcher_tx.send "load" end) async.void(function() while true do - local mode = rx.recv() + local mode = self.watcher_rx.recv() log.debug("DB coroutine start:", mode) if mode == "load" then self:load() @@ -66,14 +92,15 @@ Database.new = function(fs) log.debug("DB coroutine end:", mode) end end)() - return self end +---@async ---@return boolean function Database:has_entry() return not vim.tbl_isempty(self.tbl.records) end +---@async ---@param paths string[] ---@return nil function Database:insert_files(paths) @@ -83,28 +110,32 @@ function Database:insert_files(paths) for _, path in ipairs(paths) do self.tbl.records[path] = { count = 1, timestamps = { 0 } } end - self.tx.send "save" + self.watcher_tx.send "save" end +---@async ---@return string[] function Database:unlinked_entries() - local paths = {} - for file in pairs(self.tbl.records) do - if not self.fs:is_valid_path(file) then - table.insert(paths, file) + return vim.tbl_flatten(async.util.join(vim.tbl_map(function(path) + return function() + local err, realpath = async.uv.fs_realpath(path) + if err or not realpath or realpath ~= path or fs.is_ignored(realpath) then + return path + end end - end - return paths + end, vim.tbl_keys(self.tbl.records)))) end +---@async ---@param paths string[] function Database:remove_files(paths) for _, file in ipairs(paths) do self.tbl.records[file] = nil end - self.tx.send "save" + self.watcher_tx.send "save" end +---@async ---@param path string ---@param epoch? integer function Database:update(path, epoch) @@ -120,9 +151,10 @@ function Database:update(path, epoch) record.timestamps = new_table end self.tbl.records[path] = record - self.tx.send "save" + self.watcher_tx.send "save" end +---@async ---@param workspace? string ---@param epoch? integer ---@return FrecencyDatabaseEntry[] @@ -130,7 +162,7 @@ function Database:get_entries(workspace, epoch) local now = epoch or os.time() local items = {} for path, record in pairs(self.tbl.records) do - if self.fs:starts_with(path, workspace) then + if fs.starts_with(path, workspace) then table.insert(items, { path = path, count = record.count, @@ -148,13 +180,13 @@ end ---@return nil function Database:load() local start = os.clock() - local err, data = self.file_lock:with(function() - local err, stat = async.uv.fs_stat(self.filename) + local err, data = self:file_lock():with(function(target) + local err, stat = async.uv.fs_stat(target) if err then return nil end local fd - err, fd = async.uv.fs_open(self.filename, "r", tonumber("644", 8)) + err, fd = async.uv.fs_open(target, "r", tonumber("644", 8)) assert(not err, err) local data err, data = async.uv.fs_read(fd, stat.size) @@ -173,9 +205,9 @@ end ---@return nil function Database:save() local start = os.clock() - local err = self.file_lock:with(function() - self:raw_save(self.tbl:raw()) - local err, stat = async.uv.fs_stat(self.filename) + local err = self:file_lock():with(function(target) + self:raw_save(self.tbl:raw(), target) + local err, stat = async.uv.fs_stat(target) assert(not err, err) watcher.update(stat) return nil @@ -185,16 +217,18 @@ function Database:save() end ---@async +---@param target string ---@param tbl FrecencyDatabaseRawTable -function Database:raw_save(tbl) +function Database:raw_save(tbl, target) local f = assert(load("return " .. vim.inspect(tbl))) local data = string.dump(f) - local err, fd = async.uv.fs_open(self.filename, "w", tonumber("644", 8)) + local err, fd = async.uv.fs_open(target, "w", tonumber("644", 8)) assert(not err, err) assert(not async.uv.fs_write(fd, data)) assert(not async.uv.fs_close(fd)) end +---@async ---@param path string ---@return boolean function Database:remove_entry(path) @@ -202,8 +236,18 @@ function Database:remove_entry(path) return false end self.tbl.records[path] = nil - self.tx.send "save" + self.watcher_tx.send "save" return true end +---@private +---@async +---@return FrecencyFileLock +function Database:file_lock() + if not self._file_lock then + self._file_lock = self.file_lock_rx() + end + return self._file_lock +end + return Database diff --git a/lua/frecency/database/table.lua b/lua/frecency/database/table.lua index f461f66..8618ca2 100644 --- a/lua/frecency/database/table.lua +++ b/lua/frecency/database/table.lua @@ -1,4 +1,5 @@ local log = require "frecency.log" +local async = require "plenary.async" ---@class FrecencyDatabaseRecordValue ---@field count integer @@ -18,14 +19,12 @@ Table.new = function(version) return setmetatable({ is_ready = false, version = version }, { __index = Table.__index }) end +---@async +---@param key string function Table:__index(key) if key == "records" and not rawget(self, "is_ready") then - local start = os.clock() - log.debug "waiting start" Table.wait_ready(self) - log.debug(("waiting until DB become clean takes %f seconds"):format(os.clock() - start)) end - log.debug(("is_ready: %s, key: %s, value: %s"):format(rawget(self, "is_ready"), key, rawget(self, key))) return vim.F.if_nil(rawget(self, key), Table[key]) end @@ -45,11 +44,16 @@ function Table:set(raw_table) end ---This is for internal or testing use only. +---@async ---@return nil function Table:wait_ready() - vim.wait(2000, function() - return rawget(self, "is_ready") - end) + local start = os.clock() + local t = 0.2 + while not rawget(self, "is_ready") do + async.util.sleep(t) + t = t * 2 + end + log.debug(("wait_ready() takes %f seconds"):format(os.clock() - start)) end return Table diff --git a/lua/frecency/entry_maker.lua b/lua/frecency/entry_maker.lua index 3aab98e..2fa4af7 100644 --- a/lua/frecency/entry_maker.lua +++ b/lua/frecency/entry_maker.lua @@ -1,19 +1,18 @@ local WebDevicons = require "frecency.web_devicons" local config = require "frecency.config" +local fs = require "frecency.fs" local Path = require "plenary.path" --[[@as FrecencyPlenaryPath]] local entry_display = require "telescope.pickers.entry_display" --[[@as FrecencyTelescopeEntryDisplay]] local utils = require "telescope.utils" --[[@as FrecencyTelescopeUtils]] ---@class FrecencyEntryMaker ----@field fs FrecencyFS ---@field loaded table ---@field web_devicons WebDevicons local EntryMaker = {} ----@param fs FrecencyFS ---@return FrecencyEntryMaker -EntryMaker.new = function(fs) - return setmetatable({ fs = fs, web_devicons = WebDevicons.new() }, { __index = EntryMaker }) +EntryMaker.new = function() + return setmetatable({ web_devicons = WebDevicons.new() }, { __index = EntryMaker }) end ---@class FrecencyEntry @@ -128,7 +127,7 @@ function EntryMaker:items(entry, workspace, workspace_tag, formatter) end if config.show_filter_column and workspace and workspace_tag then local filtered = self:should_show_tail(workspace_tag) and utils.path_tail(workspace) .. Path.path.sep - or self.fs:relative_from_home(workspace) .. Path.path.sep + or fs.relative_from_home(workspace) .. Path.path.sep table.insert(items, { filtered, "Directory" }) end local formatted_name, path_style = formatter(entry.name) @@ -153,7 +152,7 @@ end ---@return integer function EntryMaker:calculate_filter_column_width(workspace, workspace_tag) return self:should_show_tail(workspace_tag) and #(utils.path_tail(workspace)) + 1 - or #(self.fs:relative_from_home(workspace)) + 1 + or #(fs.relative_from_home(workspace)) + 1 end ---@private diff --git a/lua/frecency/file_lock.lua b/lua/frecency/file_lock.lua index 738cf0d..3c19ac0 100644 --- a/lua/frecency/file_lock.lua +++ b/lua/frecency/file_lock.lua @@ -4,23 +4,28 @@ local async = require "plenary.async" --[[@as FrecencyPlenaryAsync]] ---@class FrecencyFileLock ---@field base string ----@field config FrecencyFileLockConfig ----@field filename string +---@field config FrecencyFileLockRawConfig +---@field lock string +---@field target string local FileLock = {} ---@class FrecencyFileLockConfig +---@field retry? integer default: 5 +---@field unlink_retry? integer default: 5 +---@field interval? integer default: 500 + +---@class FrecencyFileLockRawConfig ---@field retry integer default: 5 ---@field unlink_retry integer default: 5 ---@field interval integer default: 500 ----@param path string +---@param target string ---@param file_lock_config? FrecencyFileLockConfig ---@return FrecencyFileLock -FileLock.new = function(path, file_lock_config) +FileLock.new = function(target, file_lock_config) + log.debug(("file_lock new(): %s"):format(target)) local config = vim.tbl_extend("force", { retry = 5, unlink_retry = 5, interval = 500 }, file_lock_config or {}) - local self = setmetatable({ config = config }, { __index = FileLock }) - self.filename = path .. ".lock" - return self + return setmetatable({ config = config, lock = target .. ".lock", target = target }, { __index = FileLock }) end ---@async @@ -31,21 +36,21 @@ function FileLock:get() local err, fd while true do count = count + 1 - local dir = Path.new(self.filename):parent() + local dir = Path.new(self.lock):parent() if not dir:exists() then -- TODO: make this call be async log.debug(("file_lock get(): mkdir parent: %s"):format(dir.filename)) ---@diagnostic disable-next-line: undefined-field dir:mkdir { parents = true } end - err, fd = async.uv.fs_open(self.filename, "wx", tonumber("600", 8)) + err, fd = async.uv.fs_open(self.lock, "wx", tonumber("600", 8)) if not err then break end async.util.sleep(self.config.interval) if count >= self.config.retry then log.debug(("file_lock get(): retry count reached. try to delete the lock file: %d"):format(count)) - err = async.uv.fs_unlink(self.filename) + err = async.uv.fs_unlink(self.lock) if err then log.debug("file_lock get() failed: " .. err) unlink_count = unlink_count + 1 @@ -67,12 +72,12 @@ end ---@async ---@return string? err function FileLock:release() - local err = async.uv.fs_stat(self.filename) + local err = async.uv.fs_stat(self.lock) if err then log.debug("file_lock release() not found: " .. err) return "lock not found" end - err = async.uv.fs_unlink(self.filename) + err = async.uv.fs_unlink(self.lock) if err then log.debug("file_lock release() unlink failed: " .. err) return err @@ -81,7 +86,7 @@ end ---@async ---@generic T ----@param f fun(): T +---@param f fun(target: string): T ---@return string? err ---@return T function FileLock:with(f) @@ -89,7 +94,7 @@ function FileLock:with(f) if err then return err, nil end - local ok, result_or_err = pcall(f) + local ok, result_or_err = pcall(f, self.target) err = self:release() if err then return err, nil diff --git a/lua/frecency/finder.lua b/lua/frecency/finder.lua index bafd9af..6bbccc3 100644 --- a/lua/frecency/finder.lua +++ b/lua/frecency/finder.lua @@ -1,4 +1,5 @@ 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" @@ -10,7 +11,6 @@ local async = require "plenary.async" --[[@as FrecencyPlenaryAsync]] ---@field entries FrecencyEntry[] ---@field scanned_entries FrecencyEntry[] ---@field entry_maker FrecencyEntryMakerInstance ----@field fs FrecencyFS ---@field path? string ---@field private database FrecencyDatabase ---@field private rx FrecencyPlenaryAsyncControlChannelRx @@ -32,14 +32,13 @@ local Finder = {} ---@param database FrecencyDatabase ---@param entry_maker FrecencyEntryMakerInstance ----@param fs FrecencyFS ---@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, fs, need_scandir, path, recency, state, finder_config) +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({ @@ -47,7 +46,6 @@ Finder.new = function(database, entry_maker, fs, need_scandir, path, recency, st closed = false, database = database, entry_maker = entry_maker, - fs = fs, path = path, recency = recency, state = state, @@ -168,7 +166,7 @@ end ---@return nil function Finder:scan_dir_lua() local count = 0 - for name in self.fs:scan_dir(self.path) do + for name in fs.scan_dir(self.path) do if self.closed then break end @@ -296,25 +294,34 @@ function Finder:reflow_results() return end async.util.scheduler() - 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 + + 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 - 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 }) + 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 diff --git a/lua/frecency/fs.lua b/lua/frecency/fs.lua index 6868839..ef57152 100644 --- a/lua/frecency/fs.lua +++ b/lua/frecency/fs.lua @@ -2,57 +2,57 @@ local config = require "frecency.config" local os_util = require "frecency.os_util" local log = require "frecency.log" local Path = require "plenary.path" --[[@as FrecencyPlenaryPath]] +local async = require "plenary.async" --[[@as FrecencyPlenaryAsync]] local scandir = require "plenary.scandir" local uv = vim.uv or vim.loop ----@class FrecencyFS ----@field os_homedir string ----@field private config FrecencyFSConfig ----@field private ignore_regexes string[] -local FS = {} +local M = { + os_homedir = assert(uv.os_homedir()), +} ----@class FrecencyFSConfig ----@field scan_depth integer? +-- TODO: make this configurable +local SCAN_DEPTH = 100 ----@param fs_config? FrecencyFSConfig ----@return FrecencyFS -FS.new = function(fs_config) - local self= setmetatable( - { config = vim.tbl_extend("force", { scan_depth = 100 }, fs_config or {}), os_homedir = assert(uv.os_homedir()) }, - { __index = FS } - ) - ---@param pattern string - self.ignore_regexes = vim.tbl_map(function(pattern) - local regex = vim.pesc(pattern):gsub("%%%*", ".*"):gsub("%%%?", ".") - return "^" .. regex .. "$" - end, config.ignore_patterns) - return self +---@param path string +---@return boolean +function M.is_ignored(path) + for _, regex in ipairs(config.ignore_regexes()) do + if path:find(regex) then + return true + end + end + return false end +---@async ---@param path? string ---@return boolean -function FS:is_valid_path(path) - return not not path and Path:new(path):is_file() and not self:is_ignored(path) +function M.is_valid_path(path) + if not path then + return false + end + local err, st = async.uv.fs_stat(path) + return not err and st.type == "file" and not M.is_ignored(path) end ---@param path string ---@return function -function FS:scan_dir(path) +function M.scan_dir(path) log.debug { path = path } - local gitignore = self:make_gitignore(path) + local gitignore = M.make_gitignore(path) return coroutine.wrap(function() for name, type in vim.fs.dir(path, { - depth = self.config.scan_depth, + depth = SCAN_DEPTH, skip = function(dirname) - if self:is_ignored(os_util.join_path(path, dirname)) then + if M.is_ignored(os_util.join_path(path, dirname)) then return false end end, }) do local fullpath = os_util.join_path(path, name) - if type == "file" and not self:is_ignored(fullpath) and gitignore({ path }, fullpath) then + if type == "file" and not M.is_ignored(fullpath) and gitignore({ path }, fullpath) then coroutine.yield(name) end end @@ -61,8 +61,8 @@ end ---@param path string ---@return string -function FS:relative_from_home(path) - return Path:new(path):make_relative(self.os_homedir) +function M.relative_from_home(path) + return Path:new(path):make_relative(M.os_homedir) end ---@type table @@ -71,7 +71,7 @@ local with_sep = {} ---@param path string ---@param base? string ---@return boolean -function FS:starts_with(path, base) +function M.starts_with(path, base) if not base then return true end @@ -81,25 +81,20 @@ function FS:starts_with(path, base) return path:find(with_sep[base], 1, true) == 1 end ----@private +---@async ---@param path string ---@return boolean -function FS:is_ignored(path) - for _, regex in ipairs(self.ignore_regexes) do - if path:find(regex) then - return true - end - end - return false +function M.exists(path) + return not (async.uv.fs_stat(path)) end ---@private ---@param basepath string ---@return fun(base_paths: string[], entry: string): boolean -function FS:make_gitignore(basepath) +function M.make_gitignore(basepath) return scandir.__make_gitignore { basepath } or function(_, _) return true end end -return FS +return M diff --git a/lua/frecency/init.lua b/lua/frecency/init.lua index bdc0eb3..b83d860 100644 --- a/lua/frecency/init.lua +++ b/lua/frecency/init.lua @@ -3,11 +3,11 @@ ---setup() to be initialized. ---@class FrecencyInstance ---@field complete fun(findstart: 1|0, base: string): integer|''|string[] ----@field delete fun(path: string): nil +---@field delete async fun(path: string): nil ---@field query fun(opts?: FrecencyQueryOpts): FrecencyQueryEntry[]|string[] ---@field register fun(bufnr: integer, datetime: string?): nil ---@field start fun(opts: FrecencyPickerOptions?): nil ----@field validate_database fun(force: boolean?): nil +---@field validate_database async fun(force: boolean?): nil local frecency = setmetatable({}, { ---@param self FrecencyInstance ---@param key "complete"|"delete"|"register"|"start"|"validate_database" @@ -28,6 +28,10 @@ local frecency = setmetatable({}, { end, }) +local function async_call(f, ...) + require("plenary.async").void(f)(...) +end + local setup_done = false ---When this func is called, Frecency instance is NOT created but only @@ -46,15 +50,20 @@ local function setup(ext_config) vim.api.nvim_set_hl(0, "TelescopeFrecencyScores", { link = "Number", default = true }) vim.api.nvim_set_hl(0, "TelescopeQueryFilter", { link = "WildMenu", default = true }) - ---@param cmd_info { bang: boolean } + ---@class FrecencyCommandInfo + ---@field args string + ---@field bang boolean + + ---@param cmd_info FrecencyCommandInfo vim.api.nvim_create_user_command("FrecencyValidate", function(cmd_info) - frecency.validate_database(cmd_info.bang) + async_call(frecency.validate_database, cmd_info.bang) end, { bang = true, desc = "Clean up DB for telescope-frecency" }) - vim.api.nvim_create_user_command("FrecencyDelete", function(info) - local path_string = info.args == "" and "%:p" or info.args + ---@param cmd_info FrecencyCommandInfo + vim.api.nvim_create_user_command("FrecencyDelete", function(cmd_info) + local path_string = cmd_info.args == "" and "%:p" or cmd_info.args local path = vim.fn.expand(path_string) --[[@as string]] - frecency.delete(path) + async_call(frecency.delete, path) end, { nargs = "?", complete = "file", desc = "Delete entry from telescope-frecency" }) local group = vim.api.nvim_create_augroup("TelescopeFrecency", {}) diff --git a/lua/frecency/klass.lua b/lua/frecency/klass.lua index 795d6cf..a192099 100644 --- a/lua/frecency/klass.lua +++ b/lua/frecency/klass.lua @@ -1,16 +1,16 @@ local Database = require "frecency.database" local EntryMaker = require "frecency.entry_maker" -local FS = require "frecency.fs" local Picker = require "frecency.picker" local Recency = require "frecency.recency" local config = require "frecency.config" +local fs = require "frecency.fs" local log = require "frecency.log" +local async = require "plenary.async" --[[@as FrecencyPlenaryAsync]] ---@class Frecency ---@field private buf_registered table flag to indicate the buffer is registered to the database. ---@field private database FrecencyDatabase ---@field private entry_maker FrecencyEntryMaker ----@field private fs FrecencyFS ---@field private picker FrecencyPicker ---@field private recency FrecencyRecency local Frecency = {} @@ -18,9 +18,8 @@ local Frecency = {} ---@return Frecency Frecency.new = function() local self = setmetatable({ buf_registered = {} }, { __index = Frecency }) --[[@as Frecency]] - self.fs = FS.new() - self.database = Database.new(self.fs) - self.entry_maker = EntryMaker.new(self.fs) + self.database = Database.new() + self.entry_maker = EntryMaker.new() self.recency = Recency.new() return self end @@ -28,14 +27,29 @@ end ---This is called when `:Telescope frecency` is called at the first time. ---@return nil function Frecency:setup() - -- HACK: Wihout this wrapping, it spoils background color detection. - -- See https://github.com/nvim-telescope/telescope-frecency.nvim/issues/210 - vim.defer_fn(function() + local done = false + ---@async + local function init() + self.database:start() self:assert_db_entries() if config.auto_validate then self:validate_database() end - end, 0) + done = true + end + + local is_async = not not coroutine.running() + if is_async then + init() + else + async.void(init)() + local ok, status = vim.wait(1000, function() + return done + end) + if not ok then + log.error("failed to setup:", status == -1 and "timed out" or "interrupted") + end + end end ---This can be calledBy `require("telescope").extensions.frecency.frecency`. @@ -52,7 +66,7 @@ function Frecency:start(opts) if opts.hide_current_buffer or config.hide_current_buffer then ignore_filenames = { vim.api.nvim_buf_get_name(0) } end - self.picker = Picker.new(self.database, self.entry_maker, self.fs, self.recency, { + self.picker = Picker.new(self.database, self.entry_maker, self.recency, { editing_bufnr = vim.api.nvim_get_current_buf(), ignore_filenames = ignore_filenames, initial_workspace_tag = opts.workspace, @@ -69,16 +83,7 @@ function Frecency:complete(findstart, base) return self.picker:complete(findstart, base) end ----@private ----@return nil -function Frecency:assert_db_entries() - if not self.database:has_entry() then - self.database:insert_files(vim.v.oldfiles) - self:notify("Imported %d entries from oldfiles.", #vim.v.oldfiles) - end -end - ----@private +---@async ---@param force? boolean ---@return nil function Frecency:validate_database(force) @@ -110,20 +115,38 @@ function Frecency:validate_database(force) end) end +---@private +---@async +---@return nil +function Frecency:assert_db_entries() + if not self.database:has_entry() then + self.database:insert_files(vim.v.oldfiles) + self:notify("Imported %d entries from oldfiles.", #vim.v.oldfiles) + end +end + ---@param bufnr integer ---@param epoch? integer function Frecency:register(bufnr, epoch) - if config.ignore_register and config.ignore_register(bufnr) then + if (config.ignore_register and config.ignore_register(bufnr)) or self.buf_registered[bufnr] then return end local path = vim.api.nvim_buf_get_name(bufnr) - if self.buf_registered[bufnr] or not self.fs:is_valid_path(path) then - return - end - self.database:update(path, epoch) - self.buf_registered[bufnr] = true + async.void(function() + if not fs.is_valid_path(path) then + return + end + local err, realpath = async.uv.fs_realpath(path) + if err or not realpath then + return + end + self.database:update(realpath, epoch) + self.buf_registered[bufnr] = true + log.debug("registered:", bufnr, path) + end)() end +---@async ---@param path string ---@return nil function Frecency:delete(path) diff --git a/lua/frecency/picker.lua b/lua/frecency/picker.lua index f28425d..297b4a5 100644 --- a/lua/frecency/picker.lua +++ b/lua/frecency/picker.lua @@ -1,6 +1,7 @@ local State = require "frecency.state" local Finder = require "frecency.finder" local config = require "frecency.config" +local fs = require "frecency.fs" local fuzzy_sorter = require "frecency.fuzzy_sorter" local substr_sorter = require "frecency.substr_sorter" local log = require "frecency.log" @@ -15,7 +16,6 @@ local uv = vim.loop or vim.uv ---@field private config FrecencyPickerConfig ---@field private database FrecencyDatabase ---@field private entry_maker FrecencyEntryMaker ----@field private fs FrecencyFS ---@field private lsp_workspaces string[] ---@field private namespace integer ---@field private recency FrecencyRecency @@ -38,16 +38,14 @@ local Picker = {} ---@param database FrecencyDatabase ---@param entry_maker FrecencyEntryMaker ----@param fs FrecencyFS ---@param recency FrecencyRecency ---@param picker_config FrecencyPickerConfig ---@return FrecencyPicker -Picker.new = function(database, entry_maker, fs, recency, picker_config) +Picker.new = function(database, entry_maker, recency, picker_config) local self = setmetatable({ config = picker_config, database = database, entry_maker = entry_maker, - fs = fs, lsp_workspaces = {}, namespace = vim.api.nvim_create_namespace "frecency", recency = recency, @@ -80,7 +78,6 @@ function Picker:finder(opts, workspace, workspace_tag) return Finder.new( self.database, entry_maker, - self.fs, need_scandir, workspace, self.recency, @@ -159,8 +156,8 @@ end function Picker:default_path_display(opts, path) local filename = Path:new(path):make_relative(opts.cwd) if not self.workspace then - if vim.startswith(filename, self.fs.os_homedir) then - filename = "~" .. Path.path.sep .. self.fs:relative_from_home(filename) + if vim.startswith(filename, fs.os_homedir) then + filename = "~" .. Path.path.sep .. fs.relative_from_home(filename) elseif filename ~= path then filename = "." .. Path.path.sep .. filename end @@ -269,7 +266,7 @@ function Picker:filepath_formatter(picker_opts) for k, v in pairs(picker_opts) do opts[k] = v end - opts.cwd = workspace or self.fs.os_homedir + opts.cwd = workspace or fs.os_homedir return function(filename) return utils.transform_path(opts, filename) diff --git a/lua/frecency/tests/database_spec.lua b/lua/frecency/tests/database_spec.lua index 1e8e05e..18d98cb 100644 --- a/lua/frecency/tests/database_spec.lua +++ b/lua/frecency/tests/database_spec.lua @@ -1,27 +1,18 @@ -local FS = require "frecency.fs" local Database = require "frecency.database" local config = require "frecency.config" local async = require "plenary.async" --[[@as FrecencyPlenaryAsync]] local util = require "frecency.tests.util" async.tests.add_to_env() ----@param datetime string? ----@return integer -local function make_epoch(datetime) - if not datetime then - return os.time() - end - local tz_fix = datetime:gsub("+(%d%d):(%d%d)$", "+%1%2") - return util.time_piece(tz_fix) -end +local make_epoch = util.make_epoch local function with_database(f) - local fs = FS.new {} local dir, close = util.tmpdir() dir:joinpath("file_frecency.bin"):touch() return function() config.setup { debug = true, db_root = dir.filename } - local database = Database.new(fs) + local database = Database.new() + database:start() f(database) close() end @@ -33,7 +24,8 @@ end ---@param epoch integer ---@return FrecencyEntry[] local function save_and_load(database, tbl, epoch) - database:raw_save(util.v1_table(tbl)) + ---@diagnostic disable-next-line: invisible + database:raw_save(util.v1_table(tbl), database:file_lock().target) async.util.sleep(100) local entries = database:get_entries(nil, epoch) table.sort(entries, function(a, b) diff --git a/lua/frecency/tests/file_lock_spec.lua b/lua/frecency/tests/file_lock_spec.lua index a98bb34..e139372 100644 --- a/lua/frecency/tests/file_lock_spec.lua +++ b/lua/frecency/tests/file_lock_spec.lua @@ -41,7 +41,7 @@ a.describe("file_lock", function() with_dir(function(filename) local fl = FileLock.new(filename, { retry = 1, interval = 10 }) a.it("gets successfully", function() - local err, fd = async.uv.fs_open(fl.filename, "wx", tonumber("600", 8)) + local err, fd = async.uv.fs_open(fl.lock, "wx", tonumber("600", 8)) assert.is.Nil(err) assert.is.Nil(async.uv.fs_close(fd)) assert.is.Nil(fl:get()) @@ -131,7 +131,7 @@ a.describe("file_lock", function() assert.are.same( "lock not found", fl:with(function() - assert.is.Nil(async.uv.fs_unlink(fl.filename)) + assert.is.Nil(async.uv.fs_unlink(fl.lock)) return nil end) ) diff --git a/lua/frecency/tests/frecency_spec.lua b/lua/frecency/tests/frecency_spec.lua index bd4f1a3..41a807c 100644 --- a/lua/frecency/tests/frecency_spec.lua +++ b/lua/frecency/tests/frecency_spec.lua @@ -2,112 +2,14 @@ -- https://github.com/nvim-lua/plenary.nvim/blob/663246936325062427597964d81d30eaa42ab1e4/lua/plenary/test_harness.lua#L86-L86 vim.opt.runtimepath:append(vim.env.TELESCOPE_PATH) ----@diagnostic disable: invisible, undefined-field -local Frecency = require "frecency.klass" -local Picker = require "frecency.picker" local util = require "frecency.tests.util" local log = require "plenary.log" -local Path = require "plenary.path" -local config = require "frecency.config" ----@param datetime string? ----@return integer -local function make_epoch(datetime) - if not datetime then - return os.time() - end - local tz_fix = datetime:gsub("+(%d%d):(%d%d)$", "+%1%2") - return util.time_piece(tz_fix) -end - ----@param files string[] ----@param cb_or_config table|fun(frecency: Frecency, finder: FrecencyFinder, dir: FrecencyPlenaryPath): nil ----@param callback? fun(frecency: Frecency, finder: FrecencyFinder, dir: FrecencyPlenaryPath): nil ----@return nil -local function with_files(files, cb_or_config, callback) - local dir, close = util.make_tree(files) - local cfg - if type(cb_or_config) == "table" then - cfg = vim.tbl_extend("force", { debug = true, db_root = dir.filename }, cb_or_config) - else - cfg = { debug = true, db_root = dir.filename } - callback = cb_or_config - end - assert(callback) - log.debug(cfg) - config.setup(cfg) - local frecency = Frecency.new() - frecency.database.tbl:wait_ready() - frecency.picker = - Picker.new(frecency.database, frecency.entry_maker, frecency.fs, frecency.recency, { editing_bufnr = 0 }) - local finder = frecency.picker:finder {} - callback(frecency, finder, dir) - close() -end - -local function filepath(dir, file) - return dir:joinpath(file):absolute() -end - ----@param frecency Frecency ----@param dir FrecencyPlenaryPath ----@return fun(file: string, epoch: integer, reset: boolean?): nil -local function make_register(frecency, dir) - return function(file, epoch, reset) - local path = filepath(dir, file) - vim.cmd.edit(path) - local bufnr = assert(vim.fn.bufnr(path)) - if reset then - frecency.buf_registered[bufnr] = nil - end - frecency:register(bufnr, epoch) - end -end - ----@param frecency Frecency ----@param dir FrecencyPlenaryPath ----@param callback fun(register: fun(file: string, epoch?: integer): nil): nil ----@return nil -local function with_fake_register(frecency, dir, callback) - local bufnr = 0 - local buffers = {} - local original_nvim_buf_get_name = vim.api.nvim_buf_get_name - ---@diagnostic disable-next-line: redefined-local, duplicate-set-field - vim.api.nvim_buf_get_name = function(bufnr) - return buffers[bufnr] - end - ---@param file string - ---@param epoch integer - local function register(file, epoch) - local path = filepath(dir, file) - Path.new(path):touch() - bufnr = bufnr + 1 - buffers[bufnr] = path - frecency:register(bufnr, epoch) - end - callback(register) - vim.api.nvim_buf_get_name = original_nvim_buf_get_name -end - ----@param choice "y"|"n" ----@param callback fun(called: fun(): integer): nil ----@return nil -local function with_fake_vim_ui_select(choice, callback) - local original_vim_ui_select = vim.ui.select - local count = 0 - local function called() - return count - end - ---@diagnostic disable-next-line: duplicate-set-field - vim.ui.select = function(_, opts, on_choice) - count = count + 1 - log.info(opts.prompt) - log.info(opts.format_item(choice)) - on_choice(choice) - end - callback(called) - vim.ui.select = original_vim_ui_select -end +local filepath = util.filepath +local make_epoch = util.make_epoch +local make_register = util.make_register +local with_fake_register = util.with_fake_register +local with_files = util.with_files describe("frecency", function() describe("register", function() @@ -116,7 +18,10 @@ describe("frecency", function() local register = make_register(frecency, dir) local epoch1 = make_epoch "2023-07-29T00:00:00+09:00" local epoch2 = make_epoch "2023-07-29T01:00:00+09:00" + -- HACK: This suspicious 'swapfile' setting is for avoiding E303. + vim.o.swapfile = false register("hoge1.txt", epoch1) + vim.o.swapfile = true register("hoge2.txt", epoch2) it("has valid records in DB", function() @@ -301,208 +206,6 @@ describe("frecency", function() end) end) - describe("validate_database", function() - describe("when no files are unlinked", function() - with_files({ "hoge1.txt", "hoge2.txt" }, function(frecency, finder, dir) - local register = make_register(frecency, dir) - local epoch1 = make_epoch "2023-07-29T00:00:00+09:00" - local epoch2 = make_epoch "2023-07-29T00:01:00+09:00" - register("hoge1.txt", epoch1) - register("hoge2.txt", epoch2) - - it("removes no entries", function() - local results = finder:get_results(nil, make_epoch "2023-07-29T02:00:00+09:00") - assert.are.same({ - { count = 1, path = filepath(dir, "hoge2.txt"), score = 10, timestamps = { epoch2 } }, - { count = 1, path = filepath(dir, "hoge1.txt"), score = 10, timestamps = { epoch1 } }, - }, results) - end) - end) - end) - - describe("when with not force", function() - describe("when files are unlinked but it is less than threshold", function() - with_files( - { "hoge1.txt", "hoge2.txt", "hoge3.txt", "hoge4.txt", "hoge5.txt" }, - { db_validate_threshold = 3 }, - function(frecency, finder, dir) - local register = make_register(frecency, dir) - local epoch1 = make_epoch "2023-07-29T00:00:00+09:00" - local epoch2 = make_epoch "2023-07-29T00:01:00+09:00" - local epoch3 = make_epoch "2023-07-29T00:02:00+09:00" - local epoch4 = make_epoch "2023-07-29T00:03:00+09:00" - local epoch5 = make_epoch "2023-07-29T00:04:00+09:00" - register("hoge1.txt", epoch1) - register("hoge2.txt", epoch2) - register("hoge3.txt", epoch3) - register("hoge4.txt", epoch4) - register("hoge5.txt", epoch5) - dir:joinpath("hoge1.txt"):rm() - dir:joinpath("hoge2.txt"):rm() - frecency:validate_database() - - it("removes no entries", function() - local results = finder:get_results(nil, make_epoch "2023-07-29T02:00:00+09:00") - table.sort(results, function(a, b) - return a.path < b.path - end) - assert.are.same({ - { count = 1, path = filepath(dir, "hoge1.txt"), score = 10, timestamps = { epoch1 } }, - { count = 1, path = filepath(dir, "hoge2.txt"), score = 10, timestamps = { epoch2 } }, - { count = 1, path = filepath(dir, "hoge3.txt"), score = 10, timestamps = { epoch3 } }, - { count = 1, path = filepath(dir, "hoge4.txt"), score = 10, timestamps = { epoch4 } }, - { count = 1, path = filepath(dir, "hoge5.txt"), score = 10, timestamps = { epoch5 } }, - }, results) - end) - end - ) - end) - - describe("when files are unlinked and it is more than threshold", function() - describe('when the user response "yes"', function() - with_files( - { "hoge1.txt", "hoge2.txt", "hoge3.txt", "hoge4.txt", "hoge5.txt" }, - { db_validate_threshold = 3 }, - function(frecency, finder, dir) - local register = make_register(frecency, dir) - local epoch1 = make_epoch "2023-07-29T00:00:00+09:00" - local epoch2 = make_epoch "2023-07-29T00:01:00+09:00" - local epoch3 = make_epoch "2023-07-29T00:02:00+09:00" - local epoch4 = make_epoch "2023-07-29T00:03:00+09:00" - local epoch5 = make_epoch "2023-07-29T00:04:00+09:00" - register("hoge1.txt", epoch1) - register("hoge2.txt", epoch2) - register("hoge3.txt", epoch3) - register("hoge4.txt", epoch4) - register("hoge5.txt", epoch5) - dir:joinpath("hoge1.txt"):rm() - dir:joinpath("hoge2.txt"):rm() - dir:joinpath("hoge3.txt"):rm() - - with_fake_vim_ui_select("y", function(called) - frecency:validate_database() - - it("called vim.ui.select()", function() - assert.are.same(1, called()) - end) - end) - - it("removes entries", function() - local results = finder:get_results(nil, make_epoch "2023-07-29T02:00:00+09:00") - table.sort(results, function(a, b) - return a.path < b.path - end) - assert.are.same({ - { count = 1, path = filepath(dir, "hoge4.txt"), score = 10, timestamps = { epoch4 } }, - { count = 1, path = filepath(dir, "hoge5.txt"), score = 10, timestamps = { epoch5 } }, - }, results) - end) - end - ) - end) - - describe('when the user response "no"', function() - with_files( - { "hoge1.txt", "hoge2.txt", "hoge3.txt", "hoge4.txt", "hoge5.txt" }, - { db_validate_threshold = 3 }, - function(frecency, finder, dir) - local register = make_register(frecency, dir) - local epoch1 = make_epoch "2023-07-29T00:00:00+09:00" - local epoch2 = make_epoch "2023-07-29T00:01:00+09:00" - local epoch3 = make_epoch "2023-07-29T00:02:00+09:00" - local epoch4 = make_epoch "2023-07-29T00:03:00+09:00" - local epoch5 = make_epoch "2023-07-29T00:04:00+09:00" - register("hoge1.txt", epoch1) - register("hoge2.txt", epoch2) - register("hoge3.txt", epoch3) - register("hoge4.txt", epoch4) - register("hoge5.txt", epoch5) - dir:joinpath("hoge1.txt"):rm() - dir:joinpath("hoge2.txt"):rm() - dir:joinpath("hoge3.txt"):rm() - - with_fake_vim_ui_select("n", function(called) - frecency:validate_database() - - it("called vim.ui.select()", function() - assert.are.same(1, called()) - end) - end) - - it("removes no entries", function() - local results = finder:get_results(nil, make_epoch "2023-07-29T02:00:00+09:00") - table.sort(results, function(a, b) - return a.path < b.path - end) - assert.are.same({ - { count = 1, path = filepath(dir, "hoge1.txt"), score = 10, timestamps = { epoch1 } }, - { count = 1, path = filepath(dir, "hoge2.txt"), score = 10, timestamps = { epoch2 } }, - { count = 1, path = filepath(dir, "hoge3.txt"), score = 10, timestamps = { epoch3 } }, - { count = 1, path = filepath(dir, "hoge4.txt"), score = 10, timestamps = { epoch4 } }, - { count = 1, path = filepath(dir, "hoge5.txt"), score = 10, timestamps = { epoch5 } }, - }, results) - end) - end - ) - end) - end) - end) - - describe("when with force", function() - describe("when db_safe_mode is true", function() - with_files({ "hoge1.txt", "hoge2.txt" }, function(frecency, finder, dir) - local register = make_register(frecency, dir) - local epoch1 = make_epoch "2023-07-29T00:00:00+09:00" - local epoch2 = make_epoch "2023-07-29T00:01:00+09:00" - register("hoge1.txt", epoch1) - register("hoge2.txt", epoch2) - dir:joinpath("hoge1.txt"):rm() - - with_fake_vim_ui_select("y", function(called) - frecency:validate_database(true) - - it("called vim.ui.select()", function() - assert.are.same(1, called()) - end) - end) - - it("needs confirmation for removing entries", function() - local results = finder:get_results(nil, make_epoch "2023-07-29T02:00:00+09:00") - assert.are.same({ - { count = 1, path = filepath(dir, "hoge2.txt"), score = 10, timestamps = { epoch2 } }, - }, results) - end) - end) - end) - - describe("when db_safe_mode is false", function() - with_files({ "hoge1.txt", "hoge2.txt" }, { db_safe_mode = false }, function(frecency, finder, dir) - local register = make_register(frecency, dir) - local epoch1 = make_epoch "2023-07-29T00:00:00+09:00" - local epoch2 = make_epoch "2023-07-29T00:01:00+09:00" - register("hoge1.txt", epoch1) - register("hoge2.txt", epoch2) - dir:joinpath("hoge1.txt"):rm() - - with_fake_vim_ui_select("y", function(called) - frecency:validate_database(true) - - it("did not call vim.ui.select()", function() - assert.are.same(0, called()) - end) - end) - - it("needs no confirmation for removing entries", function() - local results = finder:get_results(nil, make_epoch "2023-07-29T02:00:00+09:00") - assert.are.same({ - { count = 1, path = filepath(dir, "hoge2.txt"), score = 10, timestamps = { epoch2 } }, - }, results) - end) - end) - end) - end) - end) - describe("delete", function() describe("when file exists", function() with_files({ "hoge1.txt", "hoge2.txt" }, function(frecency, finder, dir) @@ -515,8 +218,9 @@ describe("frecency", function() it("deletes the file successfully", function() local path = filepath(dir, "hoge2.txt") local result - ---@diagnostic disable-next-line: duplicate-set-field + ---@diagnostic disable-next-line: duplicate-set-field, invisible frecency.notify = function(self, fmt, ...) + ---@diagnostic disable-next-line: invisible vim.notify(self:message(fmt, ...)) result = true end diff --git a/lua/frecency/tests/frecency_validate_database_spec.lua b/lua/frecency/tests/frecency_validate_database_spec.lua new file mode 100644 index 0000000..c1129de --- /dev/null +++ b/lua/frecency/tests/frecency_validate_database_spec.lua @@ -0,0 +1,258 @@ +local util = require "frecency.tests.util" +local async = require "plenary.async" + +local filepath = util.filepath +local make_epoch = util.make_epoch +local make_register = util.make_register +local with_fake_vim_ui_select = util.with_fake_vim_ui_select +local with_files = util.with_files + +-- HACK: avoid error: +-- E5560: nvim_echo must not be called in a lua loop callback +vim.notify = function(_, _) end + +describe("frecency", function() + describe("validate_database", function() + describe("when no files are unlinked", function() + with_files({ "hoge1.txt", "hoge2.txt" }, function(frecency, finder, dir) + local register = make_register(frecency, dir) + local epoch1 = make_epoch "2023-07-29T00:00:00+09:00" + local epoch2 = make_epoch "2023-07-29T00:01:00+09:00" + register("hoge1.txt", epoch1) + register("hoge2.txt", epoch2) + + it("removes no entries", function() + local results = finder:get_results(nil, make_epoch "2023-07-29T02:00:00+09:00") + assert.are.same({ + { count = 1, path = filepath(dir, "hoge2.txt"), score = 10, timestamps = { epoch2 } }, + { count = 1, path = filepath(dir, "hoge1.txt"), score = 10, timestamps = { epoch1 } }, + }, results) + end) + end) + end) + + describe("when with not force", function() + describe("when files are unlinked but it is less than threshold", function() + with_files( + { "hoge1.txt", "hoge2.txt", "hoge3.txt", "hoge4.txt", "hoge5.txt" }, + { db_validate_threshold = 3 }, + function(frecency, finder, dir) + local register = make_register(frecency, dir) + local epoch1 = make_epoch "2023-07-29T00:00:00+09:00" + local epoch2 = make_epoch "2023-07-29T00:01:00+09:00" + local epoch3 = make_epoch "2023-07-29T00:02:00+09:00" + local epoch4 = make_epoch "2023-07-29T00:03:00+09:00" + local epoch5 = make_epoch "2023-07-29T00:04:00+09:00" + register("hoge1.txt", epoch1) + register("hoge2.txt", epoch2) + register("hoge3.txt", epoch3) + register("hoge4.txt", epoch4) + register("hoge5.txt", epoch5) + dir:joinpath("hoge1.txt"):rm() + dir:joinpath("hoge2.txt"):rm() + async.util.block_on(function() + frecency:validate_database() + end) + + it("removes no entries", function() + local results = finder:get_results(nil, make_epoch "2023-07-29T02:00:00+09:00") + table.sort(results, function(a, b) + return a.path < b.path + end) + assert.are.same({ + { count = 1, path = filepath(dir, "hoge1.txt"), score = 10, timestamps = { epoch1 } }, + { count = 1, path = filepath(dir, "hoge2.txt"), score = 10, timestamps = { epoch2 } }, + { count = 1, path = filepath(dir, "hoge3.txt"), score = 10, timestamps = { epoch3 } }, + { count = 1, path = filepath(dir, "hoge4.txt"), score = 10, timestamps = { epoch4 } }, + { count = 1, path = filepath(dir, "hoge5.txt"), score = 10, timestamps = { epoch5 } }, + }, results) + end) + end + ) + end) + + describe("when files are unlinked and it is more than threshold", function() + describe('when the user response "yes"', function() + with_files( + { "hoge1.txt", "hoge2.txt", "hoge3.txt", "hoge4.txt", "hoge5.txt" }, + { db_validate_threshold = 3 }, + function(frecency, finder, dir) + local register = make_register(frecency, dir) + local epoch1 = make_epoch "2023-07-29T00:00:00+09:00" + local epoch2 = make_epoch "2023-07-29T00:01:00+09:00" + local epoch3 = make_epoch "2023-07-29T00:02:00+09:00" + local epoch4 = make_epoch "2023-07-29T00:03:00+09:00" + local epoch5 = make_epoch "2023-07-29T00:04:00+09:00" + register("hoge1.txt", epoch1) + register("hoge2.txt", epoch2) + register("hoge3.txt", epoch3) + register("hoge4.txt", epoch4) + register("hoge5.txt", epoch5) + dir:joinpath("hoge1.txt"):rm() + dir:joinpath("hoge2.txt"):rm() + dir:joinpath("hoge3.txt"):rm() + + with_fake_vim_ui_select("y", function(called) + async.util.block_on(function() + frecency:validate_database() + end) + + it("called vim.ui.select()", function() + assert.are.same(1, called()) + end) + end) + + it("removes entries", function() + local results = finder:get_results(nil, make_epoch "2023-07-29T02:00:00+09:00") + table.sort(results, function(a, b) + return a.path < b.path + end) + assert.are.same({ + { count = 1, path = filepath(dir, "hoge4.txt"), score = 10, timestamps = { epoch4 } }, + { count = 1, path = filepath(dir, "hoge5.txt"), score = 10, timestamps = { epoch5 } }, + }, results) + end) + end + ) + end) + + describe('when the user response "no"', function() + with_files( + { "hoge1.txt", "hoge2.txt", "hoge3.txt", "hoge4.txt", "hoge5.txt" }, + { db_validate_threshold = 3 }, + function(frecency, finder, dir) + local register = make_register(frecency, dir) + local epoch1 = make_epoch "2023-07-29T00:00:00+09:00" + local epoch2 = make_epoch "2023-07-29T00:01:00+09:00" + local epoch3 = make_epoch "2023-07-29T00:02:00+09:00" + local epoch4 = make_epoch "2023-07-29T00:03:00+09:00" + local epoch5 = make_epoch "2023-07-29T00:04:00+09:00" + register("hoge1.txt", epoch1) + register("hoge2.txt", epoch2) + register("hoge3.txt", epoch3) + register("hoge4.txt", epoch4) + register("hoge5.txt", epoch5) + dir:joinpath("hoge1.txt"):rm() + dir:joinpath("hoge2.txt"):rm() + dir:joinpath("hoge3.txt"):rm() + + with_fake_vim_ui_select("n", function(called) + async.util.block_on(function() + frecency:validate_database() + end) + + it("called vim.ui.select()", function() + assert.are.same(1, called()) + end) + end) + + it("removes no entries", function() + local results = finder:get_results(nil, make_epoch "2023-07-29T02:00:00+09:00") + table.sort(results, function(a, b) + return a.path < b.path + end) + assert.are.same({ + { count = 1, path = filepath(dir, "hoge1.txt"), score = 10, timestamps = { epoch1 } }, + { count = 1, path = filepath(dir, "hoge2.txt"), score = 10, timestamps = { epoch2 } }, + { count = 1, path = filepath(dir, "hoge3.txt"), score = 10, timestamps = { epoch3 } }, + { count = 1, path = filepath(dir, "hoge4.txt"), score = 10, timestamps = { epoch4 } }, + { count = 1, path = filepath(dir, "hoge5.txt"), score = 10, timestamps = { epoch5 } }, + }, results) + end) + end + ) + end) + end) + end) + + describe("when with force", function() + describe("when db_safe_mode is true", function() + with_files({ "hoge1.txt", "hoge2.txt" }, function(frecency, finder, dir) + local register = make_register(frecency, dir) + local epoch1 = make_epoch "2023-07-29T00:00:00+09:00" + local epoch2 = make_epoch "2023-07-29T00:01:00+09:00" + register("hoge1.txt", epoch1) + register("hoge2.txt", epoch2) + dir:joinpath("hoge1.txt"):rm() + + with_fake_vim_ui_select("y", function(called) + async.util.block_on(function() + frecency:validate_database(true) + end) + + it("called vim.ui.select()", function() + assert.are.same(1, called()) + end) + end) + + it("needs confirmation for removing entries", function() + local results = finder:get_results(nil, make_epoch "2023-07-29T02:00:00+09:00") + assert.are.same({ + { count = 1, path = filepath(dir, "hoge2.txt"), score = 10, timestamps = { epoch2 } }, + }, results) + end) + end) + end) + + describe("when db_safe_mode is false", function() + with_files({ "hoge1.txt", "hoge2.txt" }, { db_safe_mode = false }, function(frecency, finder, dir) + local register = make_register(frecency, dir) + local epoch1 = make_epoch "2023-07-29T00:00:00+09:00" + local epoch2 = make_epoch "2023-07-29T00:01:00+09:00" + register("hoge1.txt", epoch1) + register("hoge2.txt", epoch2) + dir:joinpath("hoge1.txt"):rm() + + with_fake_vim_ui_select("y", function(called) + async.util.block_on(function() + frecency:validate_database(true) + end) + + it("did not call vim.ui.select()", function() + assert.are.same(0, called()) + end) + end) + + it("needs no confirmation for removing entries", function() + local results = finder:get_results(nil, make_epoch "2023-07-29T02:00:00+09:00") + assert.are.same({ + { count = 1, path = filepath(dir, "hoge2.txt"), score = 10, timestamps = { epoch2 } }, + }, results) + end) + end) + end) + end) + + describe("when case sensive filename", function() + with_files({ "hoge1.txt", "hoge2.txt" }, function(frecency, finder, dir) + local register = make_register(frecency, dir) + local epoch1 = make_epoch "2023-07-29T00:00:00+09:00" + local epoch2 = make_epoch "2023-07-29T00:01:00+09:00" + local epoch3 = make_epoch "2023-07-29T00:02:00+09:00" + register("hoge1.txt", epoch1) + register("hoge2.txt", epoch2, nil, true) + dir:joinpath("hoge1.txt"):rm() + dir:joinpath("hoge2.txt"):rename { new_name = dir:joinpath("_hoge2.txt").filename } + dir:joinpath("_hoge2.txt"):rename { new_name = dir:joinpath("Hoge2.txt").filename } + register("Hoge2.txt", epoch3) + + with_fake_vim_ui_select("y", function(called) + async.util.block_on(function() + frecency:validate_database(true) + end) + + it("calls vim.ui.select()", function() + assert.are.same(1, called()) + end) + end) + + it("removes duplicated case sensitive filenames", function() + local results = finder:get_results(nil, make_epoch "2023-07-29T03:00:00+09:00") + assert.are.same({ + { count = 1, path = filepath(dir, "Hoge2.txt"), score = 10, timestamps = { epoch3 } }, + }, results) + end) + end) + end) + end) +end) diff --git a/lua/frecency/tests/util.lua b/lua/frecency/tests/util.lua index b7c230e..933b396 100644 --- a/lua/frecency/tests/util.lua +++ b/lua/frecency/tests/util.lua @@ -1,4 +1,9 @@ +---@diagnostic disable: invisible, undefined-field +local Frecency = require "frecency.klass" +local Picker = require "frecency.picker" +local config = require "frecency.config" local uv = vim.uv or vim.loop +local log = require "plenary.log" local async = require "plenary.async" --[[@as FrecencyPlenaryAsync]] local Path = require "plenary.path" local Job = require "plenary.job" @@ -7,7 +12,18 @@ local wait = require "frecency.tests.wait" ---@return FrecencyPlenaryPath ---@return fun(): nil close swwp all entries local function tmpdir() - local dir = Path:new(Path:new(assert(uv.fs_mkdtemp "tests_XXXXXX")):absolute()) + local ci = uv.os_getenv "CI" + local dir + if ci then + dir = Path:new(assert(uv.fs_mkdtemp "tests_XXXXXX")) + else + local tmp = assert(uv.os_tmpdir()) + -- HACK: plenary.path resolves paths later, so here it resolves in advance. + if uv.os_uname().sysname == "Darwin" then + tmp = tmp:gsub("^/var", "/private/var") + end + dir = Path:new(assert(uv.fs_mkdtemp(Path:new(tmp, "tests_XXXXXX").filename))) + end return dir, function() dir:rm { recursive = true } end @@ -37,6 +53,8 @@ local AsyncJob = async.wrap(function(cmd, callback) end, 2) -- NOTE: vim.fn.strptime cannot be used in Lua loop +---@param iso8601 string +---@return integer? local function time_piece(iso8601) local epoch wait(function() @@ -47,9 +65,130 @@ local function time_piece(iso8601) return epoch end +---@param datetime string? +---@return integer +local function make_epoch(datetime) + if not datetime then + return os.time() + end + local tz_fix = datetime:gsub("+(%d%d):(%d%d)$", "+%1%2") + return time_piece(tz_fix) or 0 +end + ---@param records table local function v1_table(records) return { version = "v1", records = records } end -return { make_tree = make_tree, tmpdir = tmpdir, v1_table = v1_table, time_piece = time_piece } +---@param files string[] +---@param cb_or_config table|fun(frecency: Frecency, finder: FrecencyFinder, dir: FrecencyPlenaryPath): nil +---@param callback? fun(frecency: Frecency, finder: FrecencyFinder, dir: FrecencyPlenaryPath): nil +---@return nil +local function with_files(files, cb_or_config, callback) + local dir, close = make_tree(files) + local cfg + if type(cb_or_config) == "table" then + cfg = vim.tbl_extend("force", { debug = true, db_root = dir.filename }, cb_or_config) + else + cfg = { debug = true, db_root = dir.filename } + callback = cb_or_config + end + assert(callback) + log.debug(cfg) + config.setup(cfg) + local frecency = Frecency.new() + async.util.block_on(function() + frecency.database:start() + frecency.database.tbl:wait_ready() + end) + frecency.picker = Picker.new(frecency.database, frecency.entry_maker, frecency.recency, { editing_bufnr = 0 }) + local finder = frecency.picker:finder {} + callback(frecency, finder, dir) + close() +end + +local function filepath(dir, file) + return dir:joinpath(file):absolute() +end + +---@param frecency Frecency +---@param dir FrecencyPlenaryPath +---@return fun(file: string, epoch: integer, reset: boolean?, wipeout?: boolean): nil reset: boolean?): nil +local function make_register(frecency, dir) + return function(file, epoch, reset, wipeout) + local path = filepath(dir, file) + vim.cmd.edit(path) + local bufnr = assert(vim.fn.bufnr(path)) + if reset then + frecency.buf_registered[bufnr] = nil + end + frecency:register(bufnr, epoch) + vim.wait(1000, function() + return not not frecency.buf_registered[bufnr] + end) + -- HACK: This is needed because almost the same filenames use the same + -- buffer. + if wipeout then + vim.cmd.bwipeout() + end + end +end + +---@param frecency Frecency +---@param dir FrecencyPlenaryPath +---@param callback fun(register: fun(file: string, epoch?: integer): nil): nil +---@return nil +local function with_fake_register(frecency, dir, callback) + local bufnr = 0 + local buffers = {} + local original_nvim_buf_get_name = vim.api.nvim_buf_get_name + ---@diagnostic disable-next-line: redefined-local, duplicate-set-field + vim.api.nvim_buf_get_name = function(bufnr) + return buffers[bufnr] + end + ---@param file string + ---@param epoch integer + local function register(file, epoch) + local path = filepath(dir, file) + Path.new(path):touch() + bufnr = bufnr + 1 + buffers[bufnr] = path + async.util.block_on(function() + frecency:register(bufnr, epoch) + end) + end + callback(register) + vim.api.nvim_buf_get_name = original_nvim_buf_get_name +end + +---@param choice "y"|"n" +---@param callback fun(called: fun(): integer): nil +---@return nil +local function with_fake_vim_ui_select(choice, callback) + local original_vim_ui_select = vim.ui.select + local count = 0 + local function called() + return count + end + ---@diagnostic disable-next-line: duplicate-set-field + vim.ui.select = function(_, opts, on_choice) + count = count + 1 + log.info(opts.prompt) + log.info(opts.format_item(choice)) + on_choice(choice) + end + callback(called) + vim.ui.select = original_vim_ui_select +end + +return { + filepath = filepath, + make_epoch = make_epoch, + make_register = make_register, + make_tree = make_tree, + tmpdir = tmpdir, + v1_table = v1_table, + with_fake_register = with_fake_register, + with_fake_vim_ui_select = with_fake_vim_ui_select, + with_files = with_files, +} diff --git a/lua/frecency/types.lua b/lua/frecency/types.lua index 5154100..88b9cb2 100644 --- a/lua/frecency/types.lua +++ b/lua/frecency/types.lua @@ -12,6 +12,7 @@ ---@field make_relative fun(self: FrecencyPlenaryPath, cwd: string): string ---@field parent fun(self: FrecencyPlenaryPath): FrecencyPlenaryPath ---@field path { sep: string } +---@field rename fun(self: FrecencyPlenaryPath, opts: { new_name: string }): nil ---@field rm fun(self: FrecencyPlenaryPath, opts?: { recursive: boolean }): nil ---@field touch fun(self: FrecencyPlenaryPath, opts?: { parents: boolean }): nil @@ -47,6 +48,11 @@ function FrecencyPlenaryAsync.run(f) end ---@class FrecencyPlenaryAsyncControlChannel ---@field mpsc fun(): FrecencyPlenaryAsyncControlChannelTx, FrecencyPlenaryAsyncControlChannelRx ---@field counter fun(): FrecencyPlenaryAsyncControlChannelTx, FrecencyPlenaryAsyncControlChannelRx +local FrecencyPlenaryAsyncControlChannel + +---@return fun(...): nil tx +---@return async fun(): ... rx +function FrecencyPlenaryAsyncControlChannel.oneshot() end ---@class FrecencyPlenaryAsyncControlChannelTx ---@field send fun(entry?: any): nil @@ -92,6 +98,12 @@ function FrecencyPlenaryAsyncUv.fs_stat(path) end ---@return integer fd function FrecencyPlenaryAsyncUv.fs_open(path, flags, mode) end +---@async +---@param path string +---@return string? err +---@return string? path +function FrecencyPlenaryAsyncUv.fs_realpath(path) end + ---@async ---@param fd integer ---@param size integer @@ -119,6 +131,11 @@ function FrecencyPlenaryAsyncUv.fs_unlink(path) end ---@return string? err function FrecencyPlenaryAsyncUv.fs_close(fd) end +---@async +---@param async_fns (async fun(...): ...)[] +---@return table +function FrecencyPlenaryAsyncUtil.join(async_fns) end + ---@async ---@param ms integer ---@return nil