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:
JINNOUCHI Yasushi 2023-08-06 16:02:37 +09:00 committed by GitHub
parent 1b1cf6aead
commit 1f32091e2b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
24 changed files with 1806 additions and 661 deletions

60
.github/workflows/ci.yml vendored Normal file
View 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
View 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
View File

@ -0,0 +1,14 @@
{
"diagnostics": {
"globals": [
"describe",
"it",
"vim"
]
},
"runtime.version": "LuaJIT",
"runtime.path": [
"lua/?.lua",
"lua/?/init.lua"
]
}

View File

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

View 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

View File

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

View File

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

View 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
View 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
View 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
View 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
View 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,
}

View File

@ -1,338 +1,299 @@
local has_devicons, devicons = pcall(require, "nvim-web-devicons") local log = require "plenary.log"
local p = require "plenary.path" local Path = require "plenary.path" --[[@as PlenaryPath]]
local util = require "frecency.util"
local os_home = vim.loop.os_homedir()
local os_path_sep = p.path.sep
local actions = require "telescope.actions" local actions = require "telescope.actions"
local conf = require("telescope.config").values local config_values = require("telescope.config").values
local entry_display = require "telescope.pickers.entry_display"
local finders = require "telescope.finders"
local pickers = require "telescope.pickers" local pickers = require "telescope.pickers"
local sorters = require "telescope.sorters" local sorters = require "telescope.sorters"
local ts_util = require "telescope.utils" local utils = require "telescope.utils" --[[@as TelescopeUtils]]
local db = require "frecency.db" local uv = vim.loop or vim.uv
---TODO: Describe FrecencyPicker fields
---@class FrecencyPicker ---@class FrecencyPicker
---@field db FrecencyDB: where the files will be stored ---@field private config FrecencyPickerConfig
---@field results table ---@field private database FrecencyDatabase
---@field active_filter string ---@field private finder FrecencyFinder
---@field active_filter_tag string ---@field private fs FrecencyFS
---@field previous_buffer string ---@field private lsp_workspaces string[]
---@field cwd string ---@field private recency FrecencyRecency
---@field lsp_workspaces table ---@field private results table[]
---@field picker table ---@field private workspace string?
---@field updated boolean: true if a new entry is added into DB ---@field private workspace_tag_regex string
local m = { local Picker = {}
results = {},
active_filter = nil, ---@class FrecencyPickerConfig
active_filter_tag = nil, ---@field default_workspace_tag string?
last_filter = nil, ---@field editing_bufnr integer
previous_buffer = nil, ---@field filter_delimiter string
cwd = nil, ---@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 = {}, lsp_workspaces = {},
picker = {}, recency = recency,
updated = false, results = {},
} }, { __index = Picker })
local d = self.config.filter_delimiter
m.__index = m self.workspace_tag_regex = "^%s*(" .. d .. "(%S+)" .. d .. ")"
return self
---@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 })
end end
---returns `true` if workspaces exit ---@class FrecencyPickerOptions
---@param bufnr integer ---@field cwd string
---@param force boolean? ---@field path_display
---@return boolean workspaces_exist ---| "hidden"
m.fetch_lsp_workspaces = function(bufnr, force) ---| "tail"
if not vim.tbl_isempty(m.lsp_workspaces) and not force then ---| "absolute"
return true ---| "smart"
end ---| "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) ---@param opts FrecencyPickerOptions?
if not vim.tbl_isempty(lsp_workspaces) then function Picker:start(opts)
m.lsp_workspaces = lsp_workspaces opts = vim.tbl_extend("force", {
return true cwd = uv.cwd(),
end path_display = function(picker_opts, path)
return self:default_path_display(picker_opts, path)
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)())
end, 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 end
---Find files local filepath_formatter = self:filepath_formatter(opts)
---@param opts table: telescope picker opts local finder = self.finder:start(filepath_formatter, self.results, {
m.fd = function(opts) need_scandir = self.workspace and self.config.show_unindexed and true or false,
opts = opts or {} workspace = self.workspace,
workspace_tag = self.config.initial_workspace_tag,
})
if not opts.path_display then local picker = pickers.new(opts, {
opts.path_display = function(path_opts, filename) prompt_title = "Frecency",
local original_filename = filename 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) function Picker:discard_results()
if not m.active_filter then self.results = {}
if vim.startswith(filename, os_home) then end
filename = "~/" .. p:new(filename):make_relative(os_home)
elseif filename ~= original_filename then --- 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 filename = "./" .. filename
end end
end end
return filename return filename
end 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 end
m.previous_buffer, m.cwd, m.opts = vim.fn.bufnr "%", vim.fn.expand(opts.cwd or vim.loop.cwd()), opts ---@private
-- TODO: should we update this every time it calls frecency on other buffers? ---@param workspace string?
m.fetch_lsp_workspaces(m.previous_buffer) ---@param datetime string? ISO8601 format string
m.update() ---@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 = { local start_sort = os.clock()
prompt_title = "Frecency", table.sort(files, function(a, b)
finder = finders.new_table { results = m.results, entry_maker = m.maker }, return a.score > b.score
previewer = conf.file_previewer(opts), end)
sorter = sorters.get_substr_matcher(opts), log.debug(("it takes %f seconds in sorting"):format(os.clock() - start_sort))
} return files
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 }
end 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 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() actions.select_default:replace_if(function()
return vim.fn.complete_info().pum_visible == 1 return vim.fn.complete_info().pum_visible == 1
end, function() 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) local accept_completion = vim.api.nvim_replace_termcodes(keys, true, false, true)
vim.api.nvim_feedkeys(accept_completion, "n", true) vim.api.nvim_feedkeys(accept_completion, "n", true)
end) end)
return true return true
end end
m.picker = pickers.new(opts, picker_opts) ---@private
m.picker:find() ---@param bufnr integer
m.set_prompt_options(m.picker.prompt_bufnr) ---@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 end
---TODO: this seems to be forgotten and just exported in old implementation. ---@alias FrecencyFilepathFormatter fun(workspace: string?): fun(filename: string): string): string
---@return table
m.workspace_tags = function() ---@private
-- Add user config workspaces. ---@param picker_opts table
-- TODO: validate that workspaces are existing directories ---@return FrecencyFilepathFormatter
local tags = {} function Picker:filepath_formatter(picker_opts)
for k, _ in pairs(m.config.workspaces) do ---@param workspace string?
table.insert(tags, k) return function(workspace)
local opts = {}
for k, v in pairs(picker_opts) do
opts[k] = v
end end
opts.cwd = workspace or self.fs.os_homedir
-- Add CWD filter return function(filename)
-- NOTE: hmmm :cwd::lsp: is easier to write. return utils.transform_path(opts, filename)
table.insert(tags, "CWD")
-- Add LSP workpace(s)
if m.fetch_lsp_workspaces(m.previous_buffer, true) then
table.insert(tags, "LSP")
end 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
end end
---Setup Frecency Picker return 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

42
lua/frecency/recency.lua Normal file
View 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

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

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

View 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"

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

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

View File

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

View 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

View File

@ -1,29 +1,21 @@
local telescope = (function() local frecency = require "frecency"
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 picker = require "frecency.picker" return require("telescope").register_extension {
setup = frecency.setup,
return telescope.register_extension {
setup = picker.setup,
health = function() health = function()
if ({ pcall(require, "sqlite") })[1] then if vim.F.npcall(require, "sqlite") then
vim.health.report_ok "sql.nvim installed." vim.health.ok "sqlite.lua installed."
else 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 end
if ({ pcall(require, "nvim-web-devicons") })[1] then if vim.F.npcall(require, "nvim-web-devicons") then
vim.health.report_ok "nvim-web-devicons installed." vim.health.ok "nvim-web-devicons installed."
else else
vim.health.report_info "nvim-web-devicons is not installed." vim.health.info "nvim-web-devicons is not installed."
end end
end, end,
exports = { exports = {
frecency = picker.fd, frecency = frecency.start,
complete = picker.complete, complete = frecency.complete,
}, },
} }