feat: check DB file has been changed (#143)

* refactor: unite logic for finder & async_finder

* chore: fix types

* chore: add sleep to show results at first

* refactor: fix to find results separatedly

* test: remove unnecessary ones and fix others

* test: add matrix for 0.9.x & Windows

* test: use forked plenary.log for Windows

* test: fix to use strptime in Windows

* test: run again if segmentation fault in Windows

* test: loosen timeout for Perl

* test: use the latest plenary.nvim again

* chore: fix types

* chore: change variable name

* feat: watch changes of DB to reload

* chore: add comments to steps

* test: copy whole modules for testing in Windows

* fix: make valid paths for Windows

* test: add tests for Native

* test: use robust way to calculate time

vim.fn.strptime cannot be used in Lua loop

* chore: fix comments

* refactor: simplify the code

* test: loosen condition to detect failures

* test: disable some logging

Many loggings make the test fail.

* test: run tests sequentially in Windows

* test: loosen timeout not to fail on Windows
This commit is contained in:
JINNOUCHI Yasushi 2023-09-17 15:21:01 +09:00 committed by GitHub
parent fbda5d91d6
commit 767fbf074f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 604 additions and 454 deletions

View File

@ -9,11 +9,11 @@ jobs:
matrix: matrix:
os: os:
- ubuntu-latest - ubuntu-latest
# TODO: nix seems not to work with SIP - macos-latest
# - macos-latest - windows-latest
# TODO: PlenaryBustedDirectory seems not to run on Windows
# - windows-latest
version: version:
- v0.9.2
- v0.9.1
- v0.9.0 - v0.9.0
- nightly - nightly
runs-on: ${{ matrix.os }} runs-on: ${{ matrix.os }}
@ -41,7 +41,7 @@ jobs:
with: with:
neovim: true neovim: true
version: ${{ matrix.version }} version: ${{ matrix.version }}
- name: Run tests - name: Run tests (not for Windows)
env: env:
PLENARY_PATH: plenary.nvim PLENARY_PATH: plenary.nvim
TELESCOPE_PATH: telescope.nvim TELESCOPE_PATH: telescope.nvim
@ -53,8 +53,29 @@ jobs:
MINIMAL_LUA=${TEST_DIR}minimal.lua MINIMAL_LUA=${TEST_DIR}minimal.lua
NVIM=$(perl -e '$_ = $ENV{EXE}; s,\\,/,g; print') NVIM=$(perl -e '$_ = $ENV{EXE}; s,\\,/,g; print')
$NVIM --headless --clean -u $MINIMAL_LUA -c "PlenaryBustedDirectory $TEST_DIR {minimal_init = '$MINIMAL_LUA'}" $NVIM --headless --clean -u $MINIMAL_LUA -c "PlenaryBustedDirectory $TEST_DIR {minimal_init = '$MINIMAL_LUA'}"
if: matrix.os != 'windows-latest'
- name: Run tests (for Windows)
shell: bash
env:
PLENARY_PATH: plenary.nvim
TELESCOPE_PATH: telescope.nvim
SQLITE_PATH: sqlite.lua
DEBUG_PLENARY: 1
EXE: ${{ steps.nvim.outputs.executable }}
run: |-
# HACK: This is needed because it fails to add runtimepath's.
cp -af $PLENARY_PATH/lua/plenary/ lua/
cp -af $TELESCOPE_PATH/lua/telescope/ lua/
cp -af $SQLITE_PATH/lua/sqlite/ lua/
TEST_DIR=lua/frecency/tests/
MINIMAL_LUA=${TEST_DIR}minimal.lua
NVIM=$(perl -e '$_ = $ENV{EXE}; s,\\,/,g; print')
$NVIM --headless --clean -u $MINIMAL_LUA -c "PlenaryBustedDirectory $TEST_DIR {minimal_init = '$MINIMAL_LUA', timeout = 180000, sequential = true}"
if: matrix.os == 'windows-latest'
- name: Type Check Code Base - name: Type Check Code Base
uses: mrcjkb/lua-typecheck-action@v0.2.0 uses: mrcjkb/lua-typecheck-action@v0.2.0
with: with:
checkLevel: Hint checkLevel: Hint
configpath: .luarc.json configpath: .luarc.json
# NOTE: This step needs nix that seems not to work with SIP (macOS)
if: matrix.os == 'ubuntu-latest'

View File

@ -1,123 +0,0 @@
local async = require "plenary.async" --[[@as PlenaryAsync]]
---@class FrecencyAsyncFinder
---@field closed boolean
---@field entries FrecencyEntry[]
---@field reflowed boolean
---@field rx PlenaryAsyncControlChannelRx
---@field state FrecencyState
---@overload fun(_: string, process_result: (fun(entry: FrecencyEntry): nil), process_complete: fun(): nil): nil
local AsyncFinder = {}
---@param fs FrecencyFS
---@param state FrecencyState
---@param path string
---@param entry_maker fun(file: FrecencyFile): FrecencyEntry
---@param initial_results FrecencyFile[]
---@return FrecencyAsyncFinder
AsyncFinder.new = function(state, fs, path, entry_maker, initial_results)
local self = setmetatable({ closed = false, entries = {}, reflowed = false, state = state }, {
__index = AsyncFinder,
---@param self FrecencyAsyncFinder
__call = function(self, ...)
return self:find(...)
end,
})
local seen = {}
for i, file in ipairs(initial_results) do
local entry = entry_maker(file)
seen[entry.filename] = true
entry.index = i
table.insert(self.entries, entry)
end
local tx, rx = async.control.channel.mpsc()
self.rx = rx
async.run(function()
local index = #initial_results
local count = 0
for name in fs:scan_dir(path) do
if self.closed then
break
end
local fullpath = fs.joinpath(path, name)
if not seen[fullpath] then
seen[fullpath] = true
index = index + 1
count = count + 1
local entry = entry_maker { id = 0, count = 0, path = fs.joinpath(path, name), score = 0 }
if entry then
entry.index = index
table.insert(self.entries, entry)
tx.send(entry)
if count % 1000 == 0 then
self:reflow_results()
-- NOTE: This is needed not to lock text input.
async.util.sleep(50)
end
end
end
end
self:close()
tx.send(nil)
end)
return self
end
---@param _ string
---@param process_result fun(entry: FrecencyEntry): nil
---@param process_complete fun(): nil
---@return nil
function AsyncFinder:find(_, process_result, process_complete)
for _, entry in ipairs(self.entries) do
if process_result(entry) then
return
end
end
local last_index = self.entries[#self.entries].index
while true do
if self.closed then
break
end
local entry = self.rx.recv()
if not entry then
break
elseif entry.index > last_index and process_result(entry) then
return
end
end
process_complete()
end
function AsyncFinder:close()
self.closed = true
end
---@return nil
function AsyncFinder:reflow_results()
local picker = self.state:get()
if not picker then
return
end
local bufnr = picker.results_bufnr
local win = picker.results_win
if not bufnr or not win then
return
end
picker:clear_extra_rows(bufnr)
if picker.sorting_strategy == "descending" then
local manager = picker.manager
if not manager then
return
end
local worst_line = picker:get_row(manager:num_results())
---@type WinInfo
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
return AsyncFinder

View File

@ -1,7 +1,9 @@
local FileLock = require "frecency.file_lock" local FileLock = require "frecency.file_lock"
local wait = require "frecency.wait" local wait = require "frecency.wait"
local watcher = require "frecency.database.native.watcher"
local log = require "plenary.log" local log = require "plenary.log"
local async = require "plenary.async" --[[@as PlenaryAsync]] local async = require "plenary.async" --[[@as PlenaryAsync]]
local Path = require "plenary.path" --[[@as PlenaryPath]]
---@class FrecencyDatabaseNative: FrecencyDatabase ---@class FrecencyDatabaseNative: FrecencyDatabase
---@field version "v1" ---@field version "v1"
@ -29,11 +31,20 @@ Native.new = function(fs, config)
table = { version = version, records = {} }, table = { version = version, records = {} },
version = version, version = version,
}, { __index = Native }) }, { __index = Native })
self.filename = self.config.root .. "/file_frecency.bin" self.filename = Path.new(self.config.root, "file_frecency.bin").filename
self.file_lock = FileLock.new(self.filename) self.file_lock = FileLock.new(self.filename)
local tx, rx = async.control.channel.counter()
watcher.watch(self.filename, tx)
wait(function() wait(function()
self:load() self:load()
end) end)
async.void(function()
while true do
rx.last()
log.debug "file changed. loading..."
self:load()
end
end)()
return self return self
end end
@ -102,8 +113,6 @@ end
---@param datetime string? ---@param datetime string?
---@return FrecencyDatabaseEntry[] ---@return FrecencyDatabaseEntry[]
function Native:get_entries(workspace, datetime) function Native:get_entries(workspace, datetime)
-- TODO: check mtime of DB and reload it
-- self:load()
local now = self:now(datetime) local now = self:now(datetime)
local items = {} local items = {}
for path, record in pairs(self.table.records) do for path, record in pairs(self.table.records) do
@ -120,11 +129,21 @@ function Native:get_entries(workspace, datetime)
return items return items
end end
-- TODO: remove this func
-- This is a func for testing
---@private ---@private
---@param datetime string? ---@param datetime string?
---@return integer ---@return integer
function Native:now(datetime) function Native:now(datetime)
return datetime and vim.fn.strptime("%FT%T%z", datetime) or os.time() if not datetime then
return os.time()
end
local epoch
wait(function()
local tz_fix = datetime:gsub("+(%d%d):(%d%d)$", "+%1%2")
epoch = require("frecency.tests.util").time_piece(tz_fix)
end)
return epoch
end end
---@async ---@async
@ -132,17 +151,18 @@ end
function Native:load() function Native:load()
local start = os.clock() local start = os.clock()
local err, data = self.file_lock:with(function() local err, data = self.file_lock:with(function()
local err, st = async.uv.fs_stat(self.filename) local err, stat = async.uv.fs_stat(self.filename)
if err then if err then
return nil return nil
end end
local fd local fd
err, fd = async.uv.fs_open(self.filename, "r", tonumber("644", 8)) err, fd = async.uv.fs_open(self.filename, "r", tonumber("644", 8))
assert(not err) assert(not err, err)
local data local data
err, data = async.uv.fs_read(fd, st.size) err, data = async.uv.fs_read(fd, stat.size)
assert(not err) assert(not err, err)
assert(not async.uv.fs_close(fd)) assert(not async.uv.fs_close(fd))
watcher.update(stat)
return data return data
end) end)
assert(not err, err) assert(not err, err)
@ -158,16 +178,23 @@ end
function Native:save() function Native:save()
local start = os.clock() local start = os.clock()
local err = self.file_lock:with(function() local err = self.file_lock:with(function()
local f = assert(load("return " .. vim.inspect(self.table))) self:raw_save(self.table)
local data = string.dump(f) local err, stat = async.uv.fs_stat(self.filename)
local err, fd = async.uv.fs_open(self.filename, "w", tonumber("644", 8)) assert(not err, err)
assert(not err) watcher.update(stat)
assert(not async.uv.fs_write(fd, data))
assert(not async.uv.fs_close(fd))
return nil return nil
end) end)
assert(not err, err) assert(not err, err)
log.debug(("save() takes %f seconds"):format(os.clock() - start)) log.debug(("save() takes %f seconds"):format(os.clock() - start))
end end
function Native:raw_save(tbl)
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))
assert(not err, err)
assert(not async.uv.fs_write(fd, data))
assert(not async.uv.fs_close(fd))
end
return Native return Native

View File

@ -0,0 +1,87 @@
local async = require "plenary.async" --[[@as PlenaryAsync]]
local log = require "plenary.log"
local uv = vim.loop or vim.uv
---@class FrecencyNativeWatcherMtime
---@field sec integer
---@field nsec integer
local Mtime = {}
---@param mtime FsStatMtime
---@return FrecencyNativeWatcherMtime
Mtime.new = function(mtime)
return setmetatable({ sec = mtime.sec, nsec = mtime.nsec }, Mtime)
end
---@param other FrecencyNativeWatcherMtime
---@return boolean
function Mtime:__eq(other)
return self.sec == other.sec and self.nsec == other.nsec
end
---@return string
function Mtime:__tostring()
return string.format("%d.%d", self.sec, self.nsec)
end
---@class FrecencyNativeWatcher
---@field handler UvFsEventHandle
---@field path string
---@field mtime FrecencyNativeWatcherMtime
local Watcher = {}
---@return FrecencyNativeWatcher
Watcher.new = function()
return setmetatable({ path = "", mtime = Mtime.new { sec = 0, nsec = 0 } }, { __index = Watcher })
end
---@param path string
---@param tx PlenaryAsyncControlChannelTx
function Watcher:watch(path, tx)
if self.handler then
self.handler:stop()
end
self.handler = assert(uv.new_fs_event()) --[[@as UvFsEventHandle]]
self.handler:start(path, { recursive = true }, function(err, _, _)
if err then
log.debug("failed to watch path: " .. err)
return
end
async.void(function()
-- NOTE: wait for updating mtime
async.util.sleep(50)
local stat
err, stat = async.uv.fs_stat(path)
if err then
log.debug("failed to stat path: " .. err)
return
end
local mtime = Mtime.new(stat.mtime)
if self.mtime ~= mtime then
log.debug(("mtime changed: %s -> %s"):format(self.mtime, mtime))
self.mtime = mtime
tx.send()
end
end)()
end)
end
local watcher = Watcher.new()
return {
---@param path string
---@param tx PlenaryAsyncControlChannelTx
---@return nil
watch = function(path, tx)
log.debug("watch path: " .. path)
watcher:watch(path, tx)
end,
---@param stat FsStat
---@return nil
update = function(stat)
local mtime = Mtime.new(stat.mtime)
log.debug(("update mtime: %s -> %s"):format(watcher.mtime, mtime))
watcher.mtime = mtime
end,
}

View File

@ -40,10 +40,12 @@ end
---@field score number ---@field score number
---@field display fun(entry: FrecencyEntry): string, table ---@field display fun(entry: FrecencyEntry): string, table
---@alias FrecencyEntryMakerInstance fun(file: FrecencyFile): FrecencyEntry
---@param filepath_formatter FrecencyFilepathFormatter ---@param filepath_formatter FrecencyFilepathFormatter
---@param workspace string? ---@param workspace string?
---@param workspace_tag string? ---@param workspace_tag string?
---@return fun(file: FrecencyFile): FrecencyEntry ---@return FrecencyEntryMakerInstance
function EntryMaker:create(filepath_formatter, workspace, workspace_tag) function EntryMaker:create(filepath_formatter, workspace, workspace_tag)
local displayer = entry_display.create { local displayer = entry_display.create {
separator = "", separator = "",

View File

@ -1,47 +1,191 @@
local AsyncFinder = require "frecency.async_finder" local async = require "plenary.async" --[[@as PlenaryAsync]]
local finders = require "telescope.finders"
local log = require "plenary.log" local log = require "plenary.log"
---@class FrecencyFinder ---@class FrecencyFinder
---@field private config FrecencyFinderConfig ---@field config FrecencyFinderConfig
---@field private entry_maker FrecencyEntryMaker ---@field closed boolean
---@field private fs FrecencyFS ---@field entries FrecencyEntry[]
---@field entry_maker FrecencyEntryMakerInstance
---@field fs FrecencyFS
---@field need_scandir boolean
---@field path string?
---@field private database FrecencyDatabase
---@field private recency FrecencyRecency
---@field private rx PlenaryAsyncControlChannelRx
---@field private state FrecencyState
---@field private tx PlenaryAsyncControlChannelTx
local Finder = {} local Finder = {}
---@class FrecencyFinderConfig ---@class FrecencyFinderConfig
---@field chunk_size integer ---@field chunk_size integer default: 1000
---@field sleep_interval integer default: 50
---@param entry_maker FrecencyEntryMaker ---@param database FrecencyDatabase
---@param entry_maker FrecencyEntryMakerInstance
---@param fs FrecencyFS ---@param fs FrecencyFS
---@param need_scandir boolean
---@param path string?
---@param recency FrecencyRecency
---@param state FrecencyState
---@param config FrecencyFinderConfig? ---@param config FrecencyFinderConfig?
---@return FrecencyFinder ---@return FrecencyFinder
Finder.new = function(entry_maker, fs, config) Finder.new = function(database, entry_maker, fs, need_scandir, path, recency, state, config)
return setmetatable( local tx, rx = async.control.channel.mpsc()
{ config = vim.tbl_extend("force", { chunk_size = 1000 }, config or {}), entry_maker = entry_maker, fs = fs }, return setmetatable({
{ __index = Finder } config = vim.tbl_extend("force", { chunk_size = 1000, sleep_interval = 50 }, config or {}),
) closed = false,
database = database,
entries = {},
entry_maker = entry_maker,
fs = fs,
need_scandir = need_scandir,
path = path,
recency = recency,
rx = rx,
state = state,
tx = tx,
}, {
__index = Finder,
---@param self FrecencyFinder
__call = function(self, ...)
return self:find(...)
end,
})
end end
---@class FrecencyFinderOptions ---@param datetime string?
---@field need_scandir boolean ---@return nil
---@field workspace string? function Finder:start(datetime)
---@field workspace_tag string? async.void(function()
-- NOTE: return to the main loop to show the main window
---@param state FrecencyState async.util.sleep(0)
---@param filepath_formatter FrecencyFilepathFormatter local seen = {}
---@param initial_results table for i, file in ipairs(self:get_results(self.path, datetime)) do
---@param opts FrecencyFinderOptions local entry = self.entry_maker(file)
---@return table seen[entry.filename] = true
function Finder:start(state, filepath_formatter, initial_results, opts) entry.index = i
local entry_maker = self.entry_maker:create(filepath_formatter, opts.workspace, opts.workspace_tag) table.insert(self.entries, entry)
if not opts.need_scandir then self.tx.send(entry)
return finders.new_table { end
results = initial_results, if self.need_scandir and self.path then
entry_maker = entry_maker, -- NOTE: return to the main loop to show results from DB
} async.util.sleep(self.config.sleep_interval)
self:scan_dir(seen)
end
self:close()
self.tx.send(nil)
end)()
end
---@param seen table<string, boolean>
---@return nil
function Finder:scan_dir(seen)
local count = 0
local index = #self.entries
for name in self.fs:scan_dir(self.path) do
if self.closed then
break
end
local fullpath = self.fs.joinpath(self.path, name)
if not seen[fullpath] then
seen[fullpath] = true
count = count + 1
local entry = self.entry_maker { id = 0, count = 0, path = fullpath, score = 0 }
if entry then
index = index + 1
entry.index = index
table.insert(self.entries, entry)
self.tx.send(entry)
if count % self.config.chunk_size == 0 then
self:reflow_results()
async.util.sleep(self.config.sleep_interval)
end
end
end
end
end
---@param _ string
---@param process_result fun(entry: FrecencyEntry): nil
---@param process_complete fun(): nil
---@return nil
function Finder:find(_, process_result, process_complete)
local index = 0
for _, entry in ipairs(self.entries) do
index = index + 1
if process_result(entry) then
return
end
end
local count = 0
while not self.closed do
count = count + 1
local entry = self.rx.recv()
if not entry then
break
elseif entry.index > index and process_result(entry) then
return
end
end
process_complete()
end
---@param workspace string?
---@param datetime string?
---@return FrecencyFile[]
function Finder:get_results(workspace, datetime)
log.debug { workspace = workspace or "NONE" }
local start_fetch = os.clock()
local files = self.database:get_entries(workspace, datetime)
log.debug(("it takes %f seconds in fetching entries"):format(os.clock() - start_fetch))
local start_results = os.clock()
local elapsed_recency = 0
for _, file in ipairs(files) do
local start_recency = os.clock()
file.score = file.ages and self.recency:calculate(file.count, file.ages) or 0
file.ages = nil
elapsed_recency = elapsed_recency + (os.clock() - start_recency)
end
log.debug(("it takes %f seconds in calculating recency"):format(elapsed_recency))
log.debug(("it takes %f seconds in making results"):format(os.clock() - start_results))
local start_sort = os.clock()
table.sort(files, function(a, b)
return a.score > b.score or (a.score == b.score and a.path > b.path)
end)
log.debug(("it takes %f seconds in sorting"):format(os.clock() - start_sort))
return files
end
function Finder:close()
self.closed = true
end
function Finder:reflow_results()
local picker = self.state:get()
if not picker then
return
end
local bufnr = picker.results_bufnr
local win = picker.results_win
if not bufnr or not win then
return
end
picker:clear_extra_rows(bufnr)
if picker.sorting_strategy == "descending" then
local manager = picker.manager
if not manager then
return
end
local worst_line = picker:get_row(manager:num_results())
---@type WinInfo
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
log.debug { finder = opts }
return AsyncFinder.new(state, self.fs, opts.workspace, entry_maker, initial_results)
end end
return Finder return Finder

View File

@ -2,7 +2,6 @@ local Sqlite = require "frecency.database.sqlite"
local Native = require "frecency.database.native" local Native = require "frecency.database.native"
local EntryMaker = require "frecency.entry_maker" local EntryMaker = require "frecency.entry_maker"
local FS = require "frecency.fs" local FS = require "frecency.fs"
local Finder = require "frecency.finder"
local Migrator = require "frecency.migrator" local Migrator = require "frecency.migrator"
local Picker = require "frecency.picker" local Picker = require "frecency.picker"
local Recency = require "frecency.recency" local Recency = require "frecency.recency"
@ -14,7 +13,7 @@ local log = require "plenary.log"
---@field config FrecencyConfig ---@field config FrecencyConfig
---@field private buf_registered table<integer, boolean> flag to indicate the buffer is registered to the database. ---@field private buf_registered table<integer, boolean> flag to indicate the buffer is registered to the database.
---@field private database FrecencyDatabase ---@field private database FrecencyDatabase
---@field private finder FrecencyFinder ---@field private entry_maker FrecencyEntryMaker
---@field private fs FrecencyFS ---@field private fs FrecencyFS
---@field private migrator FrecencyMigrator ---@field private migrator FrecencyMigrator
---@field private picker FrecencyPicker ---@field private picker FrecencyPicker
@ -69,11 +68,10 @@ Frecency.new = function(opts)
end end
self.database = Database.new(self.fs, { root = config.db_root }) self.database = Database.new(self.fs, { root = config.db_root })
local web_devicons = WebDevicons.new(not config.disable_devicons) local web_devicons = WebDevicons.new(not config.disable_devicons)
local entry_maker = EntryMaker.new(self.fs, web_devicons, { self.entry_maker = EntryMaker.new(self.fs, web_devicons, {
show_filter_column = config.show_filter_column, show_filter_column = config.show_filter_column,
show_scores = config.show_scores, show_scores = config.show_scores,
}) })
self.finder = Finder.new(entry_maker, self.fs)
self.recency = Recency.new() self.recency = Recency.new()
self.migrator = Migrator.new(self.fs, self.recency, self.config.db_root) self.migrator = Migrator.new(self.fs, self.recency, self.config.db_root)
return self return self
@ -122,7 +120,7 @@ function Frecency:start(opts)
local start = os.clock() local start = os.clock()
log.debug "Frecency:start" log.debug "Frecency:start"
opts = opts or {} opts = opts or {}
self.picker = Picker.new(self.database, self.finder, self.fs, self.recency, { self.picker = Picker.new(self.database, self.entry_maker, self.fs, self.recency, {
default_workspace_tag = self.config.default_workspace, default_workspace_tag = self.config.default_workspace,
editing_bufnr = vim.api.nvim_get_current_buf(), editing_bufnr = vim.api.nvim_get_current_buf(),
filter_delimiter = self.config.filter_delimiter, filter_delimiter = self.config.filter_delimiter,

View File

@ -1,4 +1,5 @@
local State = require "frecency.state" local State = require "frecency.state"
local Finder = require "frecency.finder"
local log = require "plenary.log" local log = require "plenary.log"
local Path = require "plenary.path" --[[@as PlenaryPath]] local Path = require "plenary.path" --[[@as PlenaryPath]]
local actions = require "telescope.actions" local actions = require "telescope.actions"
@ -11,12 +12,12 @@ local uv = vim.loop or vim.uv
---@class FrecencyPicker ---@class FrecencyPicker
---@field private config FrecencyPickerConfig ---@field private config FrecencyPickerConfig
---@field private database FrecencyDatabase ---@field private database FrecencyDatabase
---@field private finder FrecencyFinder ---@field private entry_maker FrecencyEntryMaker
---@field private fs FrecencyFS ---@field private fs FrecencyFS
---@field private lsp_workspaces string[] ---@field private lsp_workspaces string[]
---@field private namespace integer ---@field private namespace integer
---@field private recency FrecencyRecency ---@field private recency FrecencyRecency
---@field private results table[] ---@field private state FrecencyState
---@field private workspace string? ---@field private workspace string?
---@field private workspace_tag_regex string ---@field private workspace_tag_regex string
local Picker = {} local Picker = {}
@ -37,21 +38,20 @@ local Picker = {}
---@field score number ---@field score number
---@param database FrecencyDatabase ---@param database FrecencyDatabase
---@param finder FrecencyFinder ---@param entry_maker FrecencyEntryMaker
---@param fs FrecencyFS ---@param fs FrecencyFS
---@param recency FrecencyRecency ---@param recency FrecencyRecency
---@param config FrecencyPickerConfig ---@param config FrecencyPickerConfig
---@return FrecencyPicker ---@return FrecencyPicker
Picker.new = function(database, finder, fs, recency, config) Picker.new = function(database, entry_maker, fs, recency, config)
local self = setmetatable({ local self = setmetatable({
config = config, config = config,
database = database, database = database,
finder = finder, entry_maker = entry_maker,
fs = fs, fs = fs,
lsp_workspaces = {}, lsp_workspaces = {},
namespace = vim.api.nvim_create_namespace "frecency", namespace = vim.api.nvim_create_namespace "frecency",
recency = recency, recency = recency,
results = {},
}, { __index = Picker }) }, { __index = Picker })
local d = self.config.filter_delimiter local d = self.config.filter_delimiter
self.workspace_tag_regex = "^%s*" .. d .. "(%S+)" .. d self.workspace_tag_regex = "^%s*" .. d .. "(%S+)" .. d
@ -70,6 +70,16 @@ end
---| fun(opts: FrecencyPickerOptions, path: string): string ---| fun(opts: FrecencyPickerOptions, path: string): string
---@field workspace string? ---@field workspace string?
---@param opts table
---@param workspace string?
---@param workspace_tag string?
function Picker:finder(opts, workspace, workspace_tag)
local filepath_formatter = self:filepath_formatter(opts)
local entry_maker = self.entry_maker:create(filepath_formatter, workspace, workspace_tag)
local need_scandir = not not (workspace and self.config.show_unindexed)
return Finder.new(self.database, entry_maker, self.fs, need_scandir, workspace, self.recency, self.state)
end
---@param opts FrecencyPickerOptions? ---@param opts FrecencyPickerOptions?
function Picker:start(opts) function Picker:start(opts)
opts = vim.tbl_extend("force", { opts = vim.tbl_extend("force", {
@ -80,36 +90,25 @@ function Picker:start(opts)
}, opts or {}) --[[@as FrecencyPickerOptions]] }, opts or {}) --[[@as FrecencyPickerOptions]]
self.workspace = self:get_workspace(opts.cwd, self.config.initial_workspace_tag) self.workspace = self:get_workspace(opts.cwd, self.config.initial_workspace_tag)
log.debug { workspace = self.workspace } log.debug { workspace = self.workspace }
self.results = self:fetch_results(self.workspace)
local state = State.new()
local filepath_formatter = self:filepath_formatter(opts)
local finder = self.finder:start(state, filepath_formatter, self.results, {
need_scandir = self.workspace and self.config.show_unindexed and true or false,
workspace = self.workspace,
workspace_tag = self.config.initial_workspace_tag,
})
self.state = State.new()
local finder = self:finder(opts, self.workspace, self.config.initial_workspace_tag)
local picker = pickers.new(opts, { local picker = pickers.new(opts, {
prompt_title = "Frecency", prompt_title = "Frecency",
finder = finder, finder = finder,
previewer = config_values.file_previewer(opts), previewer = config_values.file_previewer(opts),
sorter = sorters.get_substr_matcher(), sorter = sorters.get_substr_matcher(),
on_input_filter_cb = self:on_input_filter_cb(state, opts), on_input_filter_cb = self:on_input_filter_cb(opts),
attach_mappings = function(prompt_bufnr) attach_mappings = function(prompt_bufnr)
return self:attach_mappings(prompt_bufnr) return self:attach_mappings(prompt_bufnr)
end, end,
}) })
state:set(picker) self.state:set(picker)
picker:find() picker:find()
finder:start()
self:set_prompt_options(picker.prompt_bufnr) self:set_prompt_options(picker.prompt_bufnr)
end end
function Picker:discard_results()
-- TODO: implement here when it needs to cache.
end
--- See :h 'complete-functions' --- See :h 'complete-functions'
---@param findstart 1|0 ---@param findstart 1|0
---@param base string ---@param base string
@ -178,34 +177,6 @@ function Picker:get_workspace(cwd, tag)
end end
end end
---@private
---@param workspace string?
---@param datetime string? ISO8601 format string
---@return FrecencyFile[]
function Picker:fetch_results(workspace, datetime)
log.debug { workspace = workspace or "NONE" }
local start_fetch = os.clock()
local files = self.database:get_entries(workspace, datetime)
log.debug(("it takes %f seconds in fetching entries"):format(os.clock() - start_fetch))
local start_results = os.clock()
local elapsed_recency = 0
for _, file in ipairs(files) do
local start_recency = os.clock()
file.score = file.ages and self.recency:calculate(file.count, file.ages) or 0
file.ages = nil
elapsed_recency = elapsed_recency + (os.clock() - start_recency)
end
log.debug(("it takes %f seconds in calculating recency"):format(elapsed_recency))
log.debug(("it takes %f seconds in making results"):format(os.clock() - start_results))
local start_sort = os.clock()
table.sort(files, function(a, b)
return a.score > b.score or (a.score == b.score and a.path > b.path)
end)
log.debug(("it takes %f seconds in sorting"):format(os.clock() - start_sort))
return files
end
---@private ---@private
---@return string? ---@return string?
function Picker:get_lsp_workspace() function Picker:get_lsp_workspace()
@ -216,11 +187,9 @@ function Picker:get_lsp_workspace()
end end
---@private ---@private
---@param state FrecencyState
---@param picker_opts table ---@param picker_opts table
---@return fun(prompt: string): table ---@return fun(prompt: string): table
function Picker:on_input_filter_cb(state, picker_opts) function Picker:on_input_filter_cb(picker_opts)
local filepath_formatter = self:filepath_formatter(picker_opts)
return function(prompt) return function(prompt)
local workspace local workspace
local start, finish, tag = prompt:find(self.workspace_tag_regex) local start, finish, tag = prompt:find(self.workspace_tag_regex)
@ -230,7 +199,7 @@ function Picker:on_input_filter_cb(state, picker_opts)
else else
workspace = self:get_workspace(picker_opts.cwd, tag) or self.workspace workspace = self:get_workspace(picker_opts.cwd, tag) or self.workspace
end end
local picker = state:get() local picker = self.state:get()
if picker then if picker then
local buf = picker.prompt_bufnr local buf = picker.prompt_bufnr
vim.api.nvim_buf_clear_namespace(buf, self.namespace, 0, -1) vim.api.nvim_buf_clear_namespace(buf, self.namespace, 0, -1)
@ -249,13 +218,7 @@ function Picker:on_input_filter_cb(state, picker_opts)
end end
if self.workspace ~= workspace then if self.workspace ~= workspace then
self.workspace = workspace self.workspace = workspace
self.results = self:fetch_results(workspace) opts.updated_finder = self:finder(picker_opts, self.workspace, tag or self.config.initial_workspace_tag):start()
opts.updated_finder = self.finder:start(state, filepath_formatter, self.results, {
initial_results = self.results,
need_scandir = self.workspace and self.config.show_unindexed and true or false,
workspace = self.workspace,
workspace_tag = tag,
})
end end
return opts return opts
end end

View File

@ -1,98 +0,0 @@
---@diagnostic disable: invisible
local AsyncFinder = require "frecency.async_finder"
local State = require "frecency.state"
local FS = require "frecency.fs"
local EntryMaker = require "frecency.entry_maker"
local WebDevicons = require "frecency.web_devicons"
local util = require "frecency.tests.util"
---@param files string[]
---@param initial_results string[]
---@param callback fun(async_finder: FrecencyAsyncFinder, dir: PlenaryPath): nil
local function with_files(files, initial_results, callback)
local dir, close = util.make_tree(files)
local fs = FS.new { ignore_patterns = {} }
local web_devicons = WebDevicons.new(true)
local function filepath_formatter()
return function(name)
return name
end
end
local entry_maker = EntryMaker.new(fs, web_devicons, { show_filter_column = false, show_scores = false })
:create(filepath_formatter, dir:absolute())
local initials = vim.tbl_map(function(v)
return { path = (dir / v):absolute() }
end, initial_results)
local async_finder = AsyncFinder.new(State.new(), fs, dir:absolute(), entry_maker, initials)
callback(async_finder, dir)
close()
end
describe("async_finder", function()
local function run(async_finder)
local count = { process_result = 0, process_complete = 0 }
local results = {}
async_finder("", function(result)
count.process_result = count.process_result + 1
table.insert(results, result.filename)
end, function()
count.process_complete = count.process_complete + 1
end)
return count, results
end
describe("with no initial_results", function()
with_files({ "hoge1.txt", "hoge2.txt" }, {}, function(async_finder, dir)
describe("when run at the first time", function()
local count, results = run(async_finder)
it("called process_result() at 2 times", function()
assert.are.same(2, count.process_result)
end)
it("called process_complete() at 1 time", function()
assert.are.same(1, count.process_complete)
end)
it("returns the whole results", function()
assert.are.same({
dir:joinpath("hoge1.txt").filename,
dir:joinpath("hoge2.txt").filename,
}, results)
end)
end)
describe("when run again", function()
local count, results = run(async_finder)
it("called process_result() at 2 times", function()
assert.are.same(2, count.process_result)
end)
it("called process_complete() at 1 time", function()
assert.are.same(1, count.process_complete)
end)
it("returns the same results", function()
assert.are.same({
dir:joinpath("hoge1.txt").filename,
dir:joinpath("hoge2.txt").filename,
}, results)
end)
end)
end)
end)
describe("with initial_results", function()
with_files({ "fuga1.txt", "hoge1.txt", "hoge2.txt" }, { "fuga1.txt" }, function(async_finder, dir)
local count, results = run(async_finder)
it("called process_result() at 3 times", function()
assert.are.same(3, count.process_result)
end)
it("called process_complete() at 1 time", function()
assert.are.same(1, count.process_complete)
end)
it("returns the same results without duplications", function()
assert.are.same({
dir:joinpath("fuga1.txt").filename,
dir:joinpath("hoge1.txt").filename,
dir:joinpath("hoge2.txt").filename,
}, results)
end)
end)
end)
end)

View File

@ -8,7 +8,7 @@ local Path = require "plenary.path"
local use_sqlite local use_sqlite
---@param files string[] ---@param files string[]
---@param callback fun(frecency: Frecency, dir: PlenaryPath): nil ---@param callback fun(frecency: Frecency, finder: FrecencyFinder, dir: PlenaryPath): nil
---@return nil ---@return nil
local function with_files(files, callback) local function with_files(files, callback)
local dir, close = util.make_tree(files) local dir, close = util.make_tree(files)
@ -16,12 +16,13 @@ local function with_files(files, callback)
local frecency = Frecency.new { db_root = dir.filename, use_sqlite = use_sqlite } local frecency = Frecency.new { db_root = dir.filename, use_sqlite = use_sqlite }
frecency.picker = Picker.new( frecency.picker = Picker.new(
frecency.database, frecency.database,
frecency.finder, frecency.entry_maker,
frecency.fs, frecency.fs,
frecency.recency, frecency.recency,
{ editing_bufnr = 0, filter_delimiter = ":", show_unindexed = false, workspaces = {} } { editing_bufnr = 0, filter_delimiter = ":", show_unindexed = false, workspaces = {} }
) )
callback(frecency, dir) local finder = frecency.picker:finder {}
callback(frecency, finder, dir)
close() close()
end end
@ -92,13 +93,13 @@ describe("frecency", function()
describe(db, function() describe(db, function()
describe("register", function() describe("register", function()
describe("when opening files", function() describe("when opening files", function()
with_files({ "hoge1.txt", "hoge2.txt" }, function(frecency, dir) with_files({ "hoge1.txt", "hoge2.txt" }, function(frecency, finder, dir)
local register = make_register(frecency, dir) local register = make_register(frecency, dir)
register("hoge1.txt", "2023-07-29T00:00:00+09:00") register("hoge1.txt", "2023-07-29T00:00:00+09:00")
register("hoge2.txt", "2023-07-29T01:00:00+09:00") register("hoge2.txt", "2023-07-29T01:00:00+09:00")
it("has valid records in DB", function() it("has valid records in DB", function()
local results = frecency.picker:fetch_results(nil, "2023-07-29T02:00:00+09:00") local results = finder:get_results(nil, "2023-07-29T02:00:00+09:00")
assert.are.same({ assert.are.same({
{ count = 1, path = filepath(dir, "hoge2.txt"), score = 10 }, { count = 1, path = filepath(dir, "hoge2.txt"), score = 10 },
{ count = 1, path = filepath(dir, "hoge1.txt"), score = 10 }, { count = 1, path = filepath(dir, "hoge1.txt"), score = 10 },
@ -108,14 +109,14 @@ describe("frecency", function()
end) end)
describe("when opening again", function() describe("when opening again", function()
with_files({ "hoge1.txt", "hoge2.txt" }, function(frecency, dir) with_files({ "hoge1.txt", "hoge2.txt" }, function(frecency, finder, dir)
local register = make_register(frecency, dir) local register = make_register(frecency, dir)
register("hoge1.txt", "2023-07-29T00:00:00+09:00") register("hoge1.txt", "2023-07-29T00:00:00+09:00")
register("hoge2.txt", "2023-07-29T01:00:00+09:00") register("hoge2.txt", "2023-07-29T01:00:00+09:00")
register("hoge1.txt", "2023-07-29T02:00:00+09:00", true) register("hoge1.txt", "2023-07-29T02:00:00+09:00", true)
it("increases the score", function() it("increases the score", function()
local results = frecency.picker:fetch_results(nil, "2023-07-29T03:00:00+09:00") local results = finder:get_results(nil, "2023-07-29T03:00:00+09:00")
assert.are.same({ assert.are.same({
{ count = 2, path = filepath(dir, "hoge1.txt"), score = 40 }, { count = 2, path = filepath(dir, "hoge1.txt"), score = 40 },
{ count = 1, path = filepath(dir, "hoge2.txt"), score = 10 }, { count = 1, path = filepath(dir, "hoge2.txt"), score = 10 },
@ -125,14 +126,14 @@ describe("frecency", function()
end) end)
describe("when opening again but the same instance", function() describe("when opening again but the same instance", function()
with_files({ "hoge1.txt", "hoge2.txt" }, function(frecency, dir) with_files({ "hoge1.txt", "hoge2.txt" }, function(frecency, finder, dir)
local register = make_register(frecency, dir) local register = make_register(frecency, dir)
register("hoge1.txt", "2023-07-29T00:00:00+09:00") register("hoge1.txt", "2023-07-29T00:00:00+09:00")
register("hoge2.txt", "2023-07-29T01:00:00+09:00") register("hoge2.txt", "2023-07-29T01:00:00+09:00")
register("hoge1.txt", "2023-07-29T02:00:00+09:00") register("hoge1.txt", "2023-07-29T02:00:00+09:00")
it("does not increase the score", function() it("does not increase the score", function()
local results = frecency.picker:fetch_results(nil, "2023-07-29T03:00:00+09:00") local results = finder:get_results(nil, "2023-07-29T03:00:00+09:00")
assert.are.same({ assert.are.same({
{ count = 1, path = filepath(dir, "hoge2.txt"), score = 10 }, { count = 1, path = filepath(dir, "hoge2.txt"), score = 10 },
{ count = 1, path = filepath(dir, "hoge1.txt"), score = 10 }, { count = 1, path = filepath(dir, "hoge1.txt"), score = 10 },
@ -142,7 +143,7 @@ describe("frecency", function()
end) end)
describe("when opening more than 10 times", function() describe("when opening more than 10 times", function()
with_files({ "hoge1.txt", "hoge2.txt" }, function(frecency, dir) with_files({ "hoge1.txt", "hoge2.txt" }, function(frecency, finder, dir)
local register = make_register(frecency, dir) local register = make_register(frecency, dir)
register("hoge1.txt", "2023-07-29T00:00:00+09:00") register("hoge1.txt", "2023-07-29T00:00:00+09:00")
register("hoge1.txt", "2023-07-29T00:01:00+09:00", true) register("hoge1.txt", "2023-07-29T00:01:00+09:00", true)
@ -161,7 +162,7 @@ describe("frecency", function()
register("hoge2.txt", "2023-07-29T00:11:00+09:00", true) register("hoge2.txt", "2023-07-29T00:11:00+09:00", true)
it("calculates score from the recent 10 times", function() it("calculates score from the recent 10 times", function()
local results = frecency.picker:fetch_results(nil, "2023-07-29T00:12:00+09:00") local results = finder:get_results(nil, "2023-07-29T00:12:00+09:00")
assert.are.same({ assert.are.same({
{ count = 12, path = filepath(dir, "hoge2.txt"), score = 12 * (10 * 100) / 10 }, { count = 12, path = filepath(dir, "hoge2.txt"), score = 12 * (10 * 100) / 10 },
{ count = 2, path = filepath(dir, "hoge1.txt"), score = 2 * (2 * 100) / 10 }, { count = 2, path = filepath(dir, "hoge1.txt"), score = 2 * (2 * 100) / 10 },
@ -173,7 +174,7 @@ describe("frecency", function()
describe("benchmark", function() describe("benchmark", function()
describe("after registered over >5000 files", function() describe("after registered over >5000 files", function()
with_files({}, function(frecency, dir) with_files({}, function(frecency, finder, dir)
with_fake_register(frecency, dir, function(register) with_fake_register(frecency, dir, function(register)
-- TODO: 6000 records is too many to use with native? -- TODO: 6000 records is too many to use with native?
-- local file_count = 6000 -- local file_count = 6000
@ -187,10 +188,13 @@ describe("frecency", function()
for i = 1, file_count do for i = 1, file_count do
local file = ("hoge%08d.txt"):format(i) local file = ("hoge%08d.txt"):format(i)
table.insert(expected, { count = 1, path = filepath(dir, file), score = 10 }) table.insert(expected, { count = 1, path = filepath(dir, file), score = 10 })
-- HACK: disable log because it fails with too many logging
log.new({ level = "info" }, true)
register(file, "2023-07-29T00:00:00+09:00") register(file, "2023-07-29T00:00:00+09:00")
log.new({}, true)
end end
local start = os.clock() local start = os.clock()
local results = frecency.picker:fetch_results(nil, "2023-07-29T00:01:00+09:00") local results = finder:get_results(nil, "2023-07-29T00:01:00+09:00")
table.sort(results, function(a, b) table.sort(results, function(a, b)
return a.path < b.path return a.path < b.path
end) end)
@ -211,13 +215,13 @@ describe("frecency", function()
describe("validate_database", function() describe("validate_database", function()
describe("when no files are unlinked", function() describe("when no files are unlinked", function()
with_files({ "hoge1.txt", "hoge2.txt" }, function(frecency, dir) with_files({ "hoge1.txt", "hoge2.txt" }, function(frecency, finder, dir)
local register = make_register(frecency, dir) local register = make_register(frecency, dir)
register("hoge1.txt", "2023-07-29T00:00:00+09:00") register("hoge1.txt", "2023-07-29T00:00:00+09:00")
register("hoge2.txt", "2023-07-29T00:01:00+09:00") register("hoge2.txt", "2023-07-29T00:01:00+09:00")
it("removes no entries", function() it("removes no entries", function()
local results = frecency.picker:fetch_results(nil, "2023-07-29T02:00:00+09:00") local results = finder:get_results(nil, "2023-07-29T02:00:00+09:00")
assert.are.same({ assert.are.same({
{ count = 1, path = filepath(dir, "hoge2.txt"), score = 10 }, { count = 1, path = filepath(dir, "hoge2.txt"), score = 10 },
{ count = 1, path = filepath(dir, "hoge1.txt"), score = 10 }, { count = 1, path = filepath(dir, "hoge1.txt"), score = 10 },
@ -228,7 +232,9 @@ describe("frecency", function()
describe("when with not force", function() describe("when with not force", function()
describe("when files are unlinked but it is less than threshold", 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" }, function(frecency, dir) with_files(
{ "hoge1.txt", "hoge2.txt", "hoge3.txt", "hoge4.txt", "hoge5.txt" },
function(frecency, finder, dir)
local register = make_register(frecency, dir) local register = make_register(frecency, dir)
register("hoge1.txt", "2023-07-29T00:00:00+09:00") register("hoge1.txt", "2023-07-29T00:00:00+09:00")
register("hoge2.txt", "2023-07-29T00:01:00+09:00") register("hoge2.txt", "2023-07-29T00:01:00+09:00")
@ -241,7 +247,7 @@ describe("frecency", function()
frecency:validate_database() frecency:validate_database()
it("removes no entries", function() it("removes no entries", function()
local results = frecency.picker:fetch_results(nil, "2023-07-29T02:00:00+09:00") local results = finder:get_results(nil, "2023-07-29T02:00:00+09:00")
table.sort(results, function(a, b) table.sort(results, function(a, b)
return a.path < b.path return a.path < b.path
end) end)
@ -253,12 +259,15 @@ describe("frecency", function()
{ count = 1, path = filepath(dir, "hoge5.txt"), score = 10 }, { count = 1, path = filepath(dir, "hoge5.txt"), score = 10 },
}, results) }, results)
end) end)
end) end
)
end) end)
describe("when files are unlinked and it is more than threshold", function() describe("when files are unlinked and it is more than threshold", function()
describe('when the user response "yes"', function() describe('when the user response "yes"', function()
with_files({ "hoge1.txt", "hoge2.txt", "hoge3.txt", "hoge4.txt", "hoge5.txt" }, function(frecency, dir) with_files(
{ "hoge1.txt", "hoge2.txt", "hoge3.txt", "hoge4.txt", "hoge5.txt" },
function(frecency, finder, dir)
local register = make_register(frecency, dir) local register = make_register(frecency, dir)
register("hoge1.txt", "2023-07-29T00:00:00+09:00") register("hoge1.txt", "2023-07-29T00:00:00+09:00")
register("hoge2.txt", "2023-07-29T00:01:00+09:00") register("hoge2.txt", "2023-07-29T00:01:00+09:00")
@ -279,7 +288,7 @@ describe("frecency", function()
end) end)
it("removes entries", function() it("removes entries", function()
local results = frecency.picker:fetch_results(nil, "2023-07-29T02:00:00+09:00") local results = finder:get_results(nil, "2023-07-29T02:00:00+09:00")
table.sort(results, function(a, b) table.sort(results, function(a, b)
return a.path < b.path return a.path < b.path
end) end)
@ -288,11 +297,14 @@ describe("frecency", function()
{ count = 1, path = filepath(dir, "hoge5.txt"), score = 10 }, { count = 1, path = filepath(dir, "hoge5.txt"), score = 10 },
}, results) }, results)
end) end)
end) end
)
end) end)
describe('when the user response "no"', function() describe('when the user response "no"', function()
with_files({ "hoge1.txt", "hoge2.txt", "hoge3.txt", "hoge4.txt", "hoge5.txt" }, function(frecency, dir) with_files(
{ "hoge1.txt", "hoge2.txt", "hoge3.txt", "hoge4.txt", "hoge5.txt" },
function(frecency, finder, dir)
local register = make_register(frecency, dir) local register = make_register(frecency, dir)
register("hoge1.txt", "2023-07-29T00:00:00+09:00") register("hoge1.txt", "2023-07-29T00:00:00+09:00")
register("hoge2.txt", "2023-07-29T00:01:00+09:00") register("hoge2.txt", "2023-07-29T00:01:00+09:00")
@ -313,7 +325,7 @@ describe("frecency", function()
end) end)
it("removes no entries", function() it("removes no entries", function()
local results = frecency.picker:fetch_results(nil, "2023-07-29T02:00:00+09:00") local results = finder:get_results(nil, "2023-07-29T02:00:00+09:00")
table.sort(results, function(a, b) table.sort(results, function(a, b)
return a.path < b.path return a.path < b.path
end) end)
@ -325,14 +337,15 @@ describe("frecency", function()
{ count = 1, path = filepath(dir, "hoge5.txt"), score = 10 }, { count = 1, path = filepath(dir, "hoge5.txt"), score = 10 },
}, results) }, results)
end) end)
end) end
)
end) end)
end) end)
end) end)
describe("when with force", function() describe("when with force", function()
describe("when db_safe_mode is true", function() describe("when db_safe_mode is true", function()
with_files({ "hoge1.txt", "hoge2.txt" }, function(frecency, dir) with_files({ "hoge1.txt", "hoge2.txt" }, function(frecency, finder, dir)
local register = make_register(frecency, dir) local register = make_register(frecency, dir)
register("hoge1.txt", "2023-07-29T00:00:00+09:00") register("hoge1.txt", "2023-07-29T00:00:00+09:00")
register("hoge2.txt", "2023-07-29T00:01:00+09:00") register("hoge2.txt", "2023-07-29T00:01:00+09:00")
@ -347,7 +360,7 @@ describe("frecency", function()
end) end)
it("needs confirmation for removing entries", function() it("needs confirmation for removing entries", function()
local results = frecency.picker:fetch_results(nil, "2023-07-29T02:00:00+09:00") local results = finder:get_results(nil, "2023-07-29T02:00:00+09:00")
assert.are.same({ assert.are.same({
{ count = 1, path = filepath(dir, "hoge2.txt"), score = 10 }, { count = 1, path = filepath(dir, "hoge2.txt"), score = 10 },
}, results) }, results)
@ -356,7 +369,7 @@ describe("frecency", function()
end) end)
describe("when db_safe_mode is false", function() describe("when db_safe_mode is false", function()
with_files({ "hoge1.txt", "hoge2.txt" }, function(frecency, dir) with_files({ "hoge1.txt", "hoge2.txt" }, function(frecency, finder, dir)
local register = make_register(frecency, dir) local register = make_register(frecency, dir)
register("hoge1.txt", "2023-07-29T00:00:00+09:00") register("hoge1.txt", "2023-07-29T00:00:00+09:00")
register("hoge2.txt", "2023-07-29T00:01:00+09:00") register("hoge2.txt", "2023-07-29T00:01:00+09:00")
@ -372,7 +385,7 @@ describe("frecency", function()
end) end)
it("needs no confirmation for removing entries", function() it("needs no confirmation for removing entries", function()
local results = frecency.picker:fetch_results(nil, "2023-07-29T02:00:00+09:00") local results = finder:get_results(nil, "2023-07-29T02:00:00+09:00")
assert.are.same({ assert.are.same({
{ count = 1, path = filepath(dir, "hoge2.txt"), score = 10 }, { count = 1, path = filepath(dir, "hoge2.txt"), score = 10 },
}, results) }, results)

View File

@ -6,6 +6,8 @@ local Sqlite = require "frecency.database.sqlite"
local Native = require "frecency.database.native" local Native = require "frecency.database.native"
local util = require "frecency.tests.util" local util = require "frecency.tests.util"
local wait = require "frecency.wait" local wait = require "frecency.wait"
-- TODO: replace this with vim.system
local Job = require "plenary.job"
---@param callback fun(migrator: FrecencyMigrator, sqlite: FrecencyDatabase): nil ---@param callback fun(migrator: FrecencyMigrator, sqlite: FrecencyDatabase): nil
---@return nil ---@return nil
@ -19,13 +21,27 @@ local function with(callback)
close() close()
end end
local function strptime(iso8601)
local result = vim.fn.strptime("%FT%T%z", iso8601)
return result ~= 0 and result or nil
end
-- NOTE: Windows has no strptime
local function time_piece(iso8601)
local stdout, code =
Job:new({ "perl", "-MTime::Piece", "-e", "print Time::Piece->strptime('" .. iso8601 .. "', '%FT%T%z')->epoch" })
:sync(30000)
return code == 0 and tonumber(stdout[1]) or nil
end
---@param source table<string,{ count: integer, timestamps: string[] }> ---@param source table<string,{ count: integer, timestamps: string[] }>
local function v1_table(source) local function v1_table(source)
local records = {} local records = {}
for path, record in pairs(source) do for path, record in pairs(source) do
local timestamps = {} local timestamps = {}
for _, timestamp in ipairs(record.timestamps) do for _, timestamp in ipairs(record.timestamps) do
table.insert(timestamps, vim.fn.strptime("%FT%T%z", timestamp)) local iso8601 = timestamp .. "+0000"
table.insert(timestamps, strptime(iso8601) or time_piece(iso8601))
end end
records[path] = { count = record.count, timestamps = timestamps } records[path] = { count = record.count, timestamps = timestamps }
end end
@ -44,11 +60,11 @@ describe("migrator", function()
it("has converted into a valid table", function() it("has converted into a valid table", function()
assert.are.same( assert.are.same(
native.table,
v1_table { v1_table {
["hoge1.txt"] = { count = 1, timestamps = { "2023-08-21T00:00:00+0000" } }, ["hoge1.txt"] = { count = 1, timestamps = { "2023-08-21T00:00:00" } },
["hoge2.txt"] = { count = 1, timestamps = { "2023-08-21T00:00:00+0000" } }, ["hoge2.txt"] = { count = 1, timestamps = { "2023-08-21T00:00:00" } },
}, }
native.table
) )
end) end)
end) end)
@ -75,13 +91,13 @@ describe("migrator", function()
it("has converted into a valid table", function() it("has converted into a valid table", function()
assert.are.same( assert.are.same(
native.table,
v1_table { v1_table {
["hoge1.txt"] = { count = 4, timestamps = { "2023-08-21T00:03:00+0000", "2023-08-21T00:04:00+0000" } }, ["hoge1.txt"] = { count = 4, timestamps = { "2023-08-21T00:03:00", "2023-08-21T00:04:00" } },
["hoge2.txt"] = { count = 3, timestamps = { "2023-08-21T00:06:00+0000", "2023-08-21T00:07:00+0000" } }, ["hoge2.txt"] = { count = 3, timestamps = { "2023-08-21T00:06:00", "2023-08-21T00:07:00" } },
["hoge3.txt"] = { count = 2, timestamps = { "2023-08-21T00:08:00+0000", "2023-08-21T00:09:00+0000" } }, ["hoge3.txt"] = { count = 2, timestamps = { "2023-08-21T00:08:00", "2023-08-21T00:09:00" } },
["hoge4.txt"] = { count = 1, timestamps = { "2023-08-21T00:10:00+0000" } }, ["hoge4.txt"] = { count = 1, timestamps = { "2023-08-21T00:10:00" } },
}, }
native.table
) )
end) end)
end) end)
@ -92,10 +108,10 @@ describe("migrator", function()
with(function(migrator, sqlite) with(function(migrator, sqlite)
local native = Native.new(migrator.fs, { root = migrator.root }) local native = Native.new(migrator.fs, { root = migrator.root })
native.table = v1_table { native.table = v1_table {
["hoge1.txt"] = { count = 4, timestamps = { "2023-08-21T00:03:00+0000", "2023-08-21T00:04:00+0000" } }, ["hoge1.txt"] = { count = 4, timestamps = { "2023-08-21T00:03:00", "2023-08-21T00:04:00" } },
["hoge2.txt"] = { count = 3, timestamps = { "2023-08-21T00:06:00+0000", "2023-08-21T00:07:00+0000" } }, ["hoge2.txt"] = { count = 3, timestamps = { "2023-08-21T00:06:00", "2023-08-21T00:07:00" } },
["hoge3.txt"] = { count = 2, timestamps = { "2023-08-21T00:08:00+0000", "2023-08-21T00:09:00+0000" } }, ["hoge3.txt"] = { count = 2, timestamps = { "2023-08-21T00:08:00", "2023-08-21T00:09:00" } },
["hoge4.txt"] = { count = 1, timestamps = { "2023-08-21T00:10:00+0000" } }, ["hoge4.txt"] = { count = 1, timestamps = { "2023-08-21T00:10:00" } },
} }
wait(function() wait(function()
native:save() native:save()
@ -114,7 +130,7 @@ describe("migrator", function()
]] ]]
it("has converted into a valid DB", function() it("has converted into a valid DB", function()
assert.are.same({ assert.are.same(records, {
{ path = "hoge1.txt", count = 4, datetime = "2023-08-21 00:03:00" }, { path = "hoge1.txt", count = 4, datetime = "2023-08-21 00:03:00" },
{ path = "hoge1.txt", count = 4, datetime = "2023-08-21 00:04:00" }, { path = "hoge1.txt", count = 4, datetime = "2023-08-21 00:04:00" },
{ path = "hoge2.txt", count = 3, datetime = "2023-08-21 00:06:00" }, { path = "hoge2.txt", count = 3, datetime = "2023-08-21 00:06:00" },
@ -122,7 +138,7 @@ describe("migrator", function()
{ path = "hoge3.txt", count = 2, datetime = "2023-08-21 00:08:00" }, { path = "hoge3.txt", count = 2, datetime = "2023-08-21 00:08:00" },
{ path = "hoge3.txt", count = 2, datetime = "2023-08-21 00:09:00" }, { path = "hoge3.txt", count = 2, datetime = "2023-08-21 00:09:00" },
{ path = "hoge4.txt", count = 1, datetime = "2023-08-21 00:10:00" }, { path = "hoge4.txt", count = 1, datetime = "2023-08-21 00:10:00" },
}, records) })
end) end)
end) end)
end) end)

View File

@ -7,7 +7,6 @@ end
if not vim.env.SQLITE_PATH then if not vim.env.SQLITE_PATH then
error "set $SQLITE_PATH to find telescope.nvim" error "set $SQLITE_PATH to find telescope.nvim"
end end
vim.opt.runtimepath:append "."
vim.opt.runtimepath:append(vim.env.PLENARY_PATH) vim.opt.runtimepath:append(vim.env.PLENARY_PATH)
vim.opt.runtimepath:append(vim.env.TELESCOPE_PATH) vim.opt.runtimepath:append(vim.env.TELESCOPE_PATH)
vim.opt.runtimepath:append(vim.env.SQLITE_PATH) vim.opt.runtimepath:append(vim.env.SQLITE_PATH)

View File

@ -0,0 +1,46 @@
local FS = require "frecency.fs"
local Native = require "frecency.database.native"
local async = require "plenary.async" --[[@as PlenaryAsync]]
local util = require "frecency.tests.util"
async.tests.add_to_env()
local function with_native(f)
local fs = FS.new { ignore_patterns = {} }
local dir, close = util.tmpdir()
dir:joinpath("file_frecency.bin"):touch()
return function()
local native = Native.new(fs, { root = dir.filename })
f(native)
close()
end
end
local function save_and_load(native, tbl, datetime)
native:raw_save(util.v1_table(tbl))
async.util.sleep(100)
local entries = native:get_entries(nil, datetime)
table.sort(entries, function(a, b)
return a.path < b.path
end)
return entries
end
a.describe("frecency.database.native", function()
a.describe("updated by another process", function()
a.it(
"returns valid entries",
with_native(function(native)
assert.are.same(
{
{ path = "hoge1.txt", count = 1, ages = { 60 } },
{ path = "hoge2.txt", count = 1, ages = { 60 } },
},
save_and_load(native, {
["hoge1.txt"] = { count = 1, timestamps = { "2023-08-21T00:00:00+0000" } },
["hoge2.txt"] = { count = 1, timestamps = { "2023-08-21T00:00:00+0000" } },
}, "2023-08-21T01:00:00+0000")
)
end)
)
end)
end)

View File

@ -1,5 +1,7 @@
local uv = vim.uv or vim.loop local uv = vim.uv or vim.loop
local async = require "plenary.async" --[[@as PlenaryAsync]]
local Path = require "plenary.path" local Path = require "plenary.path"
local Job = require "plenary.job"
---@return PlenaryPath ---@return PlenaryPath
---@return fun(): nil close swwp all entries ---@return fun(): nil close swwp all entries
@ -22,4 +24,35 @@ local function make_tree(entries)
return dir, close return dir, close
end end
return { make_tree = make_tree, tmpdir = tmpdir } local AsyncJob = async.wrap(function(cmd, callback)
return Job:new({
command = cmd[1],
args = { select(2, unpack(cmd)) },
on_exit = function(self, code, _)
local stdout = code == 0 and table.concat(self:result(), "\n") or nil
callback(stdout, code)
end,
}):start()
end, 2)
-- NOTE: vim.fn.strptime cannot be used in Lua loop
local function time_piece(iso8601)
local stdout, code =
AsyncJob { "perl", "-MTime::Piece", "-e", "print Time::Piece->strptime('" .. iso8601 .. "', '%FT%T%z')->epoch" }
return code == 0 and tonumber(stdout) or nil
end
---@param source table<string,{ count: integer, timestamps: string[] }>
local function v1_table(source)
local records = {}
for path, record in pairs(source) do
local timestamps = {}
for _, iso8601 in ipairs(record.timestamps) do
table.insert(timestamps, time_piece(iso8601))
end
records[path] = { count = record.count, timestamps = timestamps }
end
return { version = "v1", records = records }
end
return { make_tree = make_tree, tmpdir = tmpdir, v1_table = v1_table, time_piece = time_piece }

View File

@ -55,6 +55,7 @@
---@field parent PlenaryPath ---@field parent PlenaryPath
---@field path { sep: string } ---@field path { sep: string }
---@field rm fun(self: PlenaryPath, opts: { recursive: boolean }?): nil ---@field rm fun(self: PlenaryPath, opts: { recursive: boolean }?): nil
---@field touch fun(self: PlenaryPath, opts: { parents: boolean }?): nil
---@class PlenaryScanDirOptions ---@class PlenaryScanDirOptions
---@field hidden boolean if true hidden files will be added ---@field hidden boolean if true hidden files will be added
@ -70,9 +71,11 @@
---@class PlenaryAsync ---@class PlenaryAsync
---@field control PlenaryAsyncControl ---@field control PlenaryAsyncControl
---@field tests { add_to_env: fun(): nil }
---@field util PlenaryAsyncUtil ---@field util PlenaryAsyncUtil
---@field uv PlenaryAsyncUv ---@field uv PlenaryAsyncUv
---@field void fun(f: fun(): nil): fun(): nil ---@field void fun(f: fun(): nil): fun(): nil
---@field wrap fun(f: (fun(...): any), args: integer): (fun(...): any)
local PlenaryAsync = {} local PlenaryAsync = {}
---@async ---@async
@ -85,28 +88,42 @@ function PlenaryAsync.run(f) end
---@class PlenaryAsyncControlChannel ---@class PlenaryAsyncControlChannel
---@field mpsc fun(): PlenaryAsyncControlChannelTx, PlenaryAsyncControlChannelRx ---@field mpsc fun(): PlenaryAsyncControlChannelTx, PlenaryAsyncControlChannelRx
---@field counter fun(): PlenaryAsyncControlChannelTx, PlenaryAsyncControlChannelRx
---@class PlenaryAsyncControlChannelTx ---@class PlenaryAsyncControlChannelTx
---@field send fun(entry: FrecencyEntry?): nil ---@field send fun(entry: any?): nil
local PlenaryAsyncControlChannelTx = {} local PlenaryAsyncControlChannelTx = {}
---@class PlenaryAsyncControlChannelRx ---@class PlenaryAsyncControlChannelRx
local PlenaryAsyncControlChannelRx = {} local PlenaryAsyncControlChannelRx = {}
---@async ---@async
---@return FrecencyEntry? ---@return any?
function PlenaryAsyncControlChannelRx.recv() end function PlenaryAsyncControlChannelRx.recv() end
---@async
---@return any?
function PlenaryAsyncControlChannelRx.last() end
---@class PlenaryAsyncUtil ---@class PlenaryAsyncUtil
local PlenaryAsyncUtil = {} local PlenaryAsyncUtil = {}
---@class PlenaryAsyncUv ---@class PlenaryAsyncUv
local PlenaryAsyncUv = {} local PlenaryAsyncUv = {}
---@class FsStatMtime
---@field sec integer
---@field nsec integer
---@class FsStat
---@field mtime FsStatMtime
---@field size integer
---@field type "file"|"directory"
---@async ---@async
---@param path string ---@param path string
---@return string? err ---@return string? err
---@return { mtime: integer, size: integer, type: "file"|"directory" } ---@return { mtime: FsStatMtime, size: integer, type: "file"|"directory" }
function PlenaryAsyncUv.fs_stat(path) end function PlenaryAsyncUv.fs_stat(path) end
---@async ---@async
@ -184,3 +201,8 @@ function PlenaryAsyncUtil.sleep(ms) end
---@class WinInfo ---@class WinInfo
---@field topline integer ---@field topline integer
---@field botline integer ---@field botline integer
---@class UvFsEventHandle
---@field stop fun(self: UvFsEventHandle): nil
---@field start fun(self: UvFsEventHandle, path: string, opts: { recursive: boolean }, cb: fun(err: string?, filename: string?, events: string[])): nil
---@field close fun(self: UvFsEventHandle): nil