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

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

* test: separate logic for utils

* fix!: register realpath for consistency

* refactor: convert fs module from class

* refactor: move db initialization phase to start()

* fix: run database:start() truly asynchronously

* fix: call each functions with async wrapping

* refactor: add types for args in command

* fix: run register() synchronously

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

* docs: add note for calling setup() twice

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

254 lines
6.6 KiB
Lua

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]]
local Path = require "plenary.path" --[[@as FrecencyPlenaryPath]]
---@class FrecencyDatabaseEntry
---@field ages number[]
---@field count integer
---@field path string
---@field score number
---@field timestamps integer[]
---@alias FrecencyDatabaseVersion "v1"
---@class FrecencyDatabase
---@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 FrecencyDatabaseVersion
---@field private watcher_rx FrecencyPlenaryAsyncControlChannelRx
---@field private watcher_tx FrecencyPlenaryAsyncControlChannelTx
local Database = {}
---@return FrecencyDatabase
Database.new = function()
local version = "v1"
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 })
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 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
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 = self.watcher_rx.recv()
log.debug("DB coroutine start:", mode)
if mode == "load" then
self:load()
elseif mode == "save" then
self:save()
else
log.error("unknown mode: " .. mode)
end
log.debug("DB coroutine end:", mode)
end
end)()
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)
if #paths == 0 then
return
end
for _, path in ipairs(paths) do
self.tbl.records[path] = { count = 1, timestamps = { 0 } }
end
self.watcher_tx.send "save"
end
---@async
---@return string[]
function Database:unlinked_entries()
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, 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.watcher_tx.send "save"
end
---@async
---@param path string
---@param epoch? integer
function Database:update(path, epoch)
local record = self.tbl.records[path] or { count = 0, timestamps = {} }
record.count = record.count + 1
local now = epoch or os.time()
table.insert(record.timestamps, now)
if #record.timestamps > config.max_timestamps then
local new_table = {}
for i = #record.timestamps - config.max_timestamps + 1, #record.timestamps do
table.insert(new_table, record.timestamps[i])
end
record.timestamps = new_table
end
self.tbl.records[path] = record
self.watcher_tx.send "save"
end
---@async
---@param workspace? string
---@param epoch? integer
---@return FrecencyDatabaseEntry[]
function Database:get_entries(workspace, epoch)
local now = epoch or os.time()
local items = {}
for path, record in pairs(self.tbl.records) do
if fs.starts_with(path, workspace) then
table.insert(items, {
path = path,
count = record.count,
ages = vim.tbl_map(function(v)
return (now - v) / 60
end, record.timestamps),
timestamps = record.timestamps,
})
end
end
return items
end
---@async
---@return nil
function Database:load()
local start = os.clock()
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(target, "r", tonumber("644", 8))
assert(not err, err)
local data
err, data = async.uv.fs_read(fd, stat.size)
assert(not err, err)
assert(not async.uv.fs_close(fd))
watcher.update(stat)
return data
end)
assert(not err, err)
local tbl = vim.F.npcall(loadstring(data or ""))
self.tbl:set(tbl)
log.debug(("load() takes %f seconds"):format(os.clock() - start))
end
---@async
---@return nil
function Database:save()
local start = os.clock()
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
end)
assert(not err, err)
log.debug(("save() takes %f seconds"):format(os.clock() - start))
end
---@async
---@param target string
---@param tbl FrecencyDatabaseRawTable
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(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)
if not self.tbl.records[path] then
return false
end
self.tbl.records[path] = nil
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