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
This commit is contained in:
JINNOUCHI Yasushi 2024-08-15 17:40:03 +09:00 committed by GitHub
parent 39f70a87a2
commit 58c0089414
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 710 additions and 499 deletions

View File

@ -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*

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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<string,boolean>
---@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

View File

@ -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

View File

@ -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

View File

@ -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<string,string>
@ -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

View File

@ -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", {})

View File

@ -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<integer, boolean> 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)

View File

@ -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)

View File

@ -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)

View File

@ -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)
)

View File

@ -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

View File

@ -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)

View File

@ -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<string, FrecencyDatabaseRecordValue>
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,
}

View File

@ -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