From 58c0089414299c173a64ca64754f579460cad511 Mon Sep 17 00:00:00 2001 From: JINNOUCHI Yasushi Date: Thu, 15 Aug 2024 17:40:03 +0900 Subject: [PATCH] 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 --- doc/telescope-frecency.txt | 10 +- lua/frecency/config.lua | 14 + lua/frecency/database.lua | 134 +++++--- lua/frecency/database/table.lua | 18 +- lua/frecency/entry_maker.lua | 11 +- lua/frecency/file_lock.lua | 33 +- lua/frecency/finder.lua | 47 +-- lua/frecency/fs.lua | 75 ++--- lua/frecency/init.lua | 23 +- lua/frecency/klass.lua | 75 +++-- lua/frecency/picker.lua | 13 +- lua/frecency/tests/database_spec.lua | 18 +- lua/frecency/tests/file_lock_spec.lua | 4 +- lua/frecency/tests/frecency_spec.lua | 316 +----------------- .../tests/frecency_validate_database_spec.lua | 258 ++++++++++++++ lua/frecency/tests/util.lua | 143 +++++++- lua/frecency/types.lua | 17 + 17 files changed, 710 insertions(+), 499 deletions(-) create mode 100644 lua/frecency/tests/frecency_validate_database_spec.lua 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