mirror of
https://github.com/kristoferssolo/telescope-frecency.nvim.git
synced 2025-10-21 20:10:38 +00:00
refactor!: use OO & add tests (#100)
* I did an overhall for all codes and added typing by Lua-language-server and tests. It also works on CI. * Now it searches files on the workspace completely asynchronously. It does not block your text input. (Fix #106) Make count = 1 when you open a file you've never opened (Fix #107)
This commit is contained in:
parent
1b1cf6aead
commit
1f32091e2b
60
.github/workflows/ci.yml
vendored
Normal file
60
.github/workflows/ci.yml
vendored
Normal file
@ -0,0 +1,60 @@
|
||||
name: CI
|
||||
on:
|
||||
- push
|
||||
- pull_request
|
||||
jobs:
|
||||
test:
|
||||
name: Run tests
|
||||
strategy:
|
||||
matrix:
|
||||
os:
|
||||
- ubuntu-latest
|
||||
# TODO: nix seems not to work with SIP
|
||||
# - macos-latest
|
||||
# TODO: PlenaryBustedDirectory seems not to run on Windows
|
||||
# - windows-latest
|
||||
version:
|
||||
- v0.9.0
|
||||
- nightly
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: Checkout plenary.nvim
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
repository: nvim-lua/plenary.nvim
|
||||
path: plenary.nvim
|
||||
- name: Checkout telescope.nvim
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
repository: nvim-telescope/telescope.nvim
|
||||
path: telescope.nvim
|
||||
- name: Checkout sqlite.lua
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
repository: kkharji/sqlite.lua
|
||||
path: sqlite.lua
|
||||
- name: Install Neovim
|
||||
uses: rhysd/action-setup-vim@v1
|
||||
id: nvim
|
||||
with:
|
||||
neovim: true
|
||||
version: ${{ matrix.version }}
|
||||
- name: Run tests
|
||||
env:
|
||||
PLENARY_PATH: plenary.nvim
|
||||
TELESCOPE_PATH: telescope.nvim
|
||||
SQLITE_PATH: sqlite.lua
|
||||
DEBUG_PLENARY: 1
|
||||
EXE: ${{ steps.nvim.outputs.executable }}
|
||||
run: |-
|
||||
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'}"
|
||||
- name: Type Check Code Base
|
||||
uses: mrcjkb/lua-typecheck-action@v0.2.0
|
||||
with:
|
||||
checkLevel: Hint
|
||||
configpath: .luarc.json
|
||||
76
.gitignore
vendored
Normal file
76
.gitignore
vendored
Normal file
@ -0,0 +1,76 @@
|
||||
### Generated by gibo (https://github.com/simonwhitaker/gibo)
|
||||
### https://raw.github.com/github/gitignore/4488915eec0b3a45b5c63ead28f286819c0917de/Lua.gitignore
|
||||
|
||||
# Compiled Lua sources
|
||||
luac.out
|
||||
|
||||
# luarocks build files
|
||||
*.src.rock
|
||||
*.zip
|
||||
*.tar.gz
|
||||
|
||||
# Object files
|
||||
*.o
|
||||
*.os
|
||||
*.ko
|
||||
*.obj
|
||||
*.elf
|
||||
|
||||
# Precompiled Headers
|
||||
*.gch
|
||||
*.pch
|
||||
|
||||
# Libraries
|
||||
*.lib
|
||||
*.a
|
||||
*.la
|
||||
*.lo
|
||||
*.def
|
||||
*.exp
|
||||
|
||||
# Shared objects (inc. Windows DLLs)
|
||||
*.dll
|
||||
*.so
|
||||
*.so.*
|
||||
*.dylib
|
||||
|
||||
# Executables
|
||||
*.exe
|
||||
*.out
|
||||
*.app
|
||||
*.i*86
|
||||
*.x86_64
|
||||
*.hex
|
||||
|
||||
|
||||
|
||||
### https://raw.github.com/github/gitignore/4488915eec0b3a45b5c63ead28f286819c0917de/Global/macOS.gitignore
|
||||
|
||||
# General
|
||||
.DS_Store
|
||||
.AppleDouble
|
||||
.LSOverride
|
||||
|
||||
# Icon must end with two \r
|
||||
Icon
|
||||
|
||||
# Thumbnails
|
||||
._*
|
||||
|
||||
# Files that might appear in the root of a volume
|
||||
.DocumentRevisions-V100
|
||||
.fseventsd
|
||||
.Spotlight-V100
|
||||
.TemporaryItems
|
||||
.Trashes
|
||||
.VolumeIcon.icns
|
||||
.com.apple.timemachine.donotpresent
|
||||
|
||||
# Directories potentially created on remote AFP share
|
||||
.AppleDB
|
||||
.AppleDesktop
|
||||
Network Trash Folder
|
||||
Temporary Items
|
||||
.apdisk
|
||||
|
||||
|
||||
14
.luarc.json
Normal file
14
.luarc.json
Normal file
@ -0,0 +1,14 @@
|
||||
{
|
||||
"diagnostics": {
|
||||
"globals": [
|
||||
"describe",
|
||||
"it",
|
||||
"vim"
|
||||
]
|
||||
},
|
||||
"runtime.version": "LuaJIT",
|
||||
"runtime.path": [
|
||||
"lua/?.lua",
|
||||
"lua/?/init.lua"
|
||||
]
|
||||
}
|
||||
@ -1,21 +0,0 @@
|
||||
local const = require "frecency.const"
|
||||
local algo = {}
|
||||
|
||||
algo.calculate_file_score = function(file)
|
||||
if not file.count or file.count == 0 then
|
||||
return 0
|
||||
end
|
||||
local recency_score = 0
|
||||
for _, ts in pairs(file.timestamps) do
|
||||
for _, rank in ipairs(const.recency_modifier) do
|
||||
if ts.age <= rank.age then
|
||||
recency_score = recency_score + rank.value
|
||||
goto continue
|
||||
end
|
||||
end
|
||||
::continue::
|
||||
end
|
||||
return file.count * recency_score / const.max_timestamps
|
||||
end
|
||||
|
||||
return algo
|
||||
98
lua/frecency/async_finder.lua
Normal file
98
lua/frecency/async_finder.lua
Normal file
@ -0,0 +1,98 @@
|
||||
local async = require "plenary.async"
|
||||
|
||||
---@class FrecencyAsyncFinder
|
||||
---@field closed boolean
|
||||
---@field entries FrecencyEntry[]
|
||||
---@field rx FrecencyRx
|
||||
---@overload fun(_: string, process_result: (fun(entry: FrecencyEntry): nil), process_complete: fun(): nil): nil
|
||||
local AsyncFinder = {}
|
||||
|
||||
---@class FrecencyRx
|
||||
---@field recv fun(): FrecencyEntry?
|
||||
|
||||
---@class FrecencyTx
|
||||
---@field send fun(entry: FrecencyEntry?): nil
|
||||
|
||||
---@param fs FrecencyFS
|
||||
---@param path string
|
||||
---@param entry_maker fun(file: FrecencyFile): FrecencyEntry
|
||||
---@param initial_results FrecencyFile[]
|
||||
---@return FrecencyAsyncFinder
|
||||
AsyncFinder.new = function(fs, path, entry_maker, initial_results)
|
||||
local self = setmetatable({ closed = false, entries = {} }, {
|
||||
__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
|
||||
---@type FrecencyTx, FrecencyRx
|
||||
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 = vim.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 = vim.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
|
||||
-- NOTE: This is needed not to lock text input.
|
||||
async.util.sleep(0)
|
||||
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 AsyncFinder
|
||||
@ -1,18 +0,0 @@
|
||||
return {
|
||||
max_timestamps = 10,
|
||||
db_remove_safety_threshold = 10,
|
||||
-- modifier used as a weight in the recency_score calculation:
|
||||
recency_modifier = {
|
||||
[1] = { age = 240, value = 100 }, -- past 4 hours
|
||||
[2] = { age = 1440, value = 80 }, -- past day
|
||||
[3] = { age = 4320, value = 60 }, -- past 3 days
|
||||
[4] = { age = 10080, value = 40 }, -- past week
|
||||
[5] = { age = 43200, value = 20 }, -- past month
|
||||
[6] = { age = 129600, value = 10 }, -- past 90 days
|
||||
},
|
||||
ignore_patterns = {
|
||||
"*.git/*",
|
||||
"*/tmp/*",
|
||||
"term://*",
|
||||
},
|
||||
}
|
||||
133
lua/frecency/database.lua
Normal file
133
lua/frecency/database.lua
Normal file
@ -0,0 +1,133 @@
|
||||
local sqlite = require "sqlite"
|
||||
local log = require "plenary.log"
|
||||
|
||||
---@class FrecencyDatabaseConfig
|
||||
---@field root string
|
||||
|
||||
---@class FrecencySqlite: sqlite_db
|
||||
---@field files sqlite_tbl
|
||||
---@field timestamps sqlite_tbl
|
||||
|
||||
---@class FrecencyFile
|
||||
---@field count integer
|
||||
---@field id integer
|
||||
---@field path string
|
||||
---@field score integer calculated from count and age
|
||||
|
||||
---@class FrecencyTimestamp
|
||||
---@field age integer calculated from timestamp
|
||||
---@field file_id integer
|
||||
---@field id integer
|
||||
---@field timestamp number
|
||||
|
||||
---@class FrecencyDatabaseGetFilesOptions
|
||||
---@field path string?
|
||||
---@field workspace string?
|
||||
|
||||
---@class FrecencyDatabase
|
||||
---@field config FrecencyDatabaseConfig
|
||||
---@field private buf_registered_flag_name string
|
||||
---@field private fs FrecencyFS
|
||||
---@field private sqlite FrecencySqlite
|
||||
local Database = {}
|
||||
|
||||
---@param fs FrecencyFS
|
||||
---@param config FrecencyDatabaseConfig
|
||||
---@return FrecencyDatabase
|
||||
Database.new = function(fs, config)
|
||||
local lib = sqlite.lib --[[@as sqlite_lib]]
|
||||
local self = setmetatable(
|
||||
{ config = config, buf_registered_flag_name = "telescope_frecency_registered", fs = fs },
|
||||
{ __index = Database }
|
||||
)
|
||||
self.sqlite = sqlite {
|
||||
uri = self.config.root .. "/file_frecency.sqlite3",
|
||||
files = { id = true, count = { "integer", default = 1, required = true }, path = "string" },
|
||||
timestamps = {
|
||||
id = true,
|
||||
file_id = { "integer", reference = "files.id", on_delete = "cascade" },
|
||||
timestamp = { "real", default = lib.julianday "now" },
|
||||
},
|
||||
}
|
||||
return self
|
||||
end
|
||||
|
||||
---@return boolean
|
||||
function Database:has_entry()
|
||||
return self.sqlite.files:count() > 0
|
||||
end
|
||||
|
||||
---@param paths string[]
|
||||
---@return integer
|
||||
function Database:insert_files(paths)
|
||||
---@param path string
|
||||
return self.sqlite.files:insert(vim.tbl_map(function(path)
|
||||
return { path = path, count = 0 } -- TODO: remove when sql.nvim#97 is closed
|
||||
end, paths))
|
||||
end
|
||||
|
||||
---@param workspace string?
|
||||
---@return FrecencyFile[]
|
||||
function Database:get_files(workspace)
|
||||
local query = workspace and { contains = { path = { workspace .. "/*" } } } or {}
|
||||
log.debug { query = query }
|
||||
return self.sqlite.files:get(query)
|
||||
end
|
||||
|
||||
---@param datetime string? ISO8601 format string
|
||||
---@return FrecencyTimestamp[]
|
||||
function Database:get_timestamps(datetime)
|
||||
local lib = sqlite.lib
|
||||
local age = lib.cast((lib.julianday(datetime) - lib.julianday "timestamp") * 24 * 60, "integer")
|
||||
return self.sqlite.timestamps:get { keys = { age = age, "id", "file_id" } }
|
||||
end
|
||||
|
||||
---@param path string
|
||||
---@return integer: id of the file entry
|
||||
---@return boolean: whether the entry is inserted (true) or updated (false)
|
||||
function Database:upsert_files(path)
|
||||
local file = self.sqlite.files:get({ where = { path = path } })[1] --[[@as FrecencyFile?]]
|
||||
if file then
|
||||
self.sqlite.files:update { where = { id = file.id }, set = { count = file.count + 1 } }
|
||||
return file.id, false
|
||||
end
|
||||
return self.sqlite.files:insert { path = path }, true
|
||||
end
|
||||
|
||||
---@param file_id integer
|
||||
---@param datetime string? ISO8601 format string
|
||||
---@return integer
|
||||
function Database:insert_timestamps(file_id, datetime)
|
||||
return self.sqlite.timestamps:insert {
|
||||
file_id = file_id,
|
||||
timestamp = datetime and sqlite.lib.julianday(datetime) or nil,
|
||||
}
|
||||
end
|
||||
|
||||
---@param file_id integer
|
||||
---@param max_count integer
|
||||
function Database:trim_timestamps(file_id, max_count)
|
||||
local timestamps = self.sqlite.timestamps:get { where = { file_id = file_id } } --[[@as FrecencyTimestamp[] ]]
|
||||
local trim_at = timestamps[#timestamps - max_count + 1]
|
||||
if trim_at then
|
||||
self.sqlite.timestamps:remove { file_id = tostring(file_id), id = "<" .. tostring(trim_at.id) }
|
||||
end
|
||||
end
|
||||
|
||||
---@return integer[]
|
||||
function Database:unlinked_entries()
|
||||
---@param file FrecencyFile
|
||||
return self.sqlite.files:map(function(file)
|
||||
if not self.fs:is_valid_path(file.path) then
|
||||
return file.id
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
---@param ids integer[]
|
||||
---@return nil
|
||||
function Database:remove_files(ids)
|
||||
self.sqlite.files:remove { id = ids }
|
||||
end
|
||||
|
||||
return Database
|
||||
@ -1,192 +0,0 @@
|
||||
local util = require "frecency.util"
|
||||
local const = require "frecency.const"
|
||||
local algo = require "frecency.algo"
|
||||
local sqlite = require "sqlite"
|
||||
local p = require "plenary.path"
|
||||
local s = sqlite.lib
|
||||
|
||||
---@class FrecencySqlite: sqlite_db
|
||||
---@field files sqlite_tbl
|
||||
---@field timestamps sqlite_tbl
|
||||
|
||||
---@class FrecencyDBConfig
|
||||
---@field db_root string: default "${stdpath.data}"
|
||||
---@field ignore_patterns table: extra ignore patterns: default empty
|
||||
---@field safe_mode boolean: When enabled, the user will be prompted when entries > 10, default true
|
||||
---@field auto_validate boolean: When this to false, stale entries will never be automatically removed, default true
|
||||
|
||||
---@class FrecencyDB
|
||||
---@field sqlite FrecencySqlite
|
||||
---@field config FrecencyConfig
|
||||
local db = {
|
||||
config = {
|
||||
db_root = vim.fn.stdpath "data",
|
||||
ignore_patterns = {},
|
||||
db_safe_mode = true,
|
||||
auto_validate = true,
|
||||
},
|
||||
}
|
||||
|
||||
---Set database configuration
|
||||
---@param config FrecencyDBConfig
|
||||
function db.set_config(config)
|
||||
db.config = vim.tbl_extend("keep", config, db.config)
|
||||
db.sqlite = sqlite {
|
||||
uri = db.config.db_root .. "/file_frecency.sqlite3",
|
||||
files = {
|
||||
id = true,
|
||||
count = { "integer", default = 0, required = true },
|
||||
path = "string",
|
||||
},
|
||||
timestamps = {
|
||||
id = true,
|
||||
timestamp = { "real", default = s.julianday "now" },
|
||||
file_id = { "integer", reference = "files.id", on_delete = "cascade" },
|
||||
},
|
||||
}
|
||||
end
|
||||
|
||||
---Get timestamps with a computed filed called age.
|
||||
---If file_id is nil, then get all timestamps.
|
||||
---@param opts table
|
||||
---- { file_id } number: id file_id corresponding to `files.id`. return all if { file_id } is nil
|
||||
---- { with_age } boolean: whether to include age, default false.
|
||||
---@return table { id, file_id, age }
|
||||
---@overload func()
|
||||
function db.get_timestamps(opts)
|
||||
opts = opts or {}
|
||||
local where = opts.file_id and { file_id = opts.file_id } or nil
|
||||
local compute_age = opts.with_age and s.cast((s.julianday() - s.julianday "timestamp") * 24 * 60, "integer") or nil
|
||||
return db.sqlite.timestamps:__get { where = where, keys = { age = compute_age, "id", "file_id" } }
|
||||
end
|
||||
|
||||
---Trim database entries
|
||||
---@param file_id any
|
||||
function db.trim_timestamps(file_id)
|
||||
local timestamps = db.get_timestamps { file_id = file_id, with_age = true }
|
||||
local trim_at = timestamps[(#timestamps - const.max_timestamps) + 1]
|
||||
if trim_at then
|
||||
db.sqlite.timestamps:remove { file_id = file_id, id = "<" .. trim_at.id }
|
||||
end
|
||||
end
|
||||
|
||||
---Get file entries
|
||||
---@param opts table:
|
||||
---- { ws_path } string: get files with matching workspace path.
|
||||
---- { show_unindexed } boolean: whether to include unindexed files, false if no ws_path is given.
|
||||
---- { with_score } boolean: whether to include score in the result and sort the files by score.
|
||||
---@overload func()
|
||||
---@return table[]: files entries
|
||||
function db.get_files(opts)
|
||||
opts = opts or {}
|
||||
local query = {}
|
||||
if opts.ws_path then
|
||||
query.contains = { path = { opts.ws_path .. "*" } }
|
||||
elseif opts.path then
|
||||
query.where = { path = opts.path }
|
||||
end
|
||||
local files = db.sqlite.files:__get(query)
|
||||
|
||||
if vim.F.if_nil(opts.with_score, true) then
|
||||
---NOTE: this might get slower with big db, it might be better to query with db.get_timestamp.
|
||||
---TODO: test the above assumption
|
||||
local timestamps = db.get_timestamps { with_age = true }
|
||||
for _, file in ipairs(files) do
|
||||
file.timestamps = util.tbl_match("file_id", file.id, timestamps)
|
||||
file.score = algo.calculate_file_score(file)
|
||||
end
|
||||
|
||||
table.sort(files, function(a, b)
|
||||
return a.score > b.score
|
||||
end)
|
||||
end
|
||||
|
||||
if opts.ws_path and opts.show_unindexed then
|
||||
util.include_unindexed(files, opts.ws_path)
|
||||
end
|
||||
|
||||
return files
|
||||
end
|
||||
---Insert or update a given path
|
||||
---@param path string
|
||||
---@return number: row id
|
||||
---@return boolean: true if it has inserted
|
||||
function db.insert_or_update_files(path)
|
||||
local entry = (db.get_files({ path = path })[1] or {})
|
||||
local file_id = entry.id
|
||||
local has_added_entry = not file_id
|
||||
|
||||
if file_id then
|
||||
db.sqlite.files:update { where = { id = file_id }, set = { count = entry.count + 1 } }
|
||||
else
|
||||
file_id = db.sqlite.files:insert { path = path }
|
||||
end
|
||||
return file_id, has_added_entry
|
||||
end
|
||||
|
||||
---Add or update file path
|
||||
---@param path string|nil: path to file or use current
|
||||
---@return boolean: true if it has added an entry
|
||||
---@overload func()
|
||||
function db.update(path)
|
||||
path = path or vim.fn.expand "%:p"
|
||||
if vim.b.telescope_frecency_registered or util.path_invalid(path, db.ignore_patterns) then
|
||||
-- print "ignoring autocmd"
|
||||
return
|
||||
else
|
||||
vim.b.telescope_frecency_registered = 1
|
||||
end
|
||||
--- Insert or update path
|
||||
local file_id, has_added_entry = db.insert_or_update_files(path)
|
||||
--- Register timestamp for this update.
|
||||
db.sqlite.timestamps:insert { file_id = file_id }
|
||||
--- Trim timestamps to max_timestamps per file
|
||||
db.trim_timestamps(file_id)
|
||||
return has_added_entry
|
||||
end
|
||||
|
||||
---Remove unlinked file entries, along with timestamps linking to it.
|
||||
---@param entries table[]|table|nil: if nil it will remove all entries
|
||||
---@param silent boolean: whether to notify user on changes made, default false
|
||||
function db.remove(entries, silent)
|
||||
if type(entries) == "nil" then
|
||||
local count = db.sqlite.files:count()
|
||||
db.sqlite.files:remove()
|
||||
if not vim.F.if_nil(silent, false) then
|
||||
vim.notify(("Telescope-frecency: removed all entries. number of entries removed %d ."):format(count))
|
||||
end
|
||||
return
|
||||
end
|
||||
|
||||
entries = (entries[1] and entries[1].id) and entries or { entries }
|
||||
|
||||
for _, entry in pairs(entries) do
|
||||
db.sqlite.files:remove { id = entry.id }
|
||||
end
|
||||
|
||||
if not vim.F.if_nil(silent, false) then
|
||||
vim.notify(("Telescope-frecency: removed %d missing entries."):format(#entries))
|
||||
end
|
||||
end
|
||||
|
||||
---Remove file entries that no longer exists.
|
||||
function db.validate(opts)
|
||||
opts = opts or {}
|
||||
|
||||
-- print "running validate"
|
||||
local threshold = const.db_remove_safety_threshold
|
||||
local unlinked = db.sqlite.files:map(function(entry)
|
||||
local invalid = (not util.path_exists(entry.path) or util.path_is_ignored(entry.path, db.ignore_patterns))
|
||||
return invalid and entry or nil
|
||||
end)
|
||||
|
||||
if #unlinked > 0 then
|
||||
if opts.force or not db.config.db_safe_mode or (#unlinked > threshold and util.confirm_deletion(#unlinked)) then
|
||||
db.remove(unlinked)
|
||||
elseif not opts.auto then
|
||||
util.abort_remove_unlinked_files()
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
return db
|
||||
127
lua/frecency/entry_maker.lua
Normal file
127
lua/frecency/entry_maker.lua
Normal file
@ -0,0 +1,127 @@
|
||||
local Path = require "plenary.path" --[[@as PlenaryPath]]
|
||||
local entry_display = require "telescope.pickers.entry_display" --[[@as TelescopeEntryDisplay]]
|
||||
local utils = require "telescope.utils" --[[@as TelescopeUtils]]
|
||||
|
||||
---@class FrecencyEntryMaker
|
||||
---@field config FrecencyEntryMakerConfig
|
||||
---@field fs FrecencyFS
|
||||
---@field loaded table<string,boolean>
|
||||
---@field web_devicons WebDevicons
|
||||
local EntryMaker = {}
|
||||
|
||||
---@class FrecencyEntryMakerConfig
|
||||
---@field show_filter_column boolean|string[]
|
||||
---@field show_scores boolean
|
||||
|
||||
---@param fs FrecencyFS
|
||||
---@param web_devicons WebDevicons
|
||||
---@param config FrecencyEntryMakerConfig
|
||||
---@return FrecencyEntryMaker
|
||||
EntryMaker.new = function(fs, web_devicons, config)
|
||||
local self = setmetatable({ config = config, fs = fs, web_devicons = web_devicons }, { __index = EntryMaker })
|
||||
local loaded_bufnrs = vim.tbl_filter(function(v)
|
||||
return vim.api.nvim_buf_is_loaded(v)
|
||||
end, vim.api.nvim_list_bufs())
|
||||
self.loaded = {}
|
||||
for _, bufnr in ipairs(loaded_bufnrs) do
|
||||
self.loaded[vim.api.nvim_buf_get_name(bufnr)] = true
|
||||
end
|
||||
return self
|
||||
end
|
||||
|
||||
---@class FrecencyEntry
|
||||
---@field filename string
|
||||
---@field index integer
|
||||
---@field ordinal string
|
||||
---@field name string
|
||||
---@field score number
|
||||
---@field display fun(entry: FrecencyEntry): string, table
|
||||
|
||||
---@param filepath_formatter FrecencyFilepathFormatter
|
||||
---@param workspace string?
|
||||
---@param workspace_tag string?
|
||||
---@return fun(file: FrecencyFile): FrecencyEntry
|
||||
function EntryMaker:create(filepath_formatter, workspace, workspace_tag)
|
||||
local displayer = entry_display.create {
|
||||
separator = "",
|
||||
hl_chars = { [Path.path.sep] = "TelescopePathSeparator" },
|
||||
items = self:displayer_items(workspace, workspace_tag),
|
||||
}
|
||||
|
||||
return function(file)
|
||||
return {
|
||||
filename = file.path,
|
||||
ordinal = file.path,
|
||||
name = file.path,
|
||||
score = file.score,
|
||||
---@param entry FrecencyEntry
|
||||
---@return table
|
||||
display = function(entry)
|
||||
local items = self:items(entry, workspace, workspace_tag, filepath_formatter(workspace))
|
||||
return displayer(items)
|
||||
end,
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
---@private
|
||||
---@param workspace string?
|
||||
---@param workspace_tag string?
|
||||
---@return table[]
|
||||
function EntryMaker:displayer_items(workspace, workspace_tag)
|
||||
local items = {}
|
||||
if self.config.show_scores then
|
||||
table.insert(items, { width = 8 })
|
||||
end
|
||||
if self.web_devicons.is_enabled then
|
||||
table.insert(items, { width = 2 })
|
||||
end
|
||||
if self.config.show_filter_column and workspace and workspace_tag then
|
||||
table.insert(items, { width = self:calculate_filter_column_width(workspace, workspace_tag) })
|
||||
end
|
||||
table.insert(items, { remaining = true })
|
||||
return items
|
||||
end
|
||||
|
||||
---@private
|
||||
---@param entry FrecencyEntry
|
||||
---@param workspace string?
|
||||
---@param workspace_tag string?
|
||||
---@param formatter fun(filename: string): string
|
||||
---@return table[]
|
||||
function EntryMaker:items(entry, workspace, workspace_tag, formatter)
|
||||
local items = {}
|
||||
if self.config.show_scores then
|
||||
table.insert(items, { entry.score, "TelescopeFrecencyScores" })
|
||||
end
|
||||
if self.web_devicons.is_enabled then
|
||||
table.insert(items, { self.web_devicons:get_icon(entry.name, entry.name:match "%a+$", { default = true }) })
|
||||
end
|
||||
if self.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
|
||||
table.insert(items, { filtered, "Directory" })
|
||||
end
|
||||
table.insert(items, { formatter(entry.name), self.loaded[entry.name] and "TelescopeBufferLoaded" or "" })
|
||||
return items
|
||||
end
|
||||
|
||||
---@private
|
||||
---@param workspace string
|
||||
---@param workspace_tag string
|
||||
---@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
|
||||
end
|
||||
|
||||
---@private
|
||||
---@param workspace_tag string
|
||||
---@return boolean
|
||||
function EntryMaker:should_show_tail(workspace_tag)
|
||||
local show_filter_column = self.config.show_filter_column
|
||||
local filters = type(show_filter_column) == "table" and show_filter_column or { "LSP", "CWD" }
|
||||
return vim.tbl_contains(filters, workspace_tag)
|
||||
end
|
||||
|
||||
return EntryMaker
|
||||
46
lua/frecency/finder.lua
Normal file
46
lua/frecency/finder.lua
Normal file
@ -0,0 +1,46 @@
|
||||
local AsyncFinder = require "frecency.async_finder"
|
||||
local finders = require "telescope.finders"
|
||||
local log = require "plenary.log"
|
||||
|
||||
---@class FrecencyFinder
|
||||
---@field private config FrecencyFinderConfig
|
||||
---@field private entry_maker FrecencyEntryMaker
|
||||
---@field private fs FrecencyFS
|
||||
local Finder = {}
|
||||
|
||||
---@class FrecencyFinderConfig
|
||||
---@field chunk_size integer
|
||||
|
||||
---@param entry_maker FrecencyEntryMaker
|
||||
---@param fs FrecencyFS
|
||||
---@param config FrecencyFinderConfig?
|
||||
---@return FrecencyFinder
|
||||
Finder.new = function(entry_maker, fs, config)
|
||||
return setmetatable(
|
||||
{ config = vim.tbl_extend("force", { chunk_size = 1000 }, config or {}), entry_maker = entry_maker, fs = fs },
|
||||
{ __index = Finder }
|
||||
)
|
||||
end
|
||||
|
||||
---@class FrecencyFinderOptions
|
||||
---@field need_scandir boolean
|
||||
---@field workspace string?
|
||||
---@field workspace_tag string?
|
||||
|
||||
---@param filepath_formatter FrecencyFilepathFormatter
|
||||
---@param initial_results table
|
||||
---@param opts FrecencyFinderOptions
|
||||
---@return table
|
||||
function Finder:start(filepath_formatter, initial_results, opts)
|
||||
local entry_maker = self.entry_maker:create(filepath_formatter, opts.workspace, opts.workspace_tag)
|
||||
if not opts.need_scandir then
|
||||
return finders.new_table {
|
||||
results = initial_results,
|
||||
entry_maker = entry_maker,
|
||||
}
|
||||
end
|
||||
log.debug { finder = opts }
|
||||
return AsyncFinder.new(self.fs, opts.workspace, entry_maker, initial_results)
|
||||
end
|
||||
|
||||
return Finder
|
||||
178
lua/frecency/frecency.lua
Normal file
178
lua/frecency/frecency.lua
Normal file
@ -0,0 +1,178 @@
|
||||
local Database = require "frecency.database"
|
||||
local EntryMaker = require "frecency.entry_maker"
|
||||
local FS = require "frecency.fs"
|
||||
local Finder = require "frecency.finder"
|
||||
local Picker = require "frecency.picker"
|
||||
local Recency = require "frecency.recency"
|
||||
local WebDevicons = require "frecency.web_devicons"
|
||||
|
||||
---@class Frecency
|
||||
---@field config FrecencyConfig
|
||||
---@field private buf_registered table<integer, boolean> flag to indicate the buffer is registered to the database.
|
||||
---@field private database FrecencyDatabase
|
||||
---@field private finder FrecencyFinder
|
||||
---@field private fs FrecencyFS
|
||||
---@field private picker FrecencyPicker
|
||||
---@field private recency FrecencyRecency
|
||||
local Frecency = {}
|
||||
|
||||
---@class FrecencyConfig
|
||||
---@field auto_validate boolean? default: true
|
||||
---@field db_root string? default: vim.fn.stdpath "data"
|
||||
---@field db_safe_mode boolean? default: true
|
||||
---@field db_validate_threshold? integer default: 10
|
||||
---@field default_workspace string? default: nil
|
||||
---@field disable_devicons boolean? default: false
|
||||
---@field filter_delimiter string? default: ":"
|
||||
---@field ignore_patterns string[]? default: { "*.git/*", "*/tmp/*", "term://*" }
|
||||
---@field show_filter_column boolean|string[]|nil default: true
|
||||
---@field show_scores boolean? default: false
|
||||
---@field show_unindexed boolean? default: true
|
||||
---@field workspaces table<string, string>? default: {}
|
||||
|
||||
---@param opts FrecencyConfig?
|
||||
---@return Frecency
|
||||
Frecency.new = function(opts)
|
||||
---@type FrecencyConfig
|
||||
local config = vim.tbl_extend("force", {
|
||||
auto_validate = true,
|
||||
db_root = vim.fn.stdpath "data",
|
||||
db_safe_mode = true,
|
||||
db_validate_threshold = 10,
|
||||
default_workspace = nil,
|
||||
disable_devicons = false,
|
||||
filter_delimiter = ":",
|
||||
ignore_patterns = { "*.git/*", "*/tmp/*", "term://*" },
|
||||
show_filter_column = true,
|
||||
show_scores = false,
|
||||
show_unindexed = true,
|
||||
workspaces = {},
|
||||
}, opts or {})
|
||||
local self = setmetatable({ buf_registered = {}, config = config }, { __index = Frecency })--[[@as Frecency]]
|
||||
self.fs = FS.new { ignore_patterns = config.ignore_patterns }
|
||||
self.database = Database.new(self.fs, { root = config.db_root })
|
||||
local web_devicons = WebDevicons.new(not config.disable_devicons)
|
||||
local entry_maker = EntryMaker.new(self.fs, web_devicons, {
|
||||
show_filter_column = config.show_filter_column,
|
||||
show_scores = config.show_scores,
|
||||
})
|
||||
self.finder = Finder.new(entry_maker, self.fs)
|
||||
self.recency = Recency.new()
|
||||
return self
|
||||
end
|
||||
|
||||
---@return nil
|
||||
function Frecency:setup()
|
||||
-- TODO: Should we schedule this after loading shada?
|
||||
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
|
||||
|
||||
---@param cmd_info { bang: boolean }
|
||||
vim.api.nvim_create_user_command("FrecencyValidate", function(cmd_info)
|
||||
self:validate_database(cmd_info.bang)
|
||||
end, { bang = true, desc = "Clean up DB for telescope-frecency" })
|
||||
|
||||
if self.config.auto_validate then
|
||||
self:validate_database()
|
||||
end
|
||||
|
||||
local group = vim.api.nvim_create_augroup("TelescopeFrecency", {})
|
||||
vim.api.nvim_create_autocmd({ "BufWinEnter", "BufWritePost" }, {
|
||||
desc = "Update database for telescope-frecency",
|
||||
group = group,
|
||||
---@param args { buf: integer }
|
||||
callback = function(args)
|
||||
self:register(args.buf)
|
||||
end,
|
||||
})
|
||||
end
|
||||
|
||||
---@param opts FrecencyPickerOptions
|
||||
---@return nil
|
||||
function Frecency:start(opts)
|
||||
self.picker = Picker.new(self.database, self.finder, self.fs, self.recency, {
|
||||
default_workspace_tag = self.config.default_workspace,
|
||||
editing_bufnr = vim.api.nvim_get_current_buf(),
|
||||
filter_delimiter = self.config.filter_delimiter,
|
||||
initial_workspace_tag = opts.workspace,
|
||||
show_unindexed = self.config.show_unindexed,
|
||||
workspaces = self.config.workspaces,
|
||||
})
|
||||
self.picker:start(opts)
|
||||
end
|
||||
|
||||
---@param findstart 1|0
|
||||
---@param base string
|
||||
---@return integer|''|string[]
|
||||
function Frecency:complete(findstart, base)
|
||||
return self.picker:complete(findstart, base)
|
||||
end
|
||||
|
||||
---@private
|
||||
---@param force boolean?
|
||||
---@return nil
|
||||
function Frecency:validate_database(force)
|
||||
local unlinked = self.database:unlinked_entries()
|
||||
if #unlinked == 0 or (not force and #unlinked < self.config.db_validate_threshold) then
|
||||
return
|
||||
end
|
||||
local function remove_entries()
|
||||
self.database:remove_files(unlinked)
|
||||
self:notify("removed %d missing entries.", #unlinked)
|
||||
end
|
||||
if force and not self.config.db_safe_mode then
|
||||
remove_entries()
|
||||
return
|
||||
end
|
||||
vim.ui.select({ "y", "n" }, {
|
||||
prompt = self:message("remove %d entries from SQLite3 database?", #unlinked),
|
||||
---@param item "y"|"n"
|
||||
---@return string
|
||||
format_item = function(item)
|
||||
return item == "y" and "Yes. Remove them." or "No. Do nothing."
|
||||
end,
|
||||
}, function(item)
|
||||
if item == "y" then
|
||||
remove_entries()
|
||||
else
|
||||
self:notify "validation aborted"
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
---@private
|
||||
---@param bufnr integer
|
||||
---@param datetime string? ISO8601 format string
|
||||
function Frecency:register(bufnr, datetime)
|
||||
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
|
||||
local id, inserted = self.database:upsert_files(path)
|
||||
self.database:insert_timestamps(id, datetime)
|
||||
self.database:trim_timestamps(id, self.recency.config.max_count)
|
||||
if inserted and self.picker then
|
||||
self.picker:discard_results()
|
||||
end
|
||||
self.buf_registered[bufnr] = true
|
||||
end
|
||||
|
||||
---@private
|
||||
---@param fmt string
|
||||
---@param ... any?
|
||||
---@return string
|
||||
function Frecency:message(fmt, ...)
|
||||
return ("[Telescope-Frecency] " .. fmt):format(unpack { ... })
|
||||
end
|
||||
|
||||
---@private
|
||||
---@param fmt string
|
||||
---@param ... any?
|
||||
---@return nil
|
||||
function Frecency:notify(fmt, ...)
|
||||
vim.notify(self:message(fmt, ...))
|
||||
end
|
||||
|
||||
return Frecency
|
||||
89
lua/frecency/fs.lua
Normal file
89
lua/frecency/fs.lua
Normal file
@ -0,0 +1,89 @@
|
||||
local Path = require "plenary.path" --[[@as PlenaryPath]]
|
||||
local scandir = require "plenary.scandir"
|
||||
local log = require "plenary.log"
|
||||
local uv = vim.uv or vim.loop
|
||||
|
||||
---@class FrecencyFS
|
||||
---@field os_homedir string
|
||||
---@field private config FrecencyFSConfig
|
||||
---@field private ignore_regexes string[]
|
||||
local FS = {}
|
||||
|
||||
---@class FrecencyFSConfig
|
||||
---@field scan_depth integer?
|
||||
---@field ignore_patterns string[]
|
||||
|
||||
---@param config FrecencyFSConfig
|
||||
---@return FrecencyFS
|
||||
FS.new = function(config)
|
||||
local self = setmetatable(
|
||||
{ config = vim.tbl_extend("force", { scan_depth = 100 }, config), os_homedir = assert(uv.os_homedir()) },
|
||||
{ __index = FS }
|
||||
)
|
||||
---@param pattern string
|
||||
self.ignore_regexes = vim.tbl_map(function(pattern)
|
||||
local escaped = pattern:gsub("[%-%.%+%[%]%(%)%$%^%%%?%*]", "%%%1")
|
||||
local regex = escaped:gsub("%%%*", ".*"):gsub("%%%?", ".")
|
||||
return "^" .. regex .. "$"
|
||||
end, self.config.ignore_patterns)
|
||||
return self
|
||||
end
|
||||
|
||||
---@param path string
|
||||
---@return boolean
|
||||
function FS:is_valid_path(path)
|
||||
return Path:new(path):is_file() and not self:is_ignored(path)
|
||||
end
|
||||
|
||||
---@param path string
|
||||
---@return function
|
||||
function FS:scan_dir(path)
|
||||
log.debug { path = path }
|
||||
local gitignore = self:make_gitignore(path)
|
||||
return coroutine.wrap(function()
|
||||
for name, type in
|
||||
vim.fs.dir(path, {
|
||||
depth = self.config.scan_depth,
|
||||
skip = function(dirname)
|
||||
if self:is_ignored(vim.fs.joinpath(path, dirname)) then
|
||||
return false
|
||||
end
|
||||
end,
|
||||
})
|
||||
do
|
||||
local fullpath = vim.fs.joinpath(path, name)
|
||||
if type == "file" and not self:is_ignored(fullpath) and gitignore({ path }, fullpath) then
|
||||
coroutine.yield(name)
|
||||
end
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
---@param path string
|
||||
---@return string
|
||||
function FS:relative_from_home(path)
|
||||
return Path:new(path):make_relative(self.os_homedir)
|
||||
end
|
||||
|
||||
---@private
|
||||
---@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
|
||||
end
|
||||
|
||||
---@private
|
||||
---@param basepath string
|
||||
---@return fun(base_paths: string[], entry: string): boolean
|
||||
function FS:make_gitignore(basepath)
|
||||
return scandir.__make_gitignore { basepath } or function(_, _)
|
||||
return true
|
||||
end
|
||||
end
|
||||
|
||||
return FS
|
||||
29
lua/frecency/init.lua
Normal file
29
lua/frecency/init.lua
Normal file
@ -0,0 +1,29 @@
|
||||
---@type Frecency?
|
||||
local frecency
|
||||
|
||||
return {
|
||||
---@param opts FrecencyConfig?
|
||||
setup = function(opts)
|
||||
frecency = require("frecency.frecency").new(opts)
|
||||
frecency:setup()
|
||||
end,
|
||||
---@param opts FrecencyPickerOptions
|
||||
start = function(opts)
|
||||
if frecency then
|
||||
frecency:start(opts)
|
||||
end
|
||||
end,
|
||||
---@param findstart 1|0
|
||||
---@param base string
|
||||
---@return integer|''|string[]
|
||||
complete = function(findstart, base)
|
||||
if frecency then
|
||||
return frecency:complete(findstart, base)
|
||||
end
|
||||
return ""
|
||||
end,
|
||||
---@return Frecency
|
||||
frecency = function()
|
||||
return assert(frecency)
|
||||
end,
|
||||
}
|
||||
@ -1,338 +1,299 @@
|
||||
local has_devicons, devicons = pcall(require, "nvim-web-devicons")
|
||||
local p = require "plenary.path"
|
||||
local util = require "frecency.util"
|
||||
local os_home = vim.loop.os_homedir()
|
||||
local os_path_sep = p.path.sep
|
||||
local log = require "plenary.log"
|
||||
local Path = require "plenary.path" --[[@as PlenaryPath]]
|
||||
local actions = require "telescope.actions"
|
||||
local conf = require("telescope.config").values
|
||||
local entry_display = require "telescope.pickers.entry_display"
|
||||
local finders = require "telescope.finders"
|
||||
local config_values = require("telescope.config").values
|
||||
local pickers = require "telescope.pickers"
|
||||
local sorters = require "telescope.sorters"
|
||||
local ts_util = require "telescope.utils"
|
||||
local db = require "frecency.db"
|
||||
|
||||
---TODO: Describe FrecencyPicker fields
|
||||
local utils = require "telescope.utils" --[[@as TelescopeUtils]]
|
||||
local uv = vim.loop or vim.uv
|
||||
|
||||
---@class FrecencyPicker
|
||||
---@field db FrecencyDB: where the files will be stored
|
||||
---@field results table
|
||||
---@field active_filter string
|
||||
---@field active_filter_tag string
|
||||
---@field previous_buffer string
|
||||
---@field cwd string
|
||||
---@field lsp_workspaces table
|
||||
---@field picker table
|
||||
---@field updated boolean: true if a new entry is added into DB
|
||||
local m = {
|
||||
results = {},
|
||||
active_filter = nil,
|
||||
active_filter_tag = nil,
|
||||
last_filter = nil,
|
||||
previous_buffer = nil,
|
||||
cwd = nil,
|
||||
---@field private config FrecencyPickerConfig
|
||||
---@field private database FrecencyDatabase
|
||||
---@field private finder FrecencyFinder
|
||||
---@field private fs FrecencyFS
|
||||
---@field private lsp_workspaces string[]
|
||||
---@field private recency FrecencyRecency
|
||||
---@field private results table[]
|
||||
---@field private workspace string?
|
||||
---@field private workspace_tag_regex string
|
||||
local Picker = {}
|
||||
|
||||
---@class FrecencyPickerConfig
|
||||
---@field default_workspace_tag string?
|
||||
---@field editing_bufnr integer
|
||||
---@field filter_delimiter string
|
||||
---@field initial_workspace_tag string?
|
||||
---@field show_unindexed boolean
|
||||
---@field workspaces table<string, string>
|
||||
|
||||
---@class FrecencyPickerEntry
|
||||
---@field display fun(entry: FrecencyPickerEntry): string
|
||||
---@field filename string
|
||||
---@field name string
|
||||
---@field ordinal string
|
||||
---@field score number
|
||||
|
||||
---@param database FrecencyDatabase
|
||||
---@param finder FrecencyFinder
|
||||
---@param fs FrecencyFS
|
||||
---@param recency FrecencyRecency
|
||||
---@param config FrecencyPickerConfig
|
||||
---@return FrecencyPicker
|
||||
Picker.new = function(database, finder, fs, recency, config)
|
||||
local self = setmetatable({
|
||||
config = config,
|
||||
database = database,
|
||||
finder = finder,
|
||||
fs = fs,
|
||||
lsp_workspaces = {},
|
||||
picker = {},
|
||||
updated = false,
|
||||
}
|
||||
|
||||
m.__index = m
|
||||
|
||||
---@class FrecencyConfig
|
||||
---@field show_unindexed boolean: default true
|
||||
---@field show_filter_column boolean|string[]: default true
|
||||
---@field workspaces table: default {}
|
||||
---@field disable_devicons boolean: default false
|
||||
---@field default_workspace string: default nil
|
||||
m.config = {
|
||||
show_scores = true,
|
||||
show_unindexed = true,
|
||||
show_filter_column = true,
|
||||
workspaces = {},
|
||||
disable_devicons = false,
|
||||
default_workspace = nil,
|
||||
}
|
||||
|
||||
---Setup frecency picker
|
||||
m.set_prompt_options = function(buffer)
|
||||
vim.bo[buffer].filetype = "frecency"
|
||||
vim.bo[buffer].completefunc = "v:lua.require'telescope'.extensions.frecency.complete"
|
||||
vim.keymap.set("i", "<Tab>", "pumvisible() ? '<C-n>' : '<C-x><C-u>'", { buffer = buffer, expr = true })
|
||||
vim.keymap.set("i", "<S-Tab>", "pumvisible() ? '<C-p>' : ''", { buffer = buffer, expr = true })
|
||||
recency = recency,
|
||||
results = {},
|
||||
}, { __index = Picker })
|
||||
local d = self.config.filter_delimiter
|
||||
self.workspace_tag_regex = "^%s*(" .. d .. "(%S+)" .. d .. ")"
|
||||
return self
|
||||
end
|
||||
|
||||
---returns `true` if workspaces exit
|
||||
---@param bufnr integer
|
||||
---@param force boolean?
|
||||
---@return boolean workspaces_exist
|
||||
m.fetch_lsp_workspaces = function(bufnr, force)
|
||||
if not vim.tbl_isempty(m.lsp_workspaces) and not force then
|
||||
return true
|
||||
end
|
||||
---@class FrecencyPickerOptions
|
||||
---@field cwd string
|
||||
---@field path_display
|
||||
---| "hidden"
|
||||
---| "tail"
|
||||
---| "absolute"
|
||||
---| "smart"
|
||||
---| "shorten"
|
||||
---| "truncate"
|
||||
---| fun(opts: FrecencyPickerOptions, path: string): string
|
||||
---@field workspace string?
|
||||
|
||||
local lsp_workspaces = vim.api.nvim_buf_call(bufnr, vim.lsp.buf.list_workspace_folders)
|
||||
if not vim.tbl_isempty(lsp_workspaces) then
|
||||
m.lsp_workspaces = lsp_workspaces
|
||||
return true
|
||||
end
|
||||
|
||||
m.lsp_workspaces = {}
|
||||
return false
|
||||
end
|
||||
|
||||
---Update Frecency Picker result
|
||||
---@param filter string
|
||||
---@return boolean
|
||||
m.update = function(filter)
|
||||
local filter_updated = false
|
||||
local ws_dir = filter and m.config.workspaces[filter] or nil
|
||||
|
||||
if filter == "LSP" and not vim.tbl_isempty(m.lsp_workspaces) then
|
||||
ws_dir = m.lsp_workspaces[1]
|
||||
elseif filter == "CWD" then
|
||||
ws_dir = m.cwd
|
||||
end
|
||||
|
||||
if ws_dir ~= m.active_filter then
|
||||
filter_updated = true
|
||||
m.active_filter, m.active_filter_tag = ws_dir, filter
|
||||
end
|
||||
|
||||
m.results = (vim.tbl_isempty(m.results) or m.updated or filter_updated)
|
||||
and db.get_files { ws_path = ws_dir, show_unindexed = m.config.show_unindexed }
|
||||
or m.results
|
||||
|
||||
return filter_updated
|
||||
end
|
||||
|
||||
---@param opts table telescope picker table
|
||||
---@return fun(filename: string): string
|
||||
m.filepath_formatter = function(opts)
|
||||
local path_opts = {}
|
||||
for k, v in pairs(opts) do
|
||||
path_opts[k] = v
|
||||
end
|
||||
|
||||
return function(filename)
|
||||
path_opts.cwd = m.active_filter or m.cwd
|
||||
return ts_util.transform_path(path_opts, filename)
|
||||
end
|
||||
end
|
||||
|
||||
m.should_show_tail = function()
|
||||
local filters = type(m.config.show_filter_column) == "table" and m.config.show_filter_column or { "LSP", "CWD" }
|
||||
return vim.tbl_contains(filters, m.active_filter_tag)
|
||||
end
|
||||
|
||||
---Create entry maker function.
|
||||
---@param entry table
|
||||
---@return function
|
||||
m.maker = function(entry)
|
||||
local filter_column_width = (function()
|
||||
if m.active_filter then
|
||||
if m.should_show_tail() then
|
||||
-- TODO: Only add +1 if m.show_filter_thing is true, +1 is for the trailing slash
|
||||
return #(ts_util.path_tail(m.active_filter)) + 1
|
||||
end
|
||||
return #(p:new(m.active_filter):make_relative(os_home)) + 1
|
||||
end
|
||||
return 0
|
||||
end)()
|
||||
|
||||
local displayer = entry_display.create {
|
||||
separator = "",
|
||||
hl_chars = { [os_path_sep] = "TelescopePathSeparator" },
|
||||
items = (function()
|
||||
local i = m.config.show_scores and { { width = 8 } } or {}
|
||||
if has_devicons and not m.config.disable_devicons then
|
||||
table.insert(i, { width = 2 })
|
||||
end
|
||||
if m.config.show_filter_column then
|
||||
table.insert(i, { width = filter_column_width })
|
||||
end
|
||||
table.insert(i, { remaining = true })
|
||||
return i
|
||||
end)(),
|
||||
}
|
||||
|
||||
local filter_path = (function()
|
||||
if m.config.show_filter_column and m.active_filter then
|
||||
return m.should_show_tail() and ts_util.path_tail(m.active_filter) .. os_path_sep
|
||||
or p:new(m.active_filter):make_relative(os_home) .. os_path_sep
|
||||
end
|
||||
return ""
|
||||
end)()
|
||||
|
||||
local formatter = m.filepath_formatter(m.opts)
|
||||
|
||||
return {
|
||||
filename = entry.path,
|
||||
ordinal = entry.path,
|
||||
name = entry.path,
|
||||
score = entry.score,
|
||||
display = function(e)
|
||||
return displayer((function()
|
||||
local i = m.config.show_scores and { { entry.score, "TelescopeFrecencyScores" } } or {}
|
||||
if has_devicons and not m.config.disable_devicons then
|
||||
table.insert(i, { devicons.get_icon(e.name, string.match(e.name, "%a+$"), { default = true }) })
|
||||
end
|
||||
if m.config.show_filter_column then
|
||||
table.insert(i, { filter_path, "Directory" })
|
||||
end
|
||||
table.insert(i, {
|
||||
formatter(e.name),
|
||||
util.buf_is_loaded(e.name) and "TelescopeBufferLoaded" or "",
|
||||
})
|
||||
return i
|
||||
end)())
|
||||
---@param opts FrecencyPickerOptions?
|
||||
function Picker:start(opts)
|
||||
opts = vim.tbl_extend("force", {
|
||||
cwd = uv.cwd(),
|
||||
path_display = function(picker_opts, path)
|
||||
return self:default_path_display(picker_opts, path)
|
||||
end,
|
||||
}
|
||||
}, opts or {}) --[[@as FrecencyPickerOptions]]
|
||||
self.lsp_workspaces = {}
|
||||
local workspace = self:get_workspace(opts.cwd, self.config.initial_workspace_tag)
|
||||
log.debug { workspace = workspace, ["self.workspace"] = self.workspace }
|
||||
if vim.tbl_isempty(self.results) or workspace ~= self.workspace then
|
||||
self.workspace = workspace
|
||||
self.results = self:fetch_results(self.workspace)
|
||||
end
|
||||
|
||||
---Find files
|
||||
---@param opts table: telescope picker opts
|
||||
m.fd = function(opts)
|
||||
opts = opts or {}
|
||||
local filepath_formatter = self:filepath_formatter(opts)
|
||||
local finder = self.finder:start(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,
|
||||
})
|
||||
|
||||
if not opts.path_display then
|
||||
opts.path_display = function(path_opts, filename)
|
||||
local original_filename = filename
|
||||
local picker = pickers.new(opts, {
|
||||
prompt_title = "Frecency",
|
||||
finder = finder,
|
||||
previewer = config_values.file_previewer(opts),
|
||||
sorter = sorters.get_substr_matcher(),
|
||||
on_input_filter_cb = self:on_input_filter_cb(opts),
|
||||
attach_mappings = function(prompt_bufnr)
|
||||
return self:attach_mappings(prompt_bufnr)
|
||||
end,
|
||||
})
|
||||
picker:find()
|
||||
self:set_prompt_options(picker.prompt_bufnr)
|
||||
end
|
||||
|
||||
filename = p:new(filename):make_relative(path_opts.cwd)
|
||||
if not m.active_filter then
|
||||
if vim.startswith(filename, os_home) then
|
||||
filename = "~/" .. p:new(filename):make_relative(os_home)
|
||||
elseif filename ~= original_filename then
|
||||
function Picker:discard_results()
|
||||
self.results = {}
|
||||
end
|
||||
|
||||
--- See :h 'complete-functions'
|
||||
---@param findstart 1|0
|
||||
---@param base string
|
||||
---@return integer|string[]|''
|
||||
function Picker:complete(findstart, base)
|
||||
if findstart == 1 then
|
||||
local delimiter = self.config.filter_delimiter
|
||||
local line = vim.api.nvim_get_current_line()
|
||||
local start = line:find(delimiter)
|
||||
-- don't complete if there's already a completed `:tag:` in line
|
||||
if not start or line:find(delimiter, start + 1) then
|
||||
return -3
|
||||
end
|
||||
return start
|
||||
elseif vim.fn.pumvisible() == 1 and #vim.v.completed_item > 0 then
|
||||
return ""
|
||||
end
|
||||
---@param v string
|
||||
local matches = vim.tbl_filter(function(v)
|
||||
return vim.startswith(v, base)
|
||||
end, self:workspace_tags())
|
||||
return #matches > 0 and matches or ""
|
||||
end
|
||||
|
||||
---@private
|
||||
---@return string[]
|
||||
function Picker:workspace_tags()
|
||||
local tags = vim.tbl_keys(self.config.workspaces)
|
||||
table.insert(tags, "CWD")
|
||||
if self:get_lsp_workspace() then
|
||||
table.insert(tags, "LSP")
|
||||
end
|
||||
return tags
|
||||
end
|
||||
|
||||
---@private
|
||||
---@param opts FrecencyPickerOptions
|
||||
---@param path string
|
||||
---@return string
|
||||
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 = "~/" .. self.fs:relative_from_home(filename)
|
||||
elseif filename ~= path then
|
||||
filename = "./" .. filename
|
||||
end
|
||||
end
|
||||
|
||||
return filename
|
||||
end
|
||||
|
||||
---@private
|
||||
---@param cwd string
|
||||
---@param tag string?
|
||||
---@return string?
|
||||
function Picker:get_workspace(cwd, tag)
|
||||
if not tag then
|
||||
return nil
|
||||
elseif self.config.workspaces[tag] then
|
||||
return self.config.workspaces[tag]
|
||||
elseif tag == "LSP" then
|
||||
return self:get_lsp_workspace()
|
||||
elseif tag == "CWD" then
|
||||
return cwd
|
||||
end
|
||||
end
|
||||
|
||||
m.previous_buffer, m.cwd, m.opts = vim.fn.bufnr "%", vim.fn.expand(opts.cwd or vim.loop.cwd()), opts
|
||||
-- TODO: should we update this every time it calls frecency on other buffers?
|
||||
m.fetch_lsp_workspaces(m.previous_buffer)
|
||||
m.update()
|
||||
---@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_files = os.clock()
|
||||
local files = self.database:get_files(workspace)
|
||||
log.debug { files = #files }
|
||||
log.debug(("it takes %f seconds in fetching files with workspace: %s"):format(os.clock() - start_files, workspace))
|
||||
local start_timesatmps = os.clock()
|
||||
local timestamps = self.database:get_timestamps(datetime)
|
||||
log.debug { timestamps = #timestamps }
|
||||
log.debug(("it takes %f seconds in fetching all timestamps"):format(os.clock() - start_timesatmps))
|
||||
local start_results = os.clock()
|
||||
local elapsed_recency = 0
|
||||
---@type table<integer,number[]>
|
||||
local age_map = {}
|
||||
for _, timestamp in ipairs(timestamps) do
|
||||
if not age_map[timestamp.file_id] then
|
||||
age_map[timestamp.file_id] = {}
|
||||
end
|
||||
table.insert(age_map[timestamp.file_id], timestamp.age)
|
||||
end
|
||||
for _, file in ipairs(files) do
|
||||
local start_recency = os.clock()
|
||||
file.score = self.recency:calculate(file.count, age_map[file.id])
|
||||
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 picker_opts = {
|
||||
prompt_title = "Frecency",
|
||||
finder = finders.new_table { results = m.results, entry_maker = m.maker },
|
||||
previewer = conf.file_previewer(opts),
|
||||
sorter = sorters.get_substr_matcher(opts),
|
||||
}
|
||||
|
||||
picker_opts.on_input_filter_cb = function(query_text)
|
||||
local o = {}
|
||||
local delim = m.config.filter_delimiter or ":" -- check for :filter: in query text
|
||||
local matched, new_filter = query_text:match("^%s*(" .. delim .. "(%S+)" .. delim .. ")")
|
||||
new_filter = new_filter or opts.workspace or m.config.default_workspace
|
||||
|
||||
o.prompt = matched and query_text:sub(matched:len() + 1) or query_text
|
||||
if m.update(new_filter) then
|
||||
m.last_filter = new_filter
|
||||
o.updated_finder = finders.new_table { results = m.results, entry_maker = m.maker }
|
||||
local start_sort = os.clock()
|
||||
table.sort(files, function(a, b)
|
||||
return a.score > b.score
|
||||
end)
|
||||
log.debug(("it takes %f seconds in sorting"):format(os.clock() - start_sort))
|
||||
return files
|
||||
end
|
||||
|
||||
return o
|
||||
---@private
|
||||
---@return string?
|
||||
function Picker:get_lsp_workspace()
|
||||
if vim.tbl_isempty(self.lsp_workspaces) then
|
||||
self.lsp_workspaces = vim.api.nvim_buf_call(self.config.editing_bufnr, vim.lsp.buf.list_workspace_folders)
|
||||
end
|
||||
return self.lsp_workspaces[1]
|
||||
end
|
||||
|
||||
picker_opts.attach_mappings = function(prompt_bufnr)
|
||||
---@private
|
||||
---@param picker_opts table
|
||||
---@return fun(prompt: string): table
|
||||
function Picker:on_input_filter_cb(picker_opts)
|
||||
local filepath_formatter = self:filepath_formatter(picker_opts)
|
||||
return function(prompt)
|
||||
local workspace
|
||||
local matched, tag = prompt:match(self.workspace_tag_regex)
|
||||
local opts = { prompt = matched and prompt:sub(matched:len() + 1) or prompt }
|
||||
if prompt == "" then
|
||||
workspace = self:get_workspace(picker_opts.cwd, self.config.initial_workspace_tag)
|
||||
else
|
||||
workspace = self:get_workspace(picker_opts.cwd, tag or self.config.default_workspace_tag) or self.workspace
|
||||
end
|
||||
if self.workspace ~= workspace then
|
||||
self.workspace = workspace
|
||||
self.results = self:fetch_results(workspace)
|
||||
opts.updated_finder = self.finder:start(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
|
||||
return opts
|
||||
end
|
||||
end
|
||||
|
||||
---@private
|
||||
---@param _ integer
|
||||
---@return boolean
|
||||
function Picker:attach_mappings(_)
|
||||
actions.select_default:replace_if(function()
|
||||
return vim.fn.complete_info().pum_visible == 1
|
||||
end, function()
|
||||
local keys = vim.fn.complete_info().selected == -1 and "<C-e><Bs><Right>" or "<C-y><Right>:"
|
||||
local keys = vim.fn.complete_info().selected == -1 and "<C-e><BS><Right>" or "<C-y><Right>:"
|
||||
local accept_completion = vim.api.nvim_replace_termcodes(keys, true, false, true)
|
||||
vim.api.nvim_feedkeys(accept_completion, "n", true)
|
||||
end)
|
||||
return true
|
||||
end
|
||||
|
||||
m.picker = pickers.new(opts, picker_opts)
|
||||
m.picker:find()
|
||||
m.set_prompt_options(m.picker.prompt_bufnr)
|
||||
---@private
|
||||
---@param bufnr integer
|
||||
---@return nil
|
||||
function Picker:set_prompt_options(bufnr)
|
||||
vim.bo[bufnr].filetype = "frecency"
|
||||
vim.bo[bufnr].completefunc = "v:lua.require'telescope'.extensions.frecency.complete"
|
||||
vim.keymap.set("i", "<Tab>", "pumvisible() ? '<C-n>' : '<C-x><C-u>'", { buffer = bufnr, expr = true })
|
||||
vim.keymap.set("i", "<S-Tab>", "pumvisible() ? '<C-p>' : ''", { buffer = bufnr, expr = true })
|
||||
end
|
||||
|
||||
---TODO: this seems to be forgotten and just exported in old implementation.
|
||||
---@return table
|
||||
m.workspace_tags = function()
|
||||
-- Add user config workspaces.
|
||||
-- TODO: validate that workspaces are existing directories
|
||||
local tags = {}
|
||||
for k, _ in pairs(m.config.workspaces) do
|
||||
table.insert(tags, k)
|
||||
---@alias FrecencyFilepathFormatter fun(workspace: string?): fun(filename: string): string): string
|
||||
|
||||
---@private
|
||||
---@param picker_opts table
|
||||
---@return FrecencyFilepathFormatter
|
||||
function Picker:filepath_formatter(picker_opts)
|
||||
---@param workspace string?
|
||||
return function(workspace)
|
||||
local opts = {}
|
||||
for k, v in pairs(picker_opts) do
|
||||
opts[k] = v
|
||||
end
|
||||
opts.cwd = workspace or self.fs.os_homedir
|
||||
|
||||
-- Add CWD filter
|
||||
-- NOTE: hmmm :cwd::lsp: is easier to write.
|
||||
table.insert(tags, "CWD")
|
||||
|
||||
-- Add LSP workpace(s)
|
||||
if m.fetch_lsp_workspaces(m.previous_buffer, true) then
|
||||
table.insert(tags, "LSP")
|
||||
return function(filename)
|
||||
return utils.transform_path(opts, filename)
|
||||
end
|
||||
|
||||
-- TODO: sort tags - by collective frecency? (?????? is this still relevant)
|
||||
return tags
|
||||
end
|
||||
|
||||
m.complete = function(findstart, base)
|
||||
if findstart == 1 then
|
||||
local line = vim.api.nvim_get_current_line()
|
||||
local start = line:find ":"
|
||||
-- don't complete if there's already a completed `:tag:` in line
|
||||
if not start or line:find(":", start + 1) then
|
||||
return -3
|
||||
end
|
||||
return start
|
||||
else
|
||||
if vim.fn.pumvisible() == 1 and #vim.v.completed_item > 0 then
|
||||
return ""
|
||||
end
|
||||
|
||||
local matches = vim.tbl_filter(function(v)
|
||||
return vim.startswith(v, base)
|
||||
end, m.workspace_tags())
|
||||
|
||||
return #matches > 0 and matches or ""
|
||||
end
|
||||
end
|
||||
|
||||
---Setup Frecency Picker
|
||||
---@param db FrecencyDB
|
||||
---@param config FrecencyConfig
|
||||
m.setup = function(config)
|
||||
m.config = vim.tbl_extend("keep", config, m.config)
|
||||
db.set_config(config)
|
||||
|
||||
--- Seed files table with oldfiles when it's empty.
|
||||
if db.sqlite.files:count() == 0 then
|
||||
-- TODO: this needs to be scheduled for after shada load??
|
||||
for _, path in ipairs(vim.v.oldfiles) do
|
||||
db.sqlite.files:insert { path = path, count = 0 } -- TODO: remove when sql.nvim#97 is closed
|
||||
end
|
||||
vim.notify(("Telescope-Frecency: Imported %d entries from oldfiles."):format(#vim.v.oldfiles))
|
||||
end
|
||||
|
||||
-- TODO: perhaps ignore buffer without file path here?
|
||||
local group = vim.api.nvim_create_augroup("TelescopeFrecency", {})
|
||||
vim.api.nvim_create_autocmd({ "BufWinEnter", "BufWritePost" }, {
|
||||
group = group,
|
||||
callback = function(args)
|
||||
local path = vim.api.nvim_buf_get_name(args.buf)
|
||||
local has_added_entry = db.update(path)
|
||||
m.updated = m.updated or has_added_entry
|
||||
end,
|
||||
})
|
||||
|
||||
vim.api.nvim_create_user_command("FrecencyValidate", function(cmd_info)
|
||||
db.validate { force = cmd_info.bang }
|
||||
end, { bang = true, desc = "Clean up DB for telescope-frecency" })
|
||||
|
||||
if db.config.auto_validate then
|
||||
db.validate { auto = true }
|
||||
end
|
||||
end
|
||||
|
||||
return m
|
||||
return Picker
|
||||
|
||||
42
lua/frecency/recency.lua
Normal file
42
lua/frecency/recency.lua
Normal file
@ -0,0 +1,42 @@
|
||||
---@class FrecencyRecency
|
||||
---@field config FrecencyRecencyConfig
|
||||
---@field private modifier table<integer, { age: integer, value: integer }>
|
||||
local Recency = {}
|
||||
|
||||
---@class FrecencyRecencyConfig
|
||||
---@field max_count integer default: 10
|
||||
|
||||
---@param config FrecencyRecencyConfig?
|
||||
---@return FrecencyRecency
|
||||
Recency.new = function(config)
|
||||
return setmetatable({
|
||||
config = vim.tbl_extend("force", { max_count = 10 }, config or {}),
|
||||
modifier = {
|
||||
{ age = 240, value = 100 }, -- past 4 hours
|
||||
{ age = 1440, value = 80 }, -- past day
|
||||
{ age = 4320, value = 60 }, -- past 3 days
|
||||
{ age = 10080, value = 40 }, -- past week
|
||||
{ age = 43200, value = 20 }, -- past month
|
||||
{ age = 129600, value = 10 }, -- past 90 days
|
||||
},
|
||||
}, { __index = Recency })
|
||||
end
|
||||
|
||||
---@param count integer
|
||||
---@param ages number[]
|
||||
---@return number
|
||||
function Recency:calculate(count, ages)
|
||||
local score = 0
|
||||
for _, age in ipairs(ages) do
|
||||
for _, rank in ipairs(self.modifier) do
|
||||
if age <= rank.age then
|
||||
score = score + rank.value
|
||||
goto continue
|
||||
end
|
||||
end
|
||||
::continue::
|
||||
end
|
||||
return count * score / self.config.max_count
|
||||
end
|
||||
|
||||
return Recency
|
||||
105
lua/frecency/tests/async_finder_spec.lua
Normal file
105
lua/frecency/tests/async_finder_spec.lua
Normal file
@ -0,0 +1,105 @@
|
||||
---@diagnostic disable: invisible
|
||||
local AsyncFinder = require "frecency.async_finder"
|
||||
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(fs, dir:absolute(), entry_maker, initials)
|
||||
callback(async_finder, dir)
|
||||
close()
|
||||
end
|
||||
|
||||
describe("async_finder", function()
|
||||
---@diagnostic disable-next-line: param-type-mismatch
|
||||
if vim.version.eq(vim.version(), "0.9.0") then
|
||||
it("skips these tests for v0.9.0", function()
|
||||
assert.are.same(true, true)
|
||||
end)
|
||||
return
|
||||
end
|
||||
|
||||
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)
|
||||
373
lua/frecency/tests/frecency_spec.lua
Normal file
373
lua/frecency/tests/frecency_spec.lua
Normal file
@ -0,0 +1,373 @@
|
||||
---@diagnostic disable: invisible
|
||||
local Frecency = require "frecency.frecency"
|
||||
local Picker = require "frecency.picker"
|
||||
local util = require "frecency.tests.util"
|
||||
local Path = require "plenary.path"
|
||||
local log = require "plenary.log"
|
||||
|
||||
---@param files string[]
|
||||
---@param callback fun(frecency: Frecency, dir: PlenaryPath): nil
|
||||
---@return nil
|
||||
local function with_files(files, callback)
|
||||
local dir, close = util.make_tree(files)
|
||||
local frecency = Frecency.new { db_root = dir.filename }
|
||||
frecency.picker = Picker.new(
|
||||
frecency.database,
|
||||
frecency.finder,
|
||||
frecency.fs,
|
||||
frecency.recency,
|
||||
{ editing_bufnr = 0, filter_delimiter = ":", show_unindexed = false, workspaces = {} }
|
||||
)
|
||||
callback(frecency, dir)
|
||||
close()
|
||||
end
|
||||
|
||||
local function filepath(dir, file)
|
||||
return dir:joinpath(file):absolute()
|
||||
end
|
||||
|
||||
---@param frecency Frecency
|
||||
---@param dir PlenaryPath
|
||||
---@return fun(file: string, datetime: string, reset: boolean?): nil
|
||||
local function make_register(frecency, dir)
|
||||
return function(file, datetime, 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, datetime)
|
||||
end
|
||||
end
|
||||
|
||||
---comment
|
||||
---@param frecency Frecency
|
||||
---@param dir PlenaryPath
|
||||
---@param callback fun(register: fun(file: string, datetime: string?): 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
|
||||
local function register(file, datetime)
|
||||
local path = filepath(dir, file)
|
||||
Path.new(path):touch()
|
||||
bufnr = bufnr + 1
|
||||
buffers[bufnr] = path
|
||||
frecency:register(bufnr, datetime)
|
||||
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
|
||||
|
||||
describe("frecency", function()
|
||||
describe("register", function()
|
||||
describe("when opening files", function()
|
||||
with_files({ "hoge1.txt", "hoge2.txt" }, function(frecency, dir)
|
||||
local register = make_register(frecency, dir)
|
||||
register("hoge1.txt", "2023-07-29T00:00:00+09:00")
|
||||
register("hoge2.txt", "2023-07-29T01:00:00+09:00")
|
||||
|
||||
it("has valid records in DB", function()
|
||||
local results = frecency.picker:fetch_results(nil, "2023-07-29T02:00:00+09:00")
|
||||
assert.are.same({
|
||||
{ count = 1, id = 1, path = filepath(dir, "hoge1.txt"), score = 10 },
|
||||
{ count = 1, id = 2, path = filepath(dir, "hoge2.txt"), score = 10 },
|
||||
}, results)
|
||||
end)
|
||||
end)
|
||||
end)
|
||||
|
||||
describe("when opening again", function()
|
||||
with_files({ "hoge1.txt", "hoge2.txt" }, function(frecency, dir)
|
||||
local register = make_register(frecency, dir)
|
||||
register("hoge1.txt", "2023-07-29T00: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)
|
||||
|
||||
it("increases the score", function()
|
||||
local results = frecency.picker:fetch_results(nil, "2023-07-29T03:00:00+09:00")
|
||||
assert.are.same({
|
||||
{ count = 2, id = 1, path = filepath(dir, "hoge1.txt"), score = 40 },
|
||||
{ count = 1, id = 2, path = filepath(dir, "hoge2.txt"), score = 10 },
|
||||
}, results)
|
||||
end)
|
||||
end)
|
||||
end)
|
||||
|
||||
describe("when opening again but the same instance", function()
|
||||
with_files({ "hoge1.txt", "hoge2.txt" }, function(frecency, dir)
|
||||
local register = make_register(frecency, dir)
|
||||
register("hoge1.txt", "2023-07-29T00:00:00+09:00")
|
||||
register("hoge2.txt", "2023-07-29T01:00:00+09:00")
|
||||
register("hoge1.txt", "2023-07-29T02:00:00+09:00")
|
||||
|
||||
it("does not increase the score", function()
|
||||
local results = frecency.picker:fetch_results(nil, "2023-07-29T03:00:00+09:00")
|
||||
assert.are.same({
|
||||
{ count = 1, id = 1, path = filepath(dir, "hoge1.txt"), score = 10 },
|
||||
{ count = 1, id = 2, path = filepath(dir, "hoge2.txt"), score = 10 },
|
||||
}, results)
|
||||
end)
|
||||
end)
|
||||
end)
|
||||
|
||||
describe("when opening more than 10 times", function()
|
||||
with_files({ "hoge1.txt", "hoge2.txt" }, function(frecency, dir)
|
||||
local register = make_register(frecency, dir)
|
||||
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:02:00+09:00", true)
|
||||
register("hoge1.txt", "2023-07-29T00:03:00+09:00", true)
|
||||
register("hoge1.txt", "2023-07-29T00:04:00+09:00", true)
|
||||
register("hoge1.txt", "2023-07-29T00:05:00+09:00", true)
|
||||
register("hoge1.txt", "2023-07-29T00:06:00+09:00", true)
|
||||
register("hoge1.txt", "2023-07-29T00:07:00+09:00", true)
|
||||
register("hoge1.txt", "2023-07-29T00:08:00+09:00", true)
|
||||
register("hoge1.txt", "2023-07-29T00:09:00+09:00", true)
|
||||
register("hoge1.txt", "2023-07-29T00:10:00+09:00", true)
|
||||
register("hoge1.txt", "2023-07-29T00:11:00+09:00", true)
|
||||
|
||||
it("calculates score from the recent 10 times", function()
|
||||
local results = frecency.picker:fetch_results(nil, "2023-07-29T00:12:00+09:00")
|
||||
assert.are.same({
|
||||
{ count = 12, id = 1, path = filepath(dir, "hoge1.txt"), score = 12 * (10 * 100) / 10 },
|
||||
}, results)
|
||||
end)
|
||||
end)
|
||||
end)
|
||||
end)
|
||||
|
||||
describe("benchmark", function()
|
||||
describe("after registered over >5000 files", function()
|
||||
with_files({}, function(frecency, dir)
|
||||
with_fake_register(frecency, dir, function(register)
|
||||
local file_count = 6000
|
||||
if not os.getenv "CI" then
|
||||
log.info "It works not on CI. Files is decreaed into 10 count."
|
||||
file_count = 10
|
||||
end
|
||||
local expected = {}
|
||||
for i = 1, file_count do
|
||||
local file = ("hoge%08d.txt"):format(i)
|
||||
table.insert(expected, { count = 1, id = i, path = filepath(dir, file), score = 10 })
|
||||
register(file, "2023-07-29T00:00:00+09:00")
|
||||
end
|
||||
local start = os.clock()
|
||||
local results = frecency.picker:fetch_results(nil, "2023-07-29T00:01:00+09:00")
|
||||
table.sort(results, function(a, b)
|
||||
return a.path < b.path
|
||||
end)
|
||||
local elapsed = os.clock() - start
|
||||
log.info(("it takes %f seconds in fetching all results"):format(elapsed))
|
||||
|
||||
it("returns appropriate latency (<1.0 second)", function()
|
||||
assert.are.is_true(elapsed < 1.0)
|
||||
end)
|
||||
|
||||
it("returns valid response", function()
|
||||
assert.are.same(expected, results)
|
||||
end)
|
||||
end)
|
||||
end)
|
||||
end)
|
||||
end)
|
||||
|
||||
describe("validate_database", function()
|
||||
describe("when no files are unlinked", function()
|
||||
with_files({ "hoge1.txt", "hoge2.txt" }, function(frecency, dir)
|
||||
local register = make_register(frecency, dir)
|
||||
register("hoge1.txt", "2023-07-29T00:00:00+09:00")
|
||||
register("hoge2.txt", "2023-07-29T00:01:00+09:00")
|
||||
|
||||
it("removes no entries", function()
|
||||
local results = frecency.picker:fetch_results(nil, "2023-07-29T02:00:00+09:00")
|
||||
assert.are.same({
|
||||
{ count = 1, id = 1, path = filepath(dir, "hoge1.txt"), score = 10 },
|
||||
{ count = 1, id = 2, path = filepath(dir, "hoge2.txt"), score = 10 },
|
||||
}, 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" }, function(frecency, dir)
|
||||
local register = make_register(frecency, dir)
|
||||
register("hoge1.txt", "2023-07-29T00:00:00+09:00")
|
||||
register("hoge2.txt", "2023-07-29T00:01:00+09:00")
|
||||
register("hoge3.txt", "2023-07-29T00:02:00+09:00")
|
||||
register("hoge4.txt", "2023-07-29T00:03:00+09:00")
|
||||
register("hoge5.txt", "2023-07-29T00:04:00+09:00")
|
||||
frecency.config.db_validate_threshold = 3
|
||||
dir:joinpath("hoge1.txt"):rm()
|
||||
dir:joinpath("hoge2.txt"):rm()
|
||||
frecency:validate_database()
|
||||
|
||||
it("removes no entries", function()
|
||||
local results = frecency.picker:fetch_results(nil, "2023-07-29T02:00:00+09:00")
|
||||
table.sort(results, function(a, b)
|
||||
return a.path < b.path
|
||||
end)
|
||||
assert.are.same({
|
||||
{ count = 1, id = 1, path = filepath(dir, "hoge1.txt"), score = 10 },
|
||||
{ count = 1, id = 2, path = filepath(dir, "hoge2.txt"), score = 10 },
|
||||
{ count = 1, id = 3, path = filepath(dir, "hoge3.txt"), score = 10 },
|
||||
{ count = 1, id = 4, path = filepath(dir, "hoge4.txt"), score = 10 },
|
||||
{ count = 1, id = 5, path = filepath(dir, "hoge5.txt"), score = 10 },
|
||||
}, 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" }, function(frecency, dir)
|
||||
local register = make_register(frecency, dir)
|
||||
register("hoge1.txt", "2023-07-29T00:00:00+09:00")
|
||||
register("hoge2.txt", "2023-07-29T00:01:00+09:00")
|
||||
register("hoge3.txt", "2023-07-29T00:02:00+09:00")
|
||||
register("hoge4.txt", "2023-07-29T00:03:00+09:00")
|
||||
register("hoge5.txt", "2023-07-29T00:04:00+09:00")
|
||||
frecency.config.db_validate_threshold = 3
|
||||
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 = frecency.picker:fetch_results(nil, "2023-07-29T02:00:00+09:00")
|
||||
table.sort(results, function(a, b)
|
||||
return a.path < b.path
|
||||
end)
|
||||
assert.are.same({
|
||||
{ count = 1, id = 4, path = filepath(dir, "hoge4.txt"), score = 10 },
|
||||
{ count = 1, id = 5, path = filepath(dir, "hoge5.txt"), score = 10 },
|
||||
}, results)
|
||||
end)
|
||||
end)
|
||||
end)
|
||||
|
||||
describe('when the user response "no"', function()
|
||||
with_files({ "hoge1.txt", "hoge2.txt", "hoge3.txt", "hoge4.txt", "hoge5.txt" }, function(frecency, dir)
|
||||
local register = make_register(frecency, dir)
|
||||
register("hoge1.txt", "2023-07-29T00:00:00+09:00")
|
||||
register("hoge2.txt", "2023-07-29T00:01:00+09:00")
|
||||
register("hoge3.txt", "2023-07-29T00:02:00+09:00")
|
||||
register("hoge4.txt", "2023-07-29T00:03:00+09:00")
|
||||
register("hoge5.txt", "2023-07-29T00:04:00+09:00")
|
||||
frecency.config.db_validate_threshold = 3
|
||||
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 = frecency.picker:fetch_results(nil, "2023-07-29T02:00:00+09:00")
|
||||
table.sort(results, function(a, b)
|
||||
return a.path < b.path
|
||||
end)
|
||||
assert.are.same({
|
||||
{ count = 1, id = 1, path = filepath(dir, "hoge1.txt"), score = 10 },
|
||||
{ count = 1, id = 2, path = filepath(dir, "hoge2.txt"), score = 10 },
|
||||
{ count = 1, id = 3, path = filepath(dir, "hoge3.txt"), score = 10 },
|
||||
{ count = 1, id = 4, path = filepath(dir, "hoge4.txt"), score = 10 },
|
||||
{ count = 1, id = 5, path = filepath(dir, "hoge5.txt"), score = 10 },
|
||||
}, 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, dir)
|
||||
local register = make_register(frecency, dir)
|
||||
register("hoge1.txt", "2023-07-29T00:00:00+09:00")
|
||||
register("hoge2.txt", "2023-07-29T00:01:00+09:00")
|
||||
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 = frecency.picker:fetch_results(nil, "2023-07-29T02:00:00+09:00")
|
||||
assert.are.same({
|
||||
{ count = 1, id = 2, path = filepath(dir, "hoge2.txt"), score = 10 },
|
||||
}, results)
|
||||
end)
|
||||
end)
|
||||
end)
|
||||
|
||||
describe("when db_safe_mode is false", function()
|
||||
with_files({ "hoge1.txt", "hoge2.txt" }, function(frecency, dir)
|
||||
local register = make_register(frecency, dir)
|
||||
register("hoge1.txt", "2023-07-29T00:00:00+09:00")
|
||||
register("hoge2.txt", "2023-07-29T00:01:00+09:00")
|
||||
dir:joinpath("hoge1.txt"):rm()
|
||||
|
||||
with_fake_vim_ui_select("y", function(called)
|
||||
frecency.config.db_safe_mode = false
|
||||
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 = frecency.picker:fetch_results(nil, "2023-07-29T02:00:00+09:00")
|
||||
assert.are.same({
|
||||
{ count = 1, id = 2, path = filepath(dir, "hoge2.txt"), score = 10 },
|
||||
}, results)
|
||||
end)
|
||||
end)
|
||||
end)
|
||||
end)
|
||||
end)
|
||||
end)
|
||||
14
lua/frecency/tests/minimal.lua
Normal file
14
lua/frecency/tests/minimal.lua
Normal file
@ -0,0 +1,14 @@
|
||||
if not vim.env.PLENARY_PATH then
|
||||
error "set $PLENARY_PATH to find plenary.nvim"
|
||||
end
|
||||
if not vim.env.TELESCOPE_PATH then
|
||||
error "set $TELESCOPE_PATH to find telescope.nvim"
|
||||
end
|
||||
if not vim.env.SQLITE_PATH then
|
||||
error "set $SQLITE_PATH to find telescope.nvim"
|
||||
end
|
||||
vim.opt.runtimepath:append "."
|
||||
vim.opt.runtimepath:append(vim.env.PLENARY_PATH)
|
||||
vim.opt.runtimepath:append(vim.env.TELESCOPE_PATH)
|
||||
vim.opt.runtimepath:append(vim.env.SQLITE_PATH)
|
||||
vim.cmd.runtime "plugin/plenary.vim"
|
||||
19
lua/frecency/tests/recency_spec.lua
Normal file
19
lua/frecency/tests/recency_spec.lua
Normal file
@ -0,0 +1,19 @@
|
||||
local Recency = require "frecency.recency"
|
||||
local recency = Recency.new()
|
||||
|
||||
describe("frecency.recency", function()
|
||||
for _, c in ipairs {
|
||||
{ count = 1, ages = { 200 }, score = 10 },
|
||||
{ count = 2, ages = { 200, 1000 }, score = 36 },
|
||||
{ count = 3, ages = { 200, 1000, 4000 }, score = 72 },
|
||||
{ count = 4, ages = { 200, 1000, 4000, 10000 }, score = 112 },
|
||||
{ count = 5, ages = { 200, 1000, 4000, 10000, 40000 }, score = 150 },
|
||||
{ count = 6, ages = { 200, 1000, 4000, 10000, 40000, 100000 }, score = 186 },
|
||||
{ count = 86, ages = { 11770, 11769, 11431, 5765, 3417, 3398, 3378, 134, 130, 9 }, score = 4988 },
|
||||
} do
|
||||
local dumped = vim.inspect(c.ages, { indent = "", newline = "" })
|
||||
it(("%d, %s => %d"):format(c.count, dumped, c.score), function()
|
||||
assert.are.same(c.score, recency:calculate(c.count, c.ages))
|
||||
end)
|
||||
end
|
||||
end)
|
||||
17
lua/frecency/tests/util.lua
Normal file
17
lua/frecency/tests/util.lua
Normal file
@ -0,0 +1,17 @@
|
||||
local uv = vim.uv or vim.loop
|
||||
local Path = require "plenary.path"
|
||||
|
||||
---@param entries string[]
|
||||
---@return PlenaryPath the top dir of tree
|
||||
---@return fun(): nil sweep all entries
|
||||
local function make_tree(entries)
|
||||
local dir = Path:new(Path.new(assert(uv.fs_mkdtemp "tests_XXXXXX")):absolute())
|
||||
for _, entry in ipairs(entries) do
|
||||
dir:joinpath(entry):touch { parents = true }
|
||||
end
|
||||
return dir, function()
|
||||
dir:rm { recursive = true }
|
||||
end
|
||||
end
|
||||
|
||||
return { make_tree = make_tree }
|
||||
83
lua/frecency/types.lua
Normal file
83
lua/frecency/types.lua
Normal file
@ -0,0 +1,83 @@
|
||||
-- NOTE: types below are borrowed from sqlite.lua
|
||||
|
||||
---@class sqlite_db @Main sqlite.lua object.
|
||||
---@field uri string: database uri. it can be an environment variable or an absolute path. default ":memory:"
|
||||
---@field opts sqlite_opts: see https://www.sqlite.org/pragma.html |sqlite_opts|
|
||||
---@field conn sqlite_blob: sqlite connection c object.
|
||||
---@field db sqlite_db: reference to fallback to when overwriting |sqlite_db| methods (extended only).
|
||||
|
||||
---@class sqlite_query_update @Query fileds used when calling |sqlite:update| or |sqlite_tbl:update|
|
||||
---@field where table: filter down values using key values.
|
||||
---@field set table: key and value to updated.
|
||||
|
||||
---@class sqlite_query_select @Query fileds used when calling |sqlite:select| or |sqlite_tbl:get|
|
||||
---@field where table? filter down values using key values.
|
||||
---@field keys table? keys to include. (default all)
|
||||
---@field join table? (TODO: support)
|
||||
---@field order_by table? { asc = "key", dsc = {"key", "another_key"} }
|
||||
---@field limit number? the number of result to limit by
|
||||
---@field contains table? for sqlite glob ex. { title = "fix*" }
|
||||
|
||||
---@alias sqlite_query_delete table<string, any>
|
||||
|
||||
---@generic T
|
||||
---@alias sqlite_map_func fun(self: sqlite_tbl, mapper: fun(entry: table): T?): T[]
|
||||
|
||||
---@class sqlite_tbl @Main sql table class
|
||||
---@field db sqlite_db: sqlite.lua database object.
|
||||
---@field name string: table name.
|
||||
---@field mtime number: db last modified time.
|
||||
---@field count fun(self: sqlite_tbl): integer
|
||||
---@field insert fun(self: sqlite_tbl, rows: table<string, any>|table<string, any>[]): integer
|
||||
---@field update fun(self: sqlite_tbl, specs: sqlite_query_update): boolean
|
||||
---@field get fun(self: sqlite_tbl, query: sqlite_query_select): table[]
|
||||
---@field remove fun(self: sqlite_tbl, where: sqlite_query_delete): boolean
|
||||
---@field map sqlite_map_func
|
||||
|
||||
---@class sqlite_opts @Sqlite3 Options (TODO: add sqlite option fields and description)
|
||||
---@class sqlite_blob @sqlite3 blob object
|
||||
|
||||
---@class sqlite_lib
|
||||
---@field cast fun(source: integer, as: string): string
|
||||
---@field julianday fun(timestring: string?): integer
|
||||
|
||||
-- NOTE: types are borrowed from plenary.nvim
|
||||
|
||||
---@class PlenaryPath
|
||||
---@field new fun(self: PlenaryPath|string, path: string?): PlenaryPath
|
||||
---@field absolute fun(): string
|
||||
---@field is_file fun(self: PlenaryPath): boolean
|
||||
---@field filename string
|
||||
---@field joinpath fun(self: PlenaryPath, ...): PlenaryPath
|
||||
---@field make_relative fun(self: PlenaryPath, cwd: string): string
|
||||
---@field path { sep: string }
|
||||
---@field rm fun(self: PlenaryPath, opts: { recursive: boolean }?): nil
|
||||
|
||||
---@class PlenaryScanDirOptions
|
||||
---@field hidden boolean if true hidden files will be added
|
||||
---@field add_dirs boolean if true dirs will also be added to the results
|
||||
---@field only_dirs boolean if true only dirs will be added to the results
|
||||
---@field respect_gitignore boolean if true will only add files that are not ignored by the git
|
||||
---@field depth integer depth on how deep the search should go
|
||||
---@field search_pattern string|string[]|fun(path: string): boolean regex for which files will be added, string, table of strings, or fn(e) -> boolean
|
||||
---@field on_insert fun(path: string): boolean Will be called for each element
|
||||
---@field silent boolean if true will not echo messages that are not accessible
|
||||
|
||||
---@alias scan_dir fun(path: string, opts: PlenaryScanDirOptions): string[]
|
||||
|
||||
-- NOTE: types are for telescope.nvim
|
||||
|
||||
---@alias TelescopeEntryDisplayer fun(items: string[]): table
|
||||
|
||||
---@class TelescopeEntryDisplayOptions
|
||||
---@field separator string?
|
||||
---@field hl_chars table<string, string>?
|
||||
---@field items string[]
|
||||
|
||||
---@class TelescopeEntryDisplay
|
||||
---@field create fun(opts: TelescopeEntryDisplayOptions): TelescopeEntryDisplayer
|
||||
|
||||
---@class TelescopeUtils
|
||||
---@field path_tail fun(path: string): string
|
||||
---@field transform_path fun(opts: table, path: string): string
|
||||
---@field buf_is_loaded fun(filename: string): boolean
|
||||
@ -1,108 +0,0 @@
|
||||
local uv = vim.loop
|
||||
local const = require "frecency.const"
|
||||
local Path = require "plenary.path"
|
||||
|
||||
local util = {}
|
||||
|
||||
-- stolen from penlight
|
||||
|
||||
---escape any Lua 'magic' characters in a string
|
||||
util.escape = function(str)
|
||||
return (str:gsub("[%-%.%+%[%]%(%)%$%^%%%?%*]", "%%%1"))
|
||||
end
|
||||
|
||||
util.string_isempty = function(str)
|
||||
return str == nil or str == ""
|
||||
end
|
||||
|
||||
util.filemask = function(mask)
|
||||
mask = util.escape(mask)
|
||||
return "^" .. mask:gsub("%%%*", ".*"):gsub("%%%?", ".") .. "$"
|
||||
end
|
||||
|
||||
util.path_is_ignored = function(filepath, ignore_patters)
|
||||
local i = ignore_patters and vim.tbl_flatten { ignore_patters, const.ignore_patterns } or const.ignore_patterns
|
||||
local is_ignored = false
|
||||
for _, pattern in ipairs(i) do
|
||||
if filepath:find(util.filemask(pattern)) ~= nil then
|
||||
is_ignored = true
|
||||
goto continue
|
||||
end
|
||||
end
|
||||
|
||||
::continue::
|
||||
return is_ignored
|
||||
end
|
||||
|
||||
util.path_exists = function(path)
|
||||
return Path:new(path):exists()
|
||||
end
|
||||
|
||||
util.path_invalid = function(path, ignore_patterns)
|
||||
local p = Path:new(path)
|
||||
if
|
||||
util.string_isempty(path)
|
||||
or (not p:is_file())
|
||||
or (not p:exists())
|
||||
or util.path_is_ignored(path, ignore_patterns)
|
||||
then
|
||||
return true
|
||||
else
|
||||
return false
|
||||
end
|
||||
end
|
||||
|
||||
util.confirm_deletion = function(num_of_entries)
|
||||
local question = "Telescope-Frecency: remove %d entries from SQLite3 database?"
|
||||
return vim.fn.confirm(question:format(num_of_entries), "&Yes\n&No", 2) == 1
|
||||
end
|
||||
|
||||
util.abort_remove_unlinked_files = function()
|
||||
---TODO: refactor all messages to a lua file. alarts.lua?
|
||||
vim.notify "TelescopeFrecency: validation aborted."
|
||||
end
|
||||
|
||||
util.tbl_match = function(field, val, tbl)
|
||||
return vim.tbl_filter(function(t)
|
||||
return t[field] == val
|
||||
end, tbl)
|
||||
end
|
||||
|
||||
---Wrappe around Path:new():make_relative
|
||||
---@return string
|
||||
util.path_relative = function(path, cwd)
|
||||
return Path:new(path):make_relative(cwd)
|
||||
end
|
||||
|
||||
---Given a filename, check if there's a buffer with the given name.
|
||||
---@return boolean
|
||||
util.buf_is_loaded = function(filename)
|
||||
return vim.api.nvim_buf_is_loaded(vim.fn.bufnr(filename))
|
||||
end
|
||||
|
||||
util.include_unindexed = function(files, ws_path)
|
||||
local is_indexed = {}
|
||||
for _, item in ipairs(files) do
|
||||
is_indexed[item.path] = true
|
||||
end
|
||||
|
||||
local scan_opts = {
|
||||
respect_gitignore = true,
|
||||
depth = 100,
|
||||
hidden = true,
|
||||
search_pattern = function(file)
|
||||
return not is_indexed[file]
|
||||
end,
|
||||
}
|
||||
|
||||
-- TODO: make sure scandir unindexed have opts.ignore_patterns applied
|
||||
-- TODO: make filters handle mulitple directories
|
||||
local unindexed_files = require("plenary.scandir").scan_dir(ws_path, scan_opts)
|
||||
for _, file in pairs(unindexed_files) do
|
||||
if not util.path_is_ignored(file) then -- this causes some slowdown on large dirs
|
||||
table.insert(files, { id = 0, path = file, count = 0, directory_id = 0, score = 0 })
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
return util
|
||||
28
lua/frecency/web_devicons.lua
Normal file
28
lua/frecency/web_devicons.lua
Normal file
@ -0,0 +1,28 @@
|
||||
---@class WebDeviconsModule
|
||||
---@field get_icon fun(name: string?, ext: string?, opts: table?): string, string
|
||||
|
||||
---@class WebDevicons
|
||||
---@field is_enabled boolean
|
||||
---@field private web_devicons WebDeviconsModule
|
||||
local WebDevicons = {}
|
||||
|
||||
---@param enable boolean
|
||||
---@return WebDevicons
|
||||
WebDevicons.new = function(enable)
|
||||
local ok, web_devicons = pcall(require, "nvim-web-devicons")
|
||||
return setmetatable({ is_enabled = enable and ok, web_devicons = web_devicons }, { __index = WebDevicons })
|
||||
end
|
||||
|
||||
---@param name string?
|
||||
---@param ext string?
|
||||
---@param opts table?
|
||||
---@return string
|
||||
---@return string
|
||||
function WebDevicons:get_icon(name, ext, opts)
|
||||
if self.is_enabled then
|
||||
return self.web_devicons.get_icon(name, ext, opts)
|
||||
end
|
||||
return "", ""
|
||||
end
|
||||
|
||||
return WebDevicons
|
||||
@ -1,29 +1,21 @@
|
||||
local telescope = (function()
|
||||
local ok, m = pcall(require, "telescope")
|
||||
if not ok then
|
||||
error "telescope-frecency: couldn't find telescope.nvim, please install"
|
||||
end
|
||||
return m
|
||||
end)()
|
||||
local frecency = require "frecency"
|
||||
|
||||
local picker = require "frecency.picker"
|
||||
|
||||
return telescope.register_extension {
|
||||
setup = picker.setup,
|
||||
return require("telescope").register_extension {
|
||||
setup = frecency.setup,
|
||||
health = function()
|
||||
if ({ pcall(require, "sqlite") })[1] then
|
||||
vim.health.report_ok "sql.nvim installed."
|
||||
if vim.F.npcall(require, "sqlite") then
|
||||
vim.health.ok "sqlite.lua installed."
|
||||
else
|
||||
vim.health.report_error "sql.nvim is required for telescope-frecency.nvim to work."
|
||||
vim.health.error "sqlite.lua is required for telescope-frecency.nvim to work."
|
||||
end
|
||||
if ({ pcall(require, "nvim-web-devicons") })[1] then
|
||||
vim.health.report_ok "nvim-web-devicons installed."
|
||||
if vim.F.npcall(require, "nvim-web-devicons") then
|
||||
vim.health.ok "nvim-web-devicons installed."
|
||||
else
|
||||
vim.health.report_info "nvim-web-devicons is not installed."
|
||||
vim.health.info "nvim-web-devicons is not installed."
|
||||
end
|
||||
end,
|
||||
exports = {
|
||||
frecency = picker.fd,
|
||||
complete = picker.complete,
|
||||
frecency = frecency.start,
|
||||
complete = frecency.complete,
|
||||
},
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user